- 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.
256 lines
6.6 KiB
JavaScript
256 lines
6.6 KiB
JavaScript
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;
|