mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
BookMyShow blocks datacenter IPs on top of TLS fingerprinting, confirmed by CI runners getting 403 with a working impersonation, so cloud hosts need the proxy for BMS as well as District. impit resolves DNS locally for socks5:// and hands an IPv6-only exit an unreachable IPv4 address, so the scheme is normalized to socks5h://. Verified end to end: a proxied BMS check returns 200 and alerts. Docs said the proxy did nothing for BookMyShow, which was wrong.
150 lines
5.4 KiB
JavaScript
150 lines
5.4 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"));
|
|
// With socks5:// impit resolves DNS locally and asks the proxy for that address,
|
|
// which fails outright on an IPv6-only exit that cannot reach the IPv4 we picked.
|
|
// socks5h:// leaves resolution to the proxy, so normalize to it.
|
|
const proxyUrl = cfg.proxyUrl ? cfg.proxyUrl.replace(/^socks5:\/\//i, "socks5h://") : "";
|
|
client = new Impit({
|
|
browser: "chrome",
|
|
...(proxyUrl ? { 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;
|
|
}
|