feat(prayer-times): add Jakarta prayer time plugins and enhance ntfy integration
- Introduced two new plugins: `jakarta-prayer-time-parser-for-ntfy` for formatting prayer times as ntfy Markdown, and `jakarta-prayer-time-today-and-tomorrow` for fetching prayer times from Kemenag for today and tomorrow. - Enhanced `ntfy.js` to support improved Markdown header handling and added a utility function for Markdown configuration validation. - Updated plugin metadata and descriptions for clarity on functionality and usage. - Implemented robust error handling and logging for better debugging and user feedback.
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const PAGE_URL = "https://bimasislam.kemenag.go.id/jadwalshalat";
|
||||
const API_URL = "https://bimasislam.kemenag.go.id/ajax/getShalatbln";
|
||||
|
||||
/** Default Kemenag hashes: DKI Jakarta / Kota Jakarta. */
|
||||
const DEFAULT_X = "c51ce410c124a10e0db5e4b97fc2af39";
|
||||
const DEFAULT_Y = "58a2fc6ed39fd083f55d4182bf88826d";
|
||||
const DEFAULT_TIMEZONE = "Asia/Jakarta";
|
||||
|
||||
const BROWSER_HEADERS = {
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": "en-US,en;q=0.9,id;q=0.8",
|
||||
"cache-control": "no-cache",
|
||||
pragma: "no-cache",
|
||||
};
|
||||
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function unwrapScalar(value) {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
function monthKey(d) {
|
||||
return `${d.year()}-${d.month() + 1}`;
|
||||
}
|
||||
|
||||
function parseCookies(setCookie) {
|
||||
if (!setCookie) return "";
|
||||
return setCookie
|
||||
.split(/,(?=[^;,]+=)/)
|
||||
.map((cookie) => cookie.split(";")[0])
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
async function getSessionCookie() {
|
||||
const cookieFetch = await fetch(PAGE_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...BROWSER_HEADERS,
|
||||
"upgrade-insecure-requests": "1",
|
||||
},
|
||||
});
|
||||
|
||||
const cookies = parseCookies(cookieFetch.headers.get("set-cookie"));
|
||||
if (!cookies) {
|
||||
throw new Error(
|
||||
`Prayer-time session cookie missing (HTTP ${cookieFetch.status})`,
|
||||
);
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function fetchPrayerMonth(cookies, month, year, x, y) {
|
||||
const resp = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json, text/javascript, */*; q=0.01",
|
||||
"accept-language": BROWSER_HEADERS["accept-language"],
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
pragma: "no-cache",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
cookie: cookies,
|
||||
Referer: PAGE_URL,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
x,
|
||||
y,
|
||||
bln: String(month),
|
||||
thn: String(year),
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
const responseText = await resp.text();
|
||||
let responseData;
|
||||
try {
|
||||
responseData = JSON.parse(responseText);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Prayer-time API returned invalid JSON (HTTP ${resp.status}) for ${year}-${month}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(
|
||||
`Prayer-time API request failed with HTTP ${resp.status} for ${year}-${month}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
data: responseData,
|
||||
status: resp.status,
|
||||
month: Number(month),
|
||||
year: Number(year),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one day's record from a monthly API payload.
|
||||
* Accepts `{ data: { "YYYY-MM-DD": {...} } }` or a date-keyed object.
|
||||
*/
|
||||
function getDateData(monthResponse, dateKey) {
|
||||
const candidates = [
|
||||
monthResponse?.data?.data,
|
||||
monthResponse?.data,
|
||||
monthResponse,
|
||||
];
|
||||
for (const map of candidates) {
|
||||
if (map && typeof map === "object" && !Array.isArray(map) && map[dateKey]) {
|
||||
const day = map[dateKey];
|
||||
if (typeof day === "object" && !Array.isArray(day)) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`No prayer times for ${dateKey}`);
|
||||
}
|
||||
|
||||
async function fetchPrayerTimes(ctx) {
|
||||
const timezoneName = unwrapScalar(ctx.config?.timezone) || DEFAULT_TIMEZONE;
|
||||
const x = unwrapScalar(ctx.config?.x) || DEFAULT_X;
|
||||
const y = unwrapScalar(ctx.config?.y) || DEFAULT_Y;
|
||||
|
||||
const today = dayjs().tz(timezoneName);
|
||||
if (!today.isValid()) {
|
||||
throw new Error(`invalid timezone "${timezoneName}"`);
|
||||
}
|
||||
const tomorrow = today.add(1, "day");
|
||||
const todayDate = today.format("YYYY-MM-DD");
|
||||
const tomorrowDate = tomorrow.format("YYYY-MM-DD");
|
||||
|
||||
log.info(
|
||||
{ today: todayDate, tomorrow: tomorrowDate, timezone: timezoneName },
|
||||
"jakarta-prayer-time-today-and-tomorrow: fetching",
|
||||
);
|
||||
|
||||
const cookies = await getSessionCookie();
|
||||
|
||||
const needed = [
|
||||
{ key: monthKey(today), month: today.month() + 1, year: today.year() },
|
||||
{ key: monthKey(tomorrow), month: tomorrow.month() + 1, year: tomorrow.year() },
|
||||
];
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
for (const item of needed) {
|
||||
if (seen.has(item.key)) continue;
|
||||
seen.add(item.key);
|
||||
unique.push(item);
|
||||
}
|
||||
|
||||
const monthResponses = {};
|
||||
await Promise.all(
|
||||
unique.map(async (item) => {
|
||||
monthResponses[item.key] = await fetchPrayerMonth(
|
||||
cookies,
|
||||
item.month,
|
||||
item.year,
|
||||
x,
|
||||
y,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const todayData = getDateData(monthResponses[monthKey(today)], todayDate);
|
||||
const tomorrowData = getDateData(
|
||||
monthResponses[monthKey(tomorrow)],
|
||||
tomorrowDate,
|
||||
);
|
||||
|
||||
const output = {
|
||||
ok: true,
|
||||
today: todayData,
|
||||
tomorrow: tomorrowData,
|
||||
todayDate,
|
||||
tomorrowDate,
|
||||
timezone: timezoneName,
|
||||
};
|
||||
|
||||
log.info(
|
||||
{
|
||||
todayFound: Boolean(output.today),
|
||||
tomorrowFound: Boolean(output.tomorrow),
|
||||
monthsFetched: unique.length,
|
||||
},
|
||||
"jakarta-prayer-time-today-and-tomorrow: extracted",
|
||||
);
|
||||
|
||||
return {
|
||||
output,
|
||||
context: { ...passContext(ctx), ...output },
|
||||
};
|
||||
}
|
||||
|
||||
fetchPrayerTimes.meta = {
|
||||
description:
|
||||
"Fetch Kemenag Bimas Islam prayer times for today and tomorrow (Asia/Jakarta by default).",
|
||||
previewConfigKey: "timezone",
|
||||
tags: ["HTTP", "Prayer Times"],
|
||||
config: {
|
||||
timezone: {
|
||||
type: "string",
|
||||
default: DEFAULT_TIMEZONE,
|
||||
description: "IANA timezone used to pick today/tomorrow",
|
||||
},
|
||||
x: {
|
||||
type: "string",
|
||||
default: DEFAULT_X,
|
||||
description: "Kemenag kabupaten hash (x). Default is DKI Jakarta",
|
||||
},
|
||||
y: {
|
||||
type: "string",
|
||||
default: DEFAULT_Y,
|
||||
description: "Kemenag kota hash (y). Default is Kota Jakarta",
|
||||
},
|
||||
},
|
||||
input: {},
|
||||
output: {
|
||||
ok: { type: "boolean" },
|
||||
today: { type: "any", description: "Prayer-time record for today" },
|
||||
tomorrow: { type: "any", description: "Prayer-time record for tomorrow" },
|
||||
todayDate: { type: "string", description: "YYYY-MM-DD in the configured timezone" },
|
||||
tomorrowDate: { type: "string", description: "YYYY-MM-DD in the configured timezone" },
|
||||
timezone: { type: "string" },
|
||||
},
|
||||
context: {
|
||||
ok: { type: "boolean" },
|
||||
today: { type: "any" },
|
||||
tomorrow: { type: "any" },
|
||||
todayDate: { type: "string" },
|
||||
tomorrowDate: { type: "string" },
|
||||
timezone: { type: "string" },
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: {
|
||||
timezone: "Asia/Jakarta",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default fetchPrayerTimes;
|
||||
Reference in New Issue
Block a user