Files
ticket-monitoring/src/index.js
T
Aculix Technologies 608221fe18 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.
2026-08-26 17:50:56 +05:30

201 lines
7.4 KiB
JavaScript

#!/usr/bin/env node
// 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 {
decideProvider, decideGlobal, defaultState, providerState, heartbeatDue, notify,
} from "./monitor.js";
const ts = () => new Date().toISOString();
// 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 = {};
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 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;
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}`);
}
/** 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";
const merged = structuredClone(state);
merged.providers ??= {};
const summaries = [];
const expired = now.getTime() >= Date.parse(cfg.expiryUtc);
const send = async (alert, apply) => {
try {
await notify(cfg, alert);
apply();
console.log(ts(), "alert sent:", alert.title);
} catch (err) {
// 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 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)) {
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);
}
}
return merged.retired ? "retired" : "ok";
}
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,
providers: cfg.providers.map((p) => p.key),
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.
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);
} 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} 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 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);
}