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