Files
ticket-monitoring/src/providers/bookmyshow.js
T
Aculix Technologies 5f86252df1 fix: make the SOCKS5 proxy work for BookMyShow too
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.
2026-08-26 17:54:22 +05:30

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;
}