feat: watch BookMyShow alongside District in one process

BookMyShow sits behind Cloudflare bot management that rejects curl and Node fetch
with 403 on the TLS fingerprint alone, so no header or cookie copying helps and a
proxy makes no difference. impit performs the request with a real Chrome
fingerprint; it ships musl and arm64 binaries, so the Alpine multi-arch image is
unaffected. BMS has no 204 either: a date that is not on sale returns 200 with no
showtimeWidgets key, so 'closed' is read from the payload.

Sites now live in src/providers/, each exposing check() and extract() and returning
one normalized session shape. State is namespaced per site, so each keeps its own
dedupe and failure tracking and neither can silence the other. Alert titles name the
site. Sessions without exact seat counts render without them, which BMS needs.

Old flat District variable names still work. CI now starts the built image and runs
a real check through both providers before publishing, since a broken native binary
only fails at runtime.
This commit is contained in:
Aculix Technologies committed 2026-08-26 17:50:56 +05:30
1 parent 00e6852863
commit 608221fe18
17 files changed
+1188 -539

No files matched your search

+88 -21
View File
@@ -1,5 +1,12 @@
// 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
// Configuration comes 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
import * as district from "./providers/district.js";
import * as bookmyshow from "./providers/bookmyshow.js";
const MODULES = { district, bookmyshow };
const env = (name, fallback = "") => process.env[name] ?? fallback;
function required(name) {
const v = process.env[name];
@@ -15,6 +22,55 @@ const num = (name, fallback) => {
return n;
};
/** Old single-provider variable names, still honoured so v1 .env files keep working. */
function legacyDistrict() {
if (!process.env.MOVIE_CODE || process.env.DISTRICT_MOVIE_CODE) return null;
return {
movieCode: env("MOVIE_CODE"),
contentId: env("CONTENT_ID"),
cityKey: env("CITY_KEY"),
lat: env("LAT"),
lng: env("LNG"),
venueId: env("CINEMA_ID"),
formatTag: process.env.FORMAT_TAG ?? "imax_2d",
formatMatch: process.env.FORMAT_MATCH ?? "IMAX",
bookingUrl: env("BOOKING_URL"),
};
}
function districtConfig() {
const legacy = legacyDistrict();
if (legacy) return legacy;
if (!process.env.DISTRICT_MOVIE_CODE) return null;
return {
movieCode: required("DISTRICT_MOVIE_CODE"),
contentId: required("DISTRICT_CONTENT_ID"),
cityKey: required("DISTRICT_CITY_KEY"),
lat: required("DISTRICT_LAT"),
lng: required("DISTRICT_LNG"),
venueId: env("DISTRICT_CINEMA_ID"),
formatTag: process.env.DISTRICT_FORMAT_TAG ?? "imax_2d",
formatMatch: process.env.DISTRICT_FORMAT_MATCH ?? "IMAX",
bookingUrl: env("DISTRICT_BOOKING_URL"),
};
}
function bookmyshowConfig() {
if (!process.env.BMS_EVENT_CODE) return null;
return {
eventCode: required("BMS_EVENT_CODE"),
refEventCode: env("BMS_REF_EVENT_CODE"),
regionCode: required("BMS_REGION_CODE"),
regionSlug: env("BMS_REGION_SLUG", "").toLowerCase(),
lat: env("BMS_LAT", "0"),
lng: env("BMS_LNG", "0"),
venueId: env("BMS_VENUE_CODE"),
language: env("BMS_LANGUAGE"),
formatMatch: process.env.BMS_FORMAT_MATCH ?? "IMAX",
bookingUrl: env("BMS_BOOKING_URL"),
};
}
export function loadConfig() {
const targetDate = required("TARGET_DATE");
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) {
@@ -26,37 +82,48 @@ export function loadConfig() {
const expiryUtc = process.env.EXPIRY_UTC ||
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() };
// PROVIDERS is an explicit allow-list; without it, every configured provider runs.
const wanted = env("PROVIDERS").split(",").map((s) => s.trim()).filter(Boolean);
for (const w of wanted) {
if (!MODULES[w]) throw new Error(`Unknown provider "${w}" in PROVIDERS. Valid: ${Object.keys(MODULES).join(", ")}`);
}
const keys = (wanted.length ? wanted : Object.keys(built)).filter((k) => built[k]);
if (!keys.length) {
throw new Error("No provider configured. Set DISTRICT_MOVIE_CODE and/or BMS_EVENT_CODE (see .env.example)");
}
for (const w of wanted) {
if (!built[w]) throw new Error(`PROVIDERS lists "${w}" but its settings are missing (see .env.example)`);
}
const providers = keys.map((k) => ({
key: k,
label: MODULES[k].label,
module: MODULES[k],
...built[k],
}));
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 || "",
providers,
formatLabel: env("FORMAT_LABEL", "IMAX"),
// Used by alerts that are not tied to one provider (heartbeat, retirement).
bookingUrl: providers[0].bookingUrl || "",
// notifications
ntfyUrl: (process.env.NTFY_URL || "https://ntfy.sh").replace(/\/+$/, ""),
ntfyUrl: env("NTFY_URL", "https://ntfy.sh").replace(/\/+$/, ""),
ntfyTopic: required("NTFY_TOPIC"),
ntfyToken: process.env.NTFY_TOKEN || "",
ntfyToken: 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 || "₹",
currency: env("CURRENCY", "₹"),
expiryUtc,
// runtime
statePath: process.env.STATE_PATH || "./state.json",
statePath: env("STATE_PATH", "./state.json"),
port: num("PORT", 4733),
proxyUrl: process.env.PROXY_URL || "",
proxyUrl: env("PROXY_URL"),
dispatcher: null, // set by attachProxy() when PROXY_URL is present
};
}
+92 -57
View File
@@ -1,18 +1,20 @@
#!/usr/bin/env node
// Entry point. Runs one check (default) or loops forever with a status server (--loop).
// Entry point. Runs one check across every configured provider (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 { localDate } from "./lib.js";
import {
decide, defaultState, extractSessions, checkDistrict, notify, heartbeatDue, localDate,
decideProvider, decideGlobal, defaultState, providerState, heartbeatDue, notify,
} from "./monitor.js";
const ts = () => new Date().toISOString();
// Last tick's outcome, surfaced by the status server. Persisted state records only
// Last tick per provider, 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 };
const lastTick = {};
function readState(cfg) {
try {
@@ -30,9 +32,10 @@ function writeState(cfg, state) {
}
/**
* 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.
* Routes provider requests through a SOCKS5 proxy. Notifications stay direct, so the
* alerting path never depends on the proxy. District needs this on datacenter hosts whose
* IP the WAF blocks. It does nothing for BookMyShow, which blocks on TLS fingerprint
* rather than IP; impit handles that instead.
*/
async function attachProxy(cfg) {
if (!cfg.proxyUrl) return;
@@ -51,49 +54,81 @@ async function attachProxy(cfg) {
console.log(ts(), `checks routed via SOCKS5 ${u.hostname}:${u.port}`);
}
/** Check one provider and return its alerts plus the next sub-state. */
async function tickProvider(cfg, p, state, now) {
const pstate = providerState(state, p.key);
// Best-effort daily probe, only from providers that support it and only on the
// heartbeat tick. Its failure never counts toward the failure streak.
let probeMax = null;
if (p.module.probeShowDates && heartbeatDue(state, now, cfg) && !pstate.brokenAlerted) {
try {
probeMax = await p.module.probeShowDates(cfg, p, localDate(now, cfg.tzOffsetMinutes));
} catch (err) {
console.error(ts(), `[${p.key}] showDates probe failed (best-effort):`, err.message);
}
}
let input;
try {
const res = await p.module.check(cfg, p, cfg.targetDate);
input = res.kind === "open"
? { kind: "open", extract: p.module.extract(res.data, cfg, p), now, probeMax }
: { kind: "closed", now, probeMax };
} catch (err) {
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
}
const { baseState, alerts } = decideProvider(pstate, input, cfg, p);
lastTick[p.key] = { at: ts(), kind: input.kind, reason: input.reason ?? null };
const matched = input.extract?.matched?.length ?? 0;
console.log(ts(), `[${p.key}] ${input.kind}${input.reason ? ` (${input.reason})` : ""}`,
`| matched: ${matched} | failures: ${baseState.failures}`);
return { pstate: baseState, alerts, input };
}
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);
}
}
const merged = structuredClone(state);
merged.providers ??= {};
const summaries = [];
const expired = now.getTime() >= Date.parse(cfg.expiryUtc);
const send = async (alert, apply) => {
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 };
await notify(cfg, alert);
apply();
console.log(ts(), "alert sent:", alert.title);
} catch (err) {
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
// Patch not applied, so the push is retried on the next tick.
console.error(ts(), `notify failed for "${alert.title}" (will retry):`, err.message);
}
};
if (!expired) {
for (const p of cfg.providers) {
const { pstate, alerts, input } = await tickProvider(cfg, p, state, now);
merged.providers[p.key] = pstate;
for (const alert of alerts) {
await send(alert, () => {
merged.providers[p.key] = { ...merged.providers[p.key], ...alert.statePatch };
});
}
summaries.push({
label: p.label,
kind: input.kind === "error" ? "checks failing" : merged.providers[p.key].lastGood?.kind,
showDatesMax: merged.providers[p.key].lastGood?.showDatesMax,
});
}
}
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);
}
const global = decideGlobal(state, cfg, now, summaries);
Object.assign(merged, global.baseState, { providers: merged.providers });
for (const alert of global.alerts) {
await send(alert, () => Object.assign(merged, alert.statePatch));
}
if (JSON.stringify(merged) !== JSON.stringify(state)) {
@@ -104,12 +139,7 @@ export async function tick(cfg) {
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;
return merged.retired ? "retired" : "ok";
}
function startStatusServer(cfg) {
@@ -125,6 +155,7 @@ function startStatusServer(cfg) {
const out = {
now: ts(),
targetDate: cfg.targetDate,
providers: cfg.providers.map((p) => p.key),
proxied: Boolean(cfg.dispatcher),
retired: state.retired,
lastTick,
@@ -132,13 +163,16 @@ function startStatusServer(cfg) {
};
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) };
out.live = {};
for (const p of cfg.providers) {
try {
const r = await p.module.check(cfg, p, cfg.targetDate);
out.live[p.key] = r.kind === "open"
? { kind: "open", ...p.module.extract(r.data, cfg, p) }
: { kind: "closed" };
} catch (err) {
out.live[p.key] = { kind: "error", reason: String(err.message ?? err) };
}
}
}
send(200, out);
@@ -153,12 +187,13 @@ await attachProxy(cfg);
if (process.argv.includes("--loop")) {
startStatusServer(cfg);
console.log(ts(), `watching ${cfg.targetDate} every ${cfg.intervalMs / 1000}s (expires ${cfg.expiryUtc})`);
console.log(ts(), `watching ${cfg.targetDate} on ${cfg.providers.map((p) => p.label).join(" + ")}`,
`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));
const r = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; });
if (r === "retired") break;
await new Promise((r2) => setTimeout(r2, cfg.intervalMs));
}
} else {
await tick(cfg);
+29
View File
@@ -0,0 +1,29 @@
// Small shared helpers used by providers and the alert logic.
export class CheckError extends Error {}
/**
* Providers report showtimes in local wall-clock terms. District returns UTC
* timestamps, so they get shifted; BookMyShow already returns local strings.
*/
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);
}
export function localMinutes(now, tzOffsetMinutes) {
const d = new Date(now.getTime() + tzOffsetMinutes * 60000);
return d.getUTCHours() * 60 + d.getUTCMinutes();
}
+118 -235
View File
@@ -1,146 +1,148 @@
// Core logic: District API client, session matching, alert state machine, ntfy publisher.
// Everything here is runtime-agnostic and side-effect-free except checkDistrict/notify.
// Provider-agnostic alert logic: what to notify about, once, and when to stay quiet.
// Providers supply normalized sessions; nothing here knows which site they came from.
import { localDate, localMinutes } from "./lib.js";
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}`;
export function defaultProviderState() {
return { dateOpenAlerted: false, sidsAlerted: {}, failures: 0, brokenAlerted: false, lastGood: null };
}
/** 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}`;
export function defaultState() {
return { hbDate: "", retired: false, providers: {} };
}
/** YYYY-MM-DD in the configured timezone. */
export function localDate(now, tzOffsetMinutes) {
return new Date(now.getTime() + tzOffsetMinutes * 60000).toISOString().slice(0, 10);
/** Provider sub-state, defaulted, so older state files keep working. */
export function providerState(state, key) {
return { ...defaultProviderState(), ...((state.providers ?? {})[key] ?? {}) };
}
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(" · "),
};
function sessionLine(s) {
const head = [s.time, s.cinema, s.audi || s.format, s.avail != null ? `seats ${s.avail}/${s.total}` : null]
.filter(Boolean).join(" | ");
return s.tiers ? `${head}\n${s.tiers}` : head;
}
/**
* 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)
* Decide what to alert for ONE provider on one tick.
* input: { kind: "closed"|"open"|"error", extract?, reason?, now, probeMax? }
* Returns { baseState, alerts } for that provider's sub-state. Each alert carries its own
* statePatch, which the caller merges only after that alert's push succeeds, so a failed
* notification retries next tick instead of being recorded as delivered.
*/
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));
export function decideProvider(pstate, input, cfg, p) {
const base = structuredClone(pstate);
const alerts = [];
const now = input.now;
const tag = (a) => ({ ...a, provider: p.key });
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((pstate.failures ?? 0) + 1, cfg.failureThreshold);
if (base.failures >= cfg.failureThreshold && !pstate.brokenAlerted) {
alerts.push(tag({
title: `${p.label} checks BROKEN - check manually`,
priority: "4",
tags: "warning",
click: p.bookingUrl,
body: `${cfg.failureThreshold} consecutive failures against ${p.label} (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 };
}
base.failures = 0;
if (pstate.brokenAlerted) {
alerts.push(tag({
title: `${p.label} checks recovered`,
priority: "3",
tags: "white_check_mark",
click: p.bookingUrl,
body: `${p.label} 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) => !pstate.sidsAlerted?.[s.sid]);
if (fresh.length > 0) {
const lines = fresh.map(sessionLine);
for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`);
const sidsAlerted = { ...pstate.sidsAlerted };
for (const s of fresh) sidsAlerted[s.sid] = true;
alerts.push(tag({
title: `${cfg.formatLabel} ${cfg.targetDate} OPEN on ${p.label} - BOOK NOW`,
priority: "5",
tags: "rotating_light,movie_camera",
click: p.bookingUrl,
body: lines.join("\n\n"),
statePatch: { sidsAlerted, dateOpenAlerted: true },
}));
} else if (!pstate.dateOpenAlerted && ex.matched.length === 0) {
alerts.push(tag({
title: `${cfg.targetDate} is OPEN on ${p.label} (no ${cfg.formatLabel} yet)`,
priority: "4",
tags: "eyes",
click: p.bookingUrl,
body: `${p.label} is listing ${cfg.targetDate} at ${ex.cinemaCount} cinemas, but nothing matching "${cfg.formatLabel}" so far. You'll get a max-priority push the moment it appears.${ex.showDates.length ? ` Open dates: ${ex.showDates.join(", ")}.` : ""}`,
statePatch: { dateOpenAlerted: true },
}));
}
}
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}`;
return { baseState: base, alerts };
}
/**
* 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.
* Tick-level decisions that are not per-provider: retirement and the daily heartbeat.
* summaries: [{ label, kind, showDatesMax }] describing each provider this tick.
*/
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 } : {}),
export function decideGlobal(state, cfg, now, summaries) {
const base = structuredClone(state);
const alerts = [];
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 },
});
} catch (err) {
throw new CheckError(`fetch failed: ${err.message}`);
return { baseState: base, alerts };
}
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}`);
}
if (heartbeatDue(state, now, cfg)) {
const lines = summaries.map((s) =>
`${s.label}: ${s.kind ?? "unknown"}${s.showDatesMax ? ` (open through ${s.showDatesMax})` : ""}`);
alerts.push({
title: `Still watching ${cfg.targetDate}`,
priority: "1",
tags: "hourglass",
click: cfg.bookingUrl,
body: `Monitor healthy.\n${lines.join("\n")}`,
statePatch: { hbDate: localDate(now, cfg.tzOffsetMinutes) },
});
}
throw new CheckError(`HTTP ${res.status}`);
return { baseState: base, alerts };
}
export async function notify(cfg, alert) {
@@ -161,122 +163,3 @@ export async function notify(cfg, alert) {
});
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 };
}
+145
View File
@@ -0,0 +1,145 @@
// BookMyShow (in.bookmyshow.com) provider.
//
// BMS sits behind Cloudflare bot management that rejects Node's fetch and curl with 403
// regardless of headers or cookies: the block is on the TLS/HTTP2 fingerprint. `impit`
// performs the request with a real Chrome fingerprint, which is why it is a dependency.
//
// Unlike District there is no 204. A date that is not on sale yet answers 200 with a
// short body that simply has no `showtimeWidgets` key, so "closed" is inferred from the
// payload rather than the status code.
import { CheckError } from "../lib.js";
export const key = "bookmyshow";
export const label = "BookMyShow";
let Impit = null;
let client = null;
async function getClient(cfg) {
if (client) return client;
if (!Impit) ({ Impit } = await import("impit"));
client = new Impit({
browser: "chrome",
...(cfg.proxyUrl ? { proxyUrl: cfg.proxyUrl } : {}),
});
return client;
}
/** BMS dates are YYYYMMDD; the rest of the app speaks YYYY-MM-DD. */
export const toDateCode = (isoDate) => isoDate.replaceAll("-", "");
function apiUrl(p, date) {
const q = new URLSearchParams({
etCodes: p.eventCode,
dateCode: toDateCode(date),
isDesktop: "true",
regionCode: p.regionCode,
xLocationShared: "false",
memberId: "", lsId: "", subCode: "",
appCode: "WEB",
refEventCode: p.refEventCode || p.eventCode,
...(p.language ? { language: p.language } : {}),
});
return `https://in.bookmyshow.com/api/movies-data/v5/showtimes-by-event/primary-dynamic?${q}`;
}
/** Generated by the BMS web client itself, not issued by the server. */
const bmsId = () => `1.${Math.floor(Math.random() * 9e8 + 1e8)}.${Date.now()}`;
export async function check(cfg, p, date) {
const impit = await getClient(cfg);
let res, text;
try {
res = await impit.fetch(apiUrl(p, date), {
headers: {
accept: "application/json, text/plain, */*",
"x-region-code": p.regionCode,
"x-region-slug": p.regionSlug,
"x-latitude": p.lat,
"x-longitude": p.lng,
"x-bms-id": bmsId(),
"x-app-code": "WEB",
"x-platform-code": "WEB",
"x-platform": "WEB",
},
});
text = await res.text();
} catch (err) {
throw new CheckError(`fetch failed: ${err.message}`);
}
if (res.status !== 200) throw new CheckError(`HTTP ${res.status}`);
let data;
try {
data = JSON.parse(text);
} catch (err) {
throw new CheckError(`unparseable body: ${err.message}`);
}
// No showtime widgets at all means the date has not gone on sale.
if (!data?.data?.showtimeWidgets) return { kind: "closed" };
return { kind: "open", data };
}
/** Venue cards sit several widget layers deep, so walk rather than index blindly. */
function collectVenueCards(node, out = []) {
if (Array.isArray(node)) {
for (const n of node) collectVenueCards(n, out);
} else if (node && typeof node === "object") {
if (node.type === "venue-card") out.push(node);
for (const v of Object.values(node)) collectVenueCards(v, out);
}
return out;
}
/** Seat tiers live in the double-tap bottom sheet: type, price and an availability word. */
function tiersOf(showtime, cfg) {
const widgets = showtime?.customGestureCTA?.additionalData?.bottomSheetData?.widgets ?? [];
return widgets
.filter((w) => w?.variableData?.seatType)
.map((w) => {
const v = w.variableData;
const cost = String(v.seatCost || "").replace(/[^\d.]/g, "");
const price = cost ? `${cfg.currency}${Math.round(Number(cost))}` : "";
return `${v.seatType} ${price} (${v.seatAvalibility || ""})`.replace(/\s+/g, " ").trim();
})
.join(" · ");
}
function formatOf(showtime) {
return String(showtime.screenAttr || showtime?.additionalData?.attributes || "");
}
function isMatch(showtime, p) {
if (!p.formatMatch) return true;
return formatOf(showtime).toUpperCase().includes(p.formatMatch.toUpperCase());
}
export function extract(data, cfg, p) {
const cards = collectVenueCards(data?.data?.showtimeWidgets ?? []);
const out = { matched: [], other: [], cinemaCount: cards.length, sessionCount: 0, showDates: [] };
for (const card of cards) {
const info = card.additionalData ?? {};
const name = info.venueName || info.venueCode || "unknown venue";
for (const section of card.showtimesSections ?? []) {
for (const st of section.showtimes ?? []) {
out.sessionCount++;
if (!isMatch(st, p)) continue;
const ad = st.additionalData ?? {};
const session = {
sid: String(ad.sessionId || `${info.venueCode}-${ad.showTimeCode || st.title}`),
cinema: name,
// BMS already returns local wall-clock time, so no timezone shift is applied.
time: ad.showTime || st.title || "",
audi: "",
format: formatOf(st),
// BMS exposes availability words per tier, never exact seat counts.
avail: null,
total: null,
tiers: tiersOf(st, cfg),
};
const wanted = !p.venueId || String(info.venueCode) === String(p.venueId);
(wanted ? out.matched : out.other).push(session);
}
}
}
return out;
}
+112
View File
@@ -0,0 +1,112 @@
// District (district.in) provider.
// The showtimes endpoint answers 204 while a date is not on sale yet, and 200 with the
// full session list once it opens. Plain fetch works, so this provider needs no special
// HTTP client, and it honours a SOCKS5 dispatcher when one is configured.
import { CheckError, toLocalTime } from "../lib.js";
export const key = "district";
export const label = "District";
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";
/** Generated client-side by District's own web app, not issued by the server. */
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}`;
}
function apiUrl(p, date) {
const q = new URLSearchParams({
version: "3", site_id: "1", channel: "mweb", child_site_id: "1", platform: "district",
movieCode: p.movieCode, city_key: p.cityKey, content_id: p.contentId,
date, latitude: p.lat, longitude: p.lng, cinemaOrderLogic: "3",
});
return `https://www.district.in/gw/consumer/movies/v5/movie?${q}`;
}
/** @returns {{kind:"closed"}|{kind:"open",data:object}} */
export async function check(cfg, p, date) {
let res;
try {
res = await fetch(apiUrl(p, date), {
headers: {
accept: "*/*",
api_source: "district",
"x-app-type": "ed_mweb",
"x-guest-token": guestToken(),
"x-request-id": crypto.randomUUID(),
"user-agent": UA,
...(p.bookingUrl ? { referer: p.bookingUrl } : {}),
},
signal: AbortSignal.timeout(15000),
...(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}`);
}
function isMatch(sess, p) {
if (!p.formatTag && !p.formatMatch) return true;
return (p.formatTag && (sess.tags || []).includes(p.formatTag)) ||
(p.formatMatch && String(sess.scrnFmt || "").toUpperCase().includes(p.formatMatch.toUpperCase()));
}
export function extract(data, cfg, p) {
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, p)) continue;
const session = {
sid: String(s.sid),
cinema: name,
time: toLocalTime(s.showTime, cfg.tzOffsetMinutes),
audi: s.audi || "",
format: s.scrnFmt || "",
avail: s.avail,
total: s.total,
tiers: (s.areas || [])
.map((a) => `${a.label} ${cfg.currency}${Math.round(a.price)} (${a.sAvail}/${a.sTotal})`)
.join(" · "),
};
const wanted = !p.venueId || String(c.id) === String(p.venueId);
(wanted ? out.matched : out.other).push(session);
}
}
return out;
}
/**
* Optional capability: how far ahead bookings currently run. `meta.showDates` only
* appears in a 200 body, and the target date returns an empty 204 while closed, so this
* asks about today instead. Best effort; the caller ignores failures.
*/
export async function probeShowDates(cfg, p, todayIso) {
const res = await check(cfg, p, todayIso);
if (res.kind !== "open") return null;
const dates = res.data?.meta?.showDates ?? [];
return dates.length ? dates.slice().sort().at(-1) : null;
}