mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
Initial commit
Watches district.in showtimes and sends a max-priority phone notification the moment tickets for a given movie, date, format and cinema go on sale.
This commit is contained in:
commit
b70968d259
15 files changed
+1164
No files matched your search
@@ -0,0 +1,62 @@
|
||||
// Configuration is read entirely from environment variables (see .env.example).
|
||||
// Load a .env file with Node's built-in flag: node --env-file-if-exists=.env src/index.js
|
||||
|
||||
function required(name) {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`${name} is required — copy .env.example to .env and fill it in`);
|
||||
return v;
|
||||
}
|
||||
|
||||
const num = (name, fallback) => {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") return fallback;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) throw new Error(`${name} must be a number, got "${v}"`);
|
||||
return n;
|
||||
};
|
||||
|
||||
export function loadConfig() {
|
||||
const targetDate = required("TARGET_DATE");
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) {
|
||||
throw new Error(`TARGET_DATE must be YYYY-MM-DD, got "${targetDate}"`);
|
||||
}
|
||||
const tzOffsetMinutes = num("TZ_OFFSET_MINUTES", 330);
|
||||
|
||||
// Retire at local midnight following the target date, unless overridden.
|
||||
const expiryUtc = process.env.EXPIRY_UTC ||
|
||||
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
|
||||
|
||||
return {
|
||||
// what to watch
|
||||
movieCode: required("MOVIE_CODE"),
|
||||
contentId: required("CONTENT_ID"),
|
||||
cityKey: required("CITY_KEY"),
|
||||
lat: required("LAT"),
|
||||
lng: required("LNG"),
|
||||
targetDate,
|
||||
cinemaId: process.env.CINEMA_ID || "", // empty = any cinema
|
||||
formatTag: process.env.FORMAT_TAG ?? "imax_2d", // empty + empty match = any format
|
||||
formatMatch: process.env.FORMAT_MATCH ?? "IMAX",
|
||||
formatLabel: process.env.FORMAT_LABEL || "IMAX",
|
||||
bookingUrl: process.env.BOOKING_URL || "",
|
||||
|
||||
// notifications
|
||||
ntfyUrl: (process.env.NTFY_URL || "https://ntfy.sh").replace(/\/+$/, ""),
|
||||
ntfyTopic: required("NTFY_TOPIC"),
|
||||
ntfyToken: process.env.NTFY_TOKEN || "",
|
||||
|
||||
// behaviour
|
||||
intervalMs: num("CHECK_INTERVAL_SECONDS", 60) * 1000,
|
||||
failureThreshold: num("FAILURE_THRESHOLD", 6),
|
||||
tzOffsetMinutes,
|
||||
heartbeatHour: num("HEARTBEAT_HOUR", 9), // -1 disables the daily heartbeat
|
||||
currency: process.env.CURRENCY || "₹",
|
||||
expiryUtc,
|
||||
|
||||
// runtime
|
||||
statePath: process.env.STATE_PATH || "./state.json",
|
||||
port: num("PORT", 8080),
|
||||
proxyUrl: process.env.PROXY_URL || "",
|
||||
dispatcher: null, // set by attachProxy() when PROXY_URL is present
|
||||
};
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
// Entry point. Runs one check (default) or loops forever with a status server (--loop).
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { loadConfig } from "./config.js";
|
||||
import {
|
||||
decide, defaultState, extractSessions, checkDistrict, notify, heartbeatDue, localDate,
|
||||
} from "./monitor.js";
|
||||
|
||||
const ts = () => new Date().toISOString();
|
||||
|
||||
// Last tick's outcome, surfaced by the status server. Persisted state records only
|
||||
// transitions, so "is it ticking right now" has to live in memory.
|
||||
const lastTick = { at: null, kind: null, reason: null };
|
||||
|
||||
function readState(cfg) {
|
||||
try {
|
||||
return { ...defaultState(), ...JSON.parse(readFileSync(cfg.statePath, "utf8")) };
|
||||
} catch {
|
||||
return defaultState(); // missing or corrupt file: start clean
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(cfg, state) {
|
||||
mkdirSync(dirname(cfg.statePath), { recursive: true });
|
||||
const tmp = cfg.statePath + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify(state, null, 1));
|
||||
renameSync(tmp, cfg.statePath); // atomic: a killed process never leaves a torn file
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes District requests through a SOCKS5 proxy. Notifications stay direct, so the
|
||||
* alerting path never depends on the proxy. Needed on datacenter hosts whose IPs the
|
||||
* site's WAF blocks.
|
||||
*/
|
||||
async function attachProxy(cfg) {
|
||||
if (!cfg.proxyUrl) return;
|
||||
const u = new URL(cfg.proxyUrl);
|
||||
const { socksDispatcher } = await import("fetch-socks");
|
||||
// Node's built-in fetch bundles its own undici and rejects a dispatcher built by the
|
||||
// npm undici ("invalid onRequestStart method"), so use undici's fetch for both halves.
|
||||
const { fetch: undiciFetch } = await import("undici");
|
||||
globalThis.fetch = undiciFetch;
|
||||
cfg.dispatcher = socksDispatcher({
|
||||
type: 5,
|
||||
host: u.hostname,
|
||||
port: Number(u.port),
|
||||
...(u.username ? { userId: decodeURIComponent(u.username), password: decodeURIComponent(u.password) } : {}),
|
||||
});
|
||||
console.log(ts(), `checks routed via SOCKS5 ${u.hostname}:${u.port}`);
|
||||
}
|
||||
|
||||
export async function tick(cfg) {
|
||||
const now = new Date();
|
||||
const state = readState(cfg);
|
||||
if (state.retired) return "retired";
|
||||
|
||||
let input;
|
||||
if (now.getTime() >= Date.parse(cfg.expiryUtc)) {
|
||||
input = { kind: "closed", now }; // decide() retires before it looks at kind
|
||||
} else {
|
||||
// Best-effort daily probe: showDates only exists in a 200 body, and the target date
|
||||
// returns an empty 204 while closed. Its failure never counts toward the streak.
|
||||
let probeMax = null;
|
||||
if (heartbeatDue(state, now, cfg) && !state.brokenAlerted) {
|
||||
try {
|
||||
const probe = await checkDistrict(cfg, localDate(now, cfg.tzOffsetMinutes));
|
||||
if (probe.kind === "open") {
|
||||
const dates = probe.data?.meta?.showDates ?? [];
|
||||
if (dates.length) probeMax = dates.slice().sort().at(-1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(ts(), "showDates probe failed (best-effort):", err.message);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await checkDistrict(cfg, cfg.targetDate);
|
||||
input = res.kind === "open"
|
||||
? { kind: "open", extract: extractSessions(res.data, cfg), now, probeMax }
|
||||
: { kind: "closed", now, probeMax };
|
||||
} catch (err) {
|
||||
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
|
||||
}
|
||||
}
|
||||
|
||||
const { baseState, alerts } = decide(state, input, cfg);
|
||||
let merged = baseState;
|
||||
for (const alert of alerts) {
|
||||
try {
|
||||
await notify(cfg, alert);
|
||||
merged = { ...merged, ...alert.statePatch };
|
||||
console.log(ts(), "alert sent:", alert.title);
|
||||
} catch (err) {
|
||||
console.error(ts(), `notify failed for "${alert.title}" (will retry next tick):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (JSON.stringify(merged) !== JSON.stringify(state)) {
|
||||
try {
|
||||
writeState(cfg, merged);
|
||||
} catch (err) {
|
||||
// Degrades to duplicate pushes, never missed ones — the right direction here.
|
||||
console.error(ts(), "state write failed (may duplicate pushes):", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
lastTick.at = ts();
|
||||
lastTick.kind = input.kind;
|
||||
lastTick.reason = input.reason ?? null;
|
||||
console.log(ts(), "tick:", input.kind, input.kind === "error" ? `(${input.reason})` : "", "failures:", merged.failures);
|
||||
return input.kind;
|
||||
}
|
||||
|
||||
function startStatusServer(cfg) {
|
||||
createServer(async (req, res) => {
|
||||
const send = (code, obj) => {
|
||||
res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(obj, null, 1));
|
||||
};
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (url.pathname === "/healthz") return send(200, { ok: true });
|
||||
const state = readState(cfg);
|
||||
const out = {
|
||||
now: ts(),
|
||||
targetDate: cfg.targetDate,
|
||||
proxied: Boolean(cfg.dispatcher),
|
||||
retired: state.retired,
|
||||
lastTick,
|
||||
state, // never includes topic, token or proxy credentials
|
||||
};
|
||||
if (url.searchParams.get("force") === "1") {
|
||||
// Side-effect-free: no state writes, no notifications.
|
||||
try {
|
||||
const r = await checkDistrict(cfg, cfg.targetDate);
|
||||
out.live = r.kind === "open"
|
||||
? { kind: "open", ...extractSessions(r.data, cfg) }
|
||||
: { kind: "closed" };
|
||||
} catch (err) {
|
||||
out.live = { kind: "error", reason: String(err.message ?? err) };
|
||||
}
|
||||
}
|
||||
send(200, out);
|
||||
} catch (err) {
|
||||
send(500, { error: String(err.message ?? err) });
|
||||
}
|
||||
}).listen(cfg.port, "0.0.0.0", () => console.log(ts(), `status server on :${cfg.port}`));
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
await attachProxy(cfg);
|
||||
|
||||
if (process.argv.includes("--loop")) {
|
||||
startStatusServer(cfg);
|
||||
console.log(ts(), `watching ${cfg.targetDate} every ${cfg.intervalMs / 1000}s (expires ${cfg.expiryUtc})`);
|
||||
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(0));
|
||||
for (;;) {
|
||||
const kind = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; });
|
||||
if (kind === "retired") break;
|
||||
await new Promise((r) => setTimeout(r, cfg.intervalMs));
|
||||
}
|
||||
} else {
|
||||
await tick(cfg);
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
// Core logic: District API client, session matching, alert state machine, ntfy publisher.
|
||||
// Everything here is runtime-agnostic and side-effect-free except checkDistrict/notify.
|
||||
|
||||
export class CheckError extends Error {}
|
||||
|
||||
const UA = "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36";
|
||||
|
||||
/**
|
||||
* District's web client mints this token locally on every request — it is not
|
||||
* server-issued, and any value of this shape is accepted.
|
||||
*/
|
||||
export function guestToken() {
|
||||
let digits = "";
|
||||
for (let i = 0; i < 18; i++) digits += Math.floor(Math.random() * 10);
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let suffix = "";
|
||||
for (let i = 0; i < 11; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
return `${Date.now()}_${digits}_${suffix}`;
|
||||
}
|
||||
|
||||
/** API showTime is UTC without a zone suffix ("2026-08-05T03:30"). */
|
||||
export function toLocalTime(showTime, tzOffsetMinutes) {
|
||||
let s = showTime;
|
||||
if (/T\d{2}:\d{2}$/.test(s)) s += ":00";
|
||||
if (!s.endsWith("Z")) s += "Z";
|
||||
const d = new Date(Date.parse(s) + tzOffsetMinutes * 60000);
|
||||
let h = d.getUTCHours();
|
||||
const m = d.getUTCMinutes();
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
h = h % 12 || 12;
|
||||
return `${h}:${String(m).padStart(2, "0")} ${ampm}`;
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD in the configured timezone. */
|
||||
export function localDate(now, tzOffsetMinutes) {
|
||||
return new Date(now.getTime() + tzOffsetMinutes * 60000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function localMinutes(now, tzOffsetMinutes) {
|
||||
const d = new Date(now.getTime() + tzOffsetMinutes * 60000);
|
||||
return d.getUTCHours() * 60 + d.getUTCMinutes();
|
||||
}
|
||||
|
||||
/** Single source of truth for "should this tick send today's heartbeat". */
|
||||
export function heartbeatDue(state, now, cfg) {
|
||||
if (cfg.heartbeatHour < 0) return false;
|
||||
return localMinutes(now, cfg.tzOffsetMinutes) >= cfg.heartbeatHour * 60 &&
|
||||
state.hbDate !== localDate(now, cfg.tzOffsetMinutes);
|
||||
}
|
||||
|
||||
function isMatch(sess, cfg) {
|
||||
// Deliberately broad: either the format tag or a substring of the screen format,
|
||||
// so "IMAX" also catches "IMAX 3D" and similar variants.
|
||||
if (!cfg.formatTag && !cfg.formatMatch) return true;
|
||||
return (cfg.formatTag && (sess.tags || []).includes(cfg.formatTag)) ||
|
||||
(cfg.formatMatch && String(sess.scrnFmt || "").toUpperCase().includes(cfg.formatMatch.toUpperCase()));
|
||||
}
|
||||
|
||||
function normalize(sess, cinemaName, cfg) {
|
||||
return {
|
||||
sid: String(sess.sid),
|
||||
cinema: cinemaName,
|
||||
time: toLocalTime(sess.showTime, cfg.tzOffsetMinutes),
|
||||
audi: sess.audi || "",
|
||||
avail: sess.avail,
|
||||
total: sess.total,
|
||||
tiers: (sess.areas || [])
|
||||
.map((a) => `${a.label} ${cfg.currency}${Math.round(a.price)} (${a.sAvail}/${a.sTotal})`)
|
||||
.join(" · "),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a District response into matching sessions.
|
||||
* matched — at cfg.cinemaId (or every cinema when cinemaId is unset)
|
||||
* other — matching format at a different cinema (reported as secondary lines)
|
||||
*/
|
||||
export function extractSessions(data, cfg) {
|
||||
const cinemas = [
|
||||
...(data?.pageData?.nearbyCinemas ?? []),
|
||||
...(data?.pageData?.farCinemas ?? []),
|
||||
];
|
||||
const out = {
|
||||
matched: [], other: [],
|
||||
cinemaCount: cinemas.length, sessionCount: 0,
|
||||
showDates: data?.meta?.showDates ?? [],
|
||||
};
|
||||
for (const c of cinemas) {
|
||||
const name = c?.cinemaInfo?.name || `cinema ${c?.id}`;
|
||||
for (const s of c?.sessions ?? []) {
|
||||
out.sessionCount++;
|
||||
if (!isMatch(s, cfg)) continue;
|
||||
const wanted = !cfg.cinemaId || String(c.id) === String(cfg.cinemaId);
|
||||
(wanted ? out.matched : out.other).push(normalize(s, name, cfg));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function apiUrl(cfg, date) {
|
||||
const p = new URLSearchParams({
|
||||
version: "3", site_id: "1", channel: "mweb", child_site_id: "1", platform: "district",
|
||||
movieCode: cfg.movieCode, city_key: cfg.cityKey, content_id: cfg.contentId,
|
||||
date, latitude: cfg.lat, longitude: cfg.lng, cinemaOrderLogic: "3",
|
||||
});
|
||||
return `https://www.district.in/gw/consumer/movies/v5/movie?${p}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One API call, classified by HTTP outcome only:
|
||||
* 204 -> date not open for booking yet
|
||||
* 200 -> open (open-but-empty is decided later, in decide())
|
||||
* Anything else throws CheckError, which counts toward the failure streak.
|
||||
*/
|
||||
export async function checkDistrict(cfg, date) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(apiUrl(cfg, date), {
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
api_source: "district",
|
||||
"x-app-type": "ed_mweb",
|
||||
"x-guest-token": guestToken(),
|
||||
"x-request-id": crypto.randomUUID(),
|
||||
"user-agent": UA,
|
||||
...(cfg.bookingUrl ? { referer: cfg.bookingUrl } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
// Node-only: routes this call through a SOCKS5 proxy when one is configured.
|
||||
...(cfg.dispatcher ? { dispatcher: cfg.dispatcher } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new CheckError(`fetch failed: ${err.message}`);
|
||||
}
|
||||
if (res.status === 204) return { kind: "closed" };
|
||||
if (res.status === 200) {
|
||||
try {
|
||||
return { kind: "open", data: await res.json() };
|
||||
} catch (err) {
|
||||
throw new CheckError(`unparseable 200 body: ${err.message}`);
|
||||
}
|
||||
}
|
||||
throw new CheckError(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
export async function notify(cfg, alert) {
|
||||
if (!cfg.ntfyTopic) throw new Error("NTFY_TOPIC is not set");
|
||||
const headers = {
|
||||
Title: alert.title,
|
||||
Priority: alert.priority,
|
||||
Tags: alert.tags,
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
};
|
||||
if (alert.click) headers.Click = alert.click;
|
||||
if (cfg.ntfyToken) headers.Authorization = `Bearer ${cfg.ntfyToken}`;
|
||||
const res = await fetch(`${cfg.ntfyUrl}/${cfg.ntfyTopic}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: alert.body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ntfy HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
export function defaultState() {
|
||||
return {
|
||||
dateOpenAlerted: false,
|
||||
sidsAlerted: {},
|
||||
failures: 0,
|
||||
brokenAlerted: false,
|
||||
hbDate: "",
|
||||
lastGood: null,
|
||||
retired: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision core.
|
||||
* input: { kind: "closed"|"open"|"error", extract?, reason?, now, probeMax? }
|
||||
* Returns { baseState, alerts }. Each alert carries its own statePatch, which the
|
||||
* caller merges ONLY after that alert's push succeeds, so a failed push retries
|
||||
* next tick instead of being silently marked as delivered.
|
||||
*/
|
||||
export function decide(state, input, cfg) {
|
||||
const base = structuredClone(state);
|
||||
const alerts = [];
|
||||
const now = input.now;
|
||||
|
||||
if (base.retired) return { baseState: base, alerts };
|
||||
|
||||
if (now.getTime() >= Date.parse(cfg.expiryUtc)) {
|
||||
alerts.push({
|
||||
title: "Monitor retired",
|
||||
priority: "3",
|
||||
tags: "checkered_flag",
|
||||
click: cfg.bookingUrl,
|
||||
body: `${cfg.targetDate} has passed, so this monitor is now idle. Stop it when convenient.`,
|
||||
statePatch: { retired: true },
|
||||
});
|
||||
return { baseState: base, alerts };
|
||||
}
|
||||
|
||||
// Only write when it actually changed, so a repeating probe cannot churn state.
|
||||
if (input.probeMax && base.lastGood?.showDatesMax !== input.probeMax) {
|
||||
base.lastGood = { ...(base.lastGood ?? {}), showDatesMax: input.probeMax, at: now.toISOString() };
|
||||
}
|
||||
|
||||
if (input.kind === "error") {
|
||||
base.failures = Math.min((state.failures ?? 0) + 1, cfg.failureThreshold);
|
||||
if (base.failures >= cfg.failureThreshold && !state.brokenAlerted) {
|
||||
alerts.push({
|
||||
title: "Monitor BROKEN - check manually",
|
||||
priority: "4",
|
||||
tags: "warning",
|
||||
click: cfg.bookingUrl,
|
||||
body: `${cfg.failureThreshold} consecutive check failures (last: ${input.reason}). The site may be blocking this host, or be down. Until this recovers, silence does NOT mean "not open yet" - check the page yourself.`,
|
||||
statePatch: { brokenAlerted: true },
|
||||
});
|
||||
}
|
||||
return { baseState: base, alerts };
|
||||
}
|
||||
|
||||
// --- success tick ---
|
||||
base.failures = 0;
|
||||
if (state.brokenAlerted) {
|
||||
alerts.push({
|
||||
title: "Monitor recovered",
|
||||
priority: "3",
|
||||
tags: "white_check_mark",
|
||||
click: cfg.bookingUrl,
|
||||
body: "Checks are succeeding again. Normal service resumed.",
|
||||
statePatch: { brokenAlerted: false },
|
||||
});
|
||||
}
|
||||
|
||||
const ex = input.kind === "open" ? input.extract : null;
|
||||
const effectiveKind = ex && ex.sessionCount > 0 ? "open" : "closed"; // open-but-empty = closed
|
||||
if (base.lastGood?.kind !== effectiveKind) {
|
||||
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, at: now.toISOString() };
|
||||
}
|
||||
|
||||
if (effectiveKind === "open") {
|
||||
const fresh = ex.matched.filter((s) => !state.sidsAlerted?.[s.sid]);
|
||||
if (fresh.length > 0) {
|
||||
const lines = fresh.map((s) =>
|
||||
`${s.time} | ${s.cinema}${s.audi ? ` | ${s.audi}` : ""} | seats ${s.avail}/${s.total}\n${s.tiers}`);
|
||||
for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`);
|
||||
const sidsAlerted = { ...state.sidsAlerted };
|
||||
for (const s of fresh) sidsAlerted[s.sid] = true;
|
||||
alerts.push({
|
||||
title: `${cfg.formatLabel} ${cfg.targetDate} OPEN - BOOK NOW`,
|
||||
priority: "5",
|
||||
tags: "rotating_light,movie_camera",
|
||||
click: cfg.bookingUrl,
|
||||
body: lines.join("\n\n"),
|
||||
statePatch: { sidsAlerted, dateOpenAlerted: true },
|
||||
});
|
||||
} else if (!state.dateOpenAlerted && ex.matched.length === 0) {
|
||||
alerts.push({
|
||||
title: `${cfg.targetDate} bookings are OPEN (no ${cfg.formatLabel} yet)`,
|
||||
priority: "4",
|
||||
tags: "eyes",
|
||||
click: cfg.bookingUrl,
|
||||
body: `${ex.cinemaCount} cinemas are listing ${cfg.targetDate}, but nothing matching "${cfg.formatLabel}" so far. You'll get a max-priority push the moment it appears. Open dates: ${ex.showDates.join(", ") || "n/a"}.`,
|
||||
statePatch: { dateOpenAlerted: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (heartbeatDue(state, now, cfg)) {
|
||||
alerts.push({
|
||||
title: `Still watching ${cfg.targetDate}`,
|
||||
priority: "1",
|
||||
tags: "hourglass",
|
||||
click: cfg.bookingUrl,
|
||||
body: `Monitor healthy. Bookings open through: ${base.lastGood?.showDatesMax ?? "unavailable"}. Target date is still ${effectiveKind}.`,
|
||||
statePatch: { hbDate: localDate(now, cfg.tzOffsetMinutes) },
|
||||
});
|
||||
}
|
||||
|
||||
return { baseState: base, alerts };
|
||||
}
|
||||
Reference in new issue
Block a user