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
+15 -3
View File
@@ -15,13 +15,24 @@ function ntfyHeaders(ctx) {
headers.Title = ctx.data.title; headers.Title = ctx.data.title;
} }
if (ctx.config?.markdown === true) { if (isMarkdownEnabled(ctx.config?.markdown)) {
headers.md = "true"; // ntfy accepts X-Markdown / Markdown / md with true | 1 | yes
headers.Markdown = "yes";
log.info("ntfy: Markdown enabled");
} }
return headers; return headers;
} }
function isMarkdownEnabled(value) {
if (value === true || value === 1) return true;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
return v === "true" || v === "yes" || v === "1";
}
return false;
}
function resolveFingerprintKey(config) { function resolveFingerprintKey(config) {
const fingerprint = config?.fingerprint; const fingerprint = config?.fingerprint;
if (fingerprint === true) return "fingerprint:ntfy"; if (fingerprint === true) return "fingerprint:ntfy";
@@ -156,7 +167,8 @@ ntfy.meta = {
markdown: { markdown: {
type: "boolean", type: "boolean",
default: false, default: false,
description: "Send as Markdown (ntfy md header)", description:
"Render body as Markdown (sets Markdown: yes; aliases X-Markdown / md also accepted by ntfy)",
}, },
fingerprint: { fingerprint: {
type: "string", type: "string",
@@ -0,0 +1,8 @@
{
"id": "jakarta-prayer-time-parser-for-ntfy",
"name": "Jakarta prayer times → ntfy",
"version": "0.2.0",
"jerapah": ">=0.1.0 <1.0.0",
"main": "script.js",
"description": "Format today/tomorrow prayer times as ntfy Markdown (headings + tables)"
}
@@ -0,0 +1,7 @@
{
"name": "jflow-plugin-jakarta-prayer-time-parser-for-ntfy",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "JerapahFlow plugin: format Jakarta prayer times as ntfy Markdown"
}
@@ -0,0 +1,165 @@
const PRAYERS = [
["imsak", "Imsak"],
["subuh", "Subuh"],
["terbit", "Terbit"],
["dhuha", "Dhuha"],
["dzuhur", "Dzuhur"],
["ashar", "Ashar"],
["maghrib", "Maghrib"],
["isya", "Isya"],
];
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 requireDay(day, label) {
if (day == null || typeof day !== "object" || Array.isArray(day)) {
throw new Error(
`parser: missing ${label} prayer-time data (run plugin/jakarta-prayer-time-today-and-tomorrow first)`,
);
}
return day;
}
function dayCaption(day, fallbackDate) {
const tanggal = unwrapScalar(day.tanggal);
if (tanggal) return tanggal;
return unwrapScalar(fallbackDate);
}
function formatDayTable(heading, day) {
const lines = [
`### ${heading}`,
"",
"| Prayer | Time |",
"| --- | --- |",
];
for (const [key, label] of PRAYERS) {
const time = unwrapScalar(day[key]) || "—";
lines.push(`| ${label} | ${time} |`);
}
return lines.join("\n");
}
async function parsePrayerTimesForNtfy(ctx) {
const data = ctx?.data && typeof ctx.data === "object" ? ctx.data : {};
const today = requireDay(data.today, "today");
const tomorrow = requireDay(data.tomorrow, "tomorrow");
const location = unwrapScalar(ctx.config?.location) || "Jakarta";
const todayCaption = dayCaption(today, data.todayDate);
const tomorrowCaption = dayCaption(tomorrow, data.tomorrowDate);
const todayHeading = todayCaption ? `Today — ${todayCaption}` : "Today";
const tomorrowHeading = tomorrowCaption
? `Tomorrow — ${tomorrowCaption}`
: "Tomorrow";
const message = [
formatDayTable(todayHeading, today),
"",
"---",
"",
formatDayTable(tomorrowHeading, tomorrow),
].join("\n");
const title = todayCaption
? `Prayer Times (${location}): ${todayCaption}`
: `Prayer Times (${location})`;
log.info(
{ location, todayCaption, tomorrowCaption },
"jakarta-prayer-time-parser-for-ntfy: formatted",
);
const output = { ok: true, title, message };
return { output, context: { ...passContext(ctx), ...output } };
}
parsePrayerTimesForNtfy.meta = {
description:
"Format today/tomorrow prayer times as ntfy Markdown (headings + tables). Set ntfy.js config.markdown to true.",
previewConfigKey: "location",
tags: ["ntfy", "Markdown", "Prayer Times"],
config: {
location: {
type: "string",
default: "Jakarta",
description: "City name used in the ntfy title",
},
},
input: {
today: {
type: "any",
required: true,
description: "Today's prayer-time record from the fetcher",
},
tomorrow: {
type: "any",
required: true,
description: "Tomorrow's prayer-time record from the fetcher",
},
todayDate: {
type: "string",
required: false,
description: "YYYY-MM-DD fallback when today.tanggal is missing",
},
tomorrowDate: {
type: "string",
required: false,
description: "YYYY-MM-DD fallback when tomorrow.tanggal is missing",
},
},
output: {
ok: { type: "boolean" },
title: { type: "string", description: "ntfy Title header" },
message: { type: "string", description: "Markdown body (headings + tables)" },
},
context: {
ok: { type: "boolean" },
title: { type: "string" },
message: { type: "string" },
},
example: {
data: {
todayDate: "2026-08-30",
tomorrowDate: "2026-08-31",
today: {
tanggal: "Minggu, 30/08/2026",
imsak: "04:32",
subuh: "04:42",
terbit: "05:57",
dhuha: "06:26",
dzuhur: "11:58",
ashar: "15:18",
maghrib: "17:58",
isya: "19:08",
},
tomorrow: {
tanggal: "Senin, 31/08/2026",
imsak: "04:32",
subuh: "04:42",
terbit: "05:57",
dhuha: "06:26",
dzuhur: "11:58",
ashar: "15:17",
maghrib: "17:58",
isya: "19:08",
},
},
config: { location: "Jakarta" },
},
};
export default parsePrayerTimesForNtfy;
@@ -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;