mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
Adds a 'watching' block with the movie id, variant, venue and format filter per provider. A wrong id or an unexpectedly narrow filter was previously impossible to tell apart from 'nothing on sale yet', since both just report closed.
211 lines
8.0 KiB
JavaScript
211 lines
8.0 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.
|
|
*
|
|
* Both sites block datacenter IPs, so a cloud host generally needs this for both. It is
|
|
* only half the story for BookMyShow, which also blocks on TLS fingerprint: impit deals
|
|
* with the fingerprint, the proxy deals with the IP, and BMS needs both.
|
|
*/
|
|
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, note: res.note ?? null };
|
|
} 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 ?? input.note ?? null };
|
|
const matched = input.extract?.matched?.length ?? 0;
|
|
console.log(ts(), `[${p.key}] ${input.kind}${input.reason || input.note ? ` (${input.reason ?? input.note})` : ""}`,
|
|
`| 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,
|
|
note: merged.providers[p.key].lastGood?.note,
|
|
});
|
|
}
|
|
}
|
|
|
|
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,
|
|
// What each site is actually watching. Without this, a misconfigured id or an
|
|
// unexpectedly narrow filter is indistinguishable from "nothing on sale yet".
|
|
watching: Object.fromEntries(cfg.providers.map((p) => [p.key, {
|
|
movie: p.contentId ?? p.eventCode,
|
|
variant: p.movieCode || p.language || "(any language/format variant)",
|
|
venue: p.venueId || "(any venue)",
|
|
format: [p.formatTag, p.formatMatch].filter(Boolean).join(" or ") || "(any format)",
|
|
}])),
|
|
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);
|
|
}
|