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:
2026-08-30 21:25:09 +07:00
parent 80e9c91b88
commit 67ed3eecca
8 changed files with 490 additions and 3 deletions
@@ -0,0 +1,8 @@
{
"id": "jakarta-prayer-time-today-and-tomorrow",
"name": "Jakarta prayer times (today & tomorrow)",
"version": "0.2.0",
"jerapah": ">=0.1.0 <1.0.0",
"main": "script.js",
"description": "Fetch Kemenag prayer times for today and tomorrow using dayjs (Asia/Jakarta)"
}
@@ -0,0 +1,10 @@
{
"name": "jflow-plugin-jakarta-prayer-time-today-and-tomorrow",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "JerapahFlow plugin: fetch Jakarta prayer times for today and tomorrow",
"dependencies": {
"dayjs": "^1.11.13"
}
}
@@ -0,0 +1,22 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
dayjs:
specifier: ^1.11.13
version: 1.11.21
packages:
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
snapshots:
dayjs@1.11.21: {}
@@ -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;