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;