mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
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.
146 lines
5.1 KiB
JavaScript
146 lines
5.1 KiB
JavaScript
// BookMyShow (in.bookmyshow.com) provider.
|
|
//
|
|
// BMS sits behind Cloudflare bot management that rejects Node's fetch and curl with 403
|
|
// regardless of headers or cookies: the block is on the TLS/HTTP2 fingerprint. `impit`
|
|
// performs the request with a real Chrome fingerprint, which is why it is a dependency.
|
|
//
|
|
// Unlike District there is no 204. A date that is not on sale yet answers 200 with a
|
|
// short body that simply has no `showtimeWidgets` key, so "closed" is inferred from the
|
|
// payload rather than the status code.
|
|
import { CheckError } from "../lib.js";
|
|
|
|
export const key = "bookmyshow";
|
|
export const label = "BookMyShow";
|
|
|
|
let Impit = null;
|
|
let client = null;
|
|
|
|
async function getClient(cfg) {
|
|
if (client) return client;
|
|
if (!Impit) ({ Impit } = await import("impit"));
|
|
client = new Impit({
|
|
browser: "chrome",
|
|
...(cfg.proxyUrl ? { proxyUrl: cfg.proxyUrl } : {}),
|
|
});
|
|
return client;
|
|
}
|
|
|
|
/** BMS dates are YYYYMMDD; the rest of the app speaks YYYY-MM-DD. */
|
|
export const toDateCode = (isoDate) => isoDate.replaceAll("-", "");
|
|
|
|
function apiUrl(p, date) {
|
|
const q = new URLSearchParams({
|
|
etCodes: p.eventCode,
|
|
dateCode: toDateCode(date),
|
|
isDesktop: "true",
|
|
regionCode: p.regionCode,
|
|
xLocationShared: "false",
|
|
memberId: "", lsId: "", subCode: "",
|
|
appCode: "WEB",
|
|
refEventCode: p.refEventCode || p.eventCode,
|
|
...(p.language ? { language: p.language } : {}),
|
|
});
|
|
return `https://in.bookmyshow.com/api/movies-data/v5/showtimes-by-event/primary-dynamic?${q}`;
|
|
}
|
|
|
|
/** Generated by the BMS web client itself, not issued by the server. */
|
|
const bmsId = () => `1.${Math.floor(Math.random() * 9e8 + 1e8)}.${Date.now()}`;
|
|
|
|
export async function check(cfg, p, date) {
|
|
const impit = await getClient(cfg);
|
|
let res, text;
|
|
try {
|
|
res = await impit.fetch(apiUrl(p, date), {
|
|
headers: {
|
|
accept: "application/json, text/plain, */*",
|
|
"x-region-code": p.regionCode,
|
|
"x-region-slug": p.regionSlug,
|
|
"x-latitude": p.lat,
|
|
"x-longitude": p.lng,
|
|
"x-bms-id": bmsId(),
|
|
"x-app-code": "WEB",
|
|
"x-platform-code": "WEB",
|
|
"x-platform": "WEB",
|
|
},
|
|
});
|
|
text = await res.text();
|
|
} catch (err) {
|
|
throw new CheckError(`fetch failed: ${err.message}`);
|
|
}
|
|
if (res.status !== 200) throw new CheckError(`HTTP ${res.status}`);
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch (err) {
|
|
throw new CheckError(`unparseable body: ${err.message}`);
|
|
}
|
|
// No showtime widgets at all means the date has not gone on sale.
|
|
if (!data?.data?.showtimeWidgets) return { kind: "closed" };
|
|
return { kind: "open", data };
|
|
}
|
|
|
|
/** Venue cards sit several widget layers deep, so walk rather than index blindly. */
|
|
function collectVenueCards(node, out = []) {
|
|
if (Array.isArray(node)) {
|
|
for (const n of node) collectVenueCards(n, out);
|
|
} else if (node && typeof node === "object") {
|
|
if (node.type === "venue-card") out.push(node);
|
|
for (const v of Object.values(node)) collectVenueCards(v, out);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Seat tiers live in the double-tap bottom sheet: type, price and an availability word. */
|
|
function tiersOf(showtime, cfg) {
|
|
const widgets = showtime?.customGestureCTA?.additionalData?.bottomSheetData?.widgets ?? [];
|
|
return widgets
|
|
.filter((w) => w?.variableData?.seatType)
|
|
.map((w) => {
|
|
const v = w.variableData;
|
|
const cost = String(v.seatCost || "").replace(/[^\d.]/g, "");
|
|
const price = cost ? `${cfg.currency}${Math.round(Number(cost))}` : "";
|
|
return `${v.seatType} ${price} (${v.seatAvalibility || ""})`.replace(/\s+/g, " ").trim();
|
|
})
|
|
.join(" · ");
|
|
}
|
|
|
|
function formatOf(showtime) {
|
|
return String(showtime.screenAttr || showtime?.additionalData?.attributes || "");
|
|
}
|
|
|
|
function isMatch(showtime, p) {
|
|
if (!p.formatMatch) return true;
|
|
return formatOf(showtime).toUpperCase().includes(p.formatMatch.toUpperCase());
|
|
}
|
|
|
|
export function extract(data, cfg, p) {
|
|
const cards = collectVenueCards(data?.data?.showtimeWidgets ?? []);
|
|
const out = { matched: [], other: [], cinemaCount: cards.length, sessionCount: 0, showDates: [] };
|
|
for (const card of cards) {
|
|
const info = card.additionalData ?? {};
|
|
const name = info.venueName || info.venueCode || "unknown venue";
|
|
for (const section of card.showtimesSections ?? []) {
|
|
for (const st of section.showtimes ?? []) {
|
|
out.sessionCount++;
|
|
if (!isMatch(st, p)) continue;
|
|
const ad = st.additionalData ?? {};
|
|
const session = {
|
|
sid: String(ad.sessionId || `${info.venueCode}-${ad.showTimeCode || st.title}`),
|
|
cinema: name,
|
|
// BMS already returns local wall-clock time, so no timezone shift is applied.
|
|
time: ad.showTime || st.title || "",
|
|
audi: "",
|
|
format: formatOf(st),
|
|
// BMS exposes availability words per tier, never exact seat counts.
|
|
avail: null,
|
|
total: null,
|
|
tiers: tiersOf(st, cfg),
|
|
};
|
|
const wanted = !p.venueId || String(info.venueCode) === String(p.venueId);
|
|
(wanted ? out.matched : out.other).push(session);
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|