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:
Aculix Technologies committed 2026-08-14 14:31:28 +05:30
commit b70968d259
15 files changed
+1164

No files matched your search

+62
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}