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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user