mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
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:
1 parent
00e6852863
commit
608221fe18
17 files changed
+1188
-539
No files matched your search
+92
-57
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user