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,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;
|
||||
Reference in New Issue
Block a user