From a3d4495ee400a0fac41a74e0b3a91237c58a4543 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 3 Jul 2026 15:13:45 +0530 Subject: [PATCH] Add BookMyShow adapter --- cli-manifest.json | 170 +++++++++++++++++++++++++++++ clis/bookmyshow/bookmyshow.test.js | 115 +++++++++++++++++++ clis/bookmyshow/cinemas.js | 56 ++++++++++ clis/bookmyshow/events.js | 63 +++++++++++ clis/bookmyshow/movies.js | 58 ++++++++++ clis/bookmyshow/shows.js | 135 +++++++++++++++++++++++ clis/bookmyshow/utils.js | 122 +++++++++++++++++++++ 7 files changed, 719 insertions(+) create mode 100644 clis/bookmyshow/bookmyshow.test.js create mode 100644 clis/bookmyshow/cinemas.js create mode 100644 clis/bookmyshow/events.js create mode 100644 clis/bookmyshow/movies.js create mode 100644 clis/bookmyshow/shows.js create mode 100644 clis/bookmyshow/utils.js diff --git a/cli-manifest.json b/cli-manifest.json index bfdb71e..4a8a9b2 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -5317,6 +5317,176 @@ "modulePath": "booking/search.js", "sourceFile": "booking/search.js" }, + { + "site": "bookmyshow", + "name": "cinemas", + "description": "BookMyShow cinemas in a city", + "access": "read", + "example": "webcmd bookmyshow cinemas --city mumbai --limit 5", + "domain": "in.bookmyshow.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "city", + "type": "string", + "default": "mumbai", + "required": false, + "help": "BookMyShow city name or slug" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of cinemas to return (max 20)" + } + ], + "columns": [ + "rank", + "name", + "address", + "city", + "url" + ], + "type": "js", + "modulePath": "bookmyshow/cinemas.js", + "sourceFile": "bookmyshow/cinemas.js", + "navigateBefore": true + }, + { + "site": "bookmyshow", + "name": "events", + "description": "BookMyShow events in a city", + "access": "read", + "example": "webcmd bookmyshow events --city mumbai --limit 5", + "domain": "in.bookmyshow.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "city", + "type": "string", + "default": "mumbai", + "required": false, + "help": "BookMyShow city name or slug" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of events to return (max 20)" + } + ], + "columns": [ + "rank", + "eventCode", + "title", + "venue", + "category", + "price", + "city", + "url" + ], + "type": "js", + "modulePath": "bookmyshow/events.js", + "sourceFile": "bookmyshow/events.js", + "navigateBefore": true + }, + { + "site": "bookmyshow", + "name": "movies", + "description": "BookMyShow movies running in a city", + "access": "read", + "example": "webcmd bookmyshow movies --city mumbai --limit 5", + "domain": "in.bookmyshow.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "city", + "type": "string", + "default": "mumbai", + "required": false, + "help": "BookMyShow city name or slug" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of movies to return (max 20)" + } + ], + "columns": [ + "rank", + "eventCode", + "title", + "genres", + "city", + "url", + "image" + ], + "type": "js", + "modulePath": "bookmyshow/movies.js", + "sourceFile": "bookmyshow/movies.js", + "navigateBefore": true + }, + { + "site": "bookmyshow", + "name": "shows", + "description": "BookMyShow movie showtimes in a city", + "access": "read", + "example": "webcmd bookmyshow shows alpha --city mumbai --limit 5", + "domain": "in.bookmyshow.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "movie", + "type": "string", + "required": true, + "positional": true, + "help": "Movie title, event code, or BookMyShow movie URL" + }, + { + "name": "city", + "type": "string", + "default": "mumbai", + "required": false, + "help": "BookMyShow city name or slug" + }, + { + "name": "date", + "type": "string", + "required": false, + "help": "Show date as YYYYMMDD; defaults to today" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of showtimes to return (max 20)" + } + ], + "columns": [ + "rank", + "eventCode", + "movie", + "cinema", + "showTime", + "format", + "status", + "city", + "url" + ], + "type": "js", + "modulePath": "bookmyshow/shows.js", + "sourceFile": "bookmyshow/shows.js", + "navigateBefore": true + }, { "site": "boss", "name": "batchgreet", diff --git a/clis/bookmyshow/bookmyshow.test.js b/clis/bookmyshow/bookmyshow.test.js new file mode 100644 index 0000000..5e45907 --- /dev/null +++ b/clis/bookmyshow/bookmyshow.test.js @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { ArgumentError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { createPageMock } from '../test-utils.js'; +import { __test__ as utils } from './utils.js'; +import { __test__ as movies } from './movies.js'; +import { __test__ as events } from './events.js'; +import { __test__ as cinemas } from './cinemas.js'; +import { __test__ as shows } from './shows.js'; + +const movieRows = [ + { + title: 'Alpha', + genres: 'Action/Thriller', + eventCode: 'ET00403805', + url: 'https://in.bookmyshow.com/movies/mumbai/alpha/ET00403805', + image: 'https://assets.example/alpha.jpg', + }, +]; + +describe('bookmyshow utils', () => { + it('normalizes common city names and validates limits', () => { + expect(utils.resolveCity('Delhi NCR')).toMatchObject({ slug: 'national-capital-region-ncr', regionCode: 'NCR' }); + expect(utils.resolveCity('Mumbai')).toMatchObject({ slug: 'mumbai', regionCode: 'MUMBAI' }); + expect(utils.parseLimit(5, 'bookmyshow movies')).toBe(5); + expect(() => utils.parseLimit(0, 'bookmyshow movies')).toThrow(ArgumentError); + }); + + it('parses event code and slug from BookMyShow movie URLs', () => { + expect(utils.parseMovieRef('https://in.bookmyshow.com/movies/mumbai/alpha/ET00403805')).toEqual({ + eventCode: 'ET00403805', + slug: 'alpha', + }); + }); +}); + +describe('bookmyshow movies', () => { + it('returns visible movie cards with stable columns', async () => { + const page = createPageMock([{ ok: true, rows: movieRows }]); + const rows = await movies.command.func(page, { city: 'mumbai', limit: 1 }); + expect(page.goto).toHaveBeenCalledWith('https://in.bookmyshow.com/explore/home/mumbai'); + expect(rows).toEqual([{ + rank: 1, + eventCode: 'ET00403805', + title: 'Alpha', + genres: 'Action/Thriller', + city: 'mumbai', + url: 'https://in.bookmyshow.com/movies/mumbai/alpha/ET00403805', + image: 'https://assets.example/alpha.jpg', + }]); + }); + + it('throws EmptyResultError when no movie cards are visible', async () => { + const page = createPageMock([{ ok: true, rows: [] }]); + await expect(movies.command.func(page, { city: 'mumbai', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); + +describe('bookmyshow events', () => { + it('returns visible event cards', async () => { + const page = createPageMock([{ ok: true, rows: [{ + title: 'Sunburn Festival', + venue: 'Mahalaxmi Race Course: Mumbai', + category: 'Concerts', + price: '3500 onwards', + eventCode: 'ET00498558', + url: 'https://in.bookmyshow.com/events/sunburn-festival-2026/ET00498558', + }] }]); + const rows = await events.command.func(page, { city: 'mumbai', limit: 1 }); + expect(page.goto).toHaveBeenCalledWith('https://in.bookmyshow.com/explore/events-mumbai'); + expect(rows[0]).toMatchObject({ rank: 1, title: 'Sunburn Festival', city: 'mumbai' }); + }); +}); + +describe('bookmyshow cinemas', () => { + it('pairs visible cinema names with addresses', async () => { + const page = createPageMock([{ ok: true, rows: [{ + name: 'Cinepolis: Nexus Seawoods', + address: 'Nerul, Navi Mumbai, Maharashtra 400706, India', + }] }]); + const rows = await cinemas.command.func(page, { city: 'mumbai', limit: 1 }); + expect(page.goto).toHaveBeenCalledWith('https://in.bookmyshow.com/mumbai/cinemas'); + expect(rows[0]).toEqual({ + rank: 1, + name: 'Cinepolis: Nexus Seawoods', + address: 'Nerul, Navi Mumbai, Maharashtra 400706, India', + city: 'mumbai', + url: 'https://in.bookmyshow.com/mumbai/cinemas', + }); + }); +}); + +describe('bookmyshow shows', () => { + it('resolves a movie title before extracting visible showtimes', async () => { + const page = createPageMock([ + { ok: true, rows: movieRows }, + { ok: true, rows: [{ + movie: 'Alpha', + cinema: 'Ajanta Cinema Cinex: Borivali (W) Newly Renovated', + showTime: '04:15 PM', + format: 'DOLBY 9.5', + status: 'AVAILABLE', + }] }, + ]); + const rows = await shows.command.func(page, { city: 'mumbai', movie: 'alpha', limit: 1 }); + expect(page.goto).toHaveBeenNthCalledWith(1, 'https://in.bookmyshow.com/explore/home/mumbai'); + expect(page.goto.mock.calls[1][0]).toMatch('/movies/mumbai/alpha/buytickets/ET00403805/'); + expect(rows[0]).toMatchObject({ + rank: 1, + eventCode: 'ET00403805', + movie: 'Alpha', + cinema: 'Ajanta Cinema Cinex: Borivali (W) Newly Renovated', + showTime: '04:15 PM', + }); + }); +}); diff --git a/clis/bookmyshow/cinemas.js b/clis/bookmyshow/cinemas.js new file mode 100644 index 0000000..7c5751b --- /dev/null +++ b/clis/bookmyshow/cinemas.js @@ -0,0 +1,56 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { HOST, SITE, addRankAndLimit, openAndExtract, parseLimit, resolveCity } from './utils.js'; + +export function buildCinemasExtractScript() { + return `(() => { + const clean = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const root = document.querySelector('[role="grid"]') || document.body; + const leaves = Array.from(root.querySelectorAll('div')) + .filter((el) => !el.children.length) + .map((el) => clean(el.textContent)) + .filter(Boolean); + const rows = []; + const seen = new Set(); + for (let i = 0; i < leaves.length - 1; i += 1) { + const name = leaves[i]; + const address = leaves[i + 1]; + if (!name || !address || seen.has(name)) continue; + if (!/\\bIndia\\b|\\bMaharashtra\\b|\\bDelhi\\b|\\bKarnataka\\b|\\bTamil Nadu\\b|\\bTelangana\\b|\\bWest Bengal\\b|\\bGujarat\\b|\\bKerala\\b/i.test(address)) continue; + seen.add(name); + rows.push({ name, address }); + i += 1; + } + return { ok: true, rows }; + })()`; +} + +export const command = cli({ + site: SITE, + name: 'cinemas', + access: 'read', + description: 'BookMyShow cinemas in a city', + example: 'webcmd bookmyshow cinemas --city mumbai --limit 5', + domain: 'in.bookmyshow.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'city', type: 'string', default: 'mumbai', help: 'BookMyShow city name or slug' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of cinemas to return (max 20)' }, + ], + columns: ['rank', 'name', 'address', 'city', 'url'], + func: async (page, kwargs) => { + const city = resolveCity(kwargs.city); + const limit = parseLimit(kwargs.limit, 'bookmyshow cinemas'); + const url = `${HOST}/${city.slug}/cinemas`; + const rows = await openAndExtract(page, url, buildCinemasExtractScript(), 'bookmyshow cinemas'); + return addRankAndLimit(rows, limit, city.slug, url, (row) => ({ + name: row.name || '', + address: row.address || '', + })); + }, +}); + +export const __test__ = { + command, + buildCinemasExtractScript, +}; diff --git a/clis/bookmyshow/events.js b/clis/bookmyshow/events.js new file mode 100644 index 0000000..c181d00 --- /dev/null +++ b/clis/bookmyshow/events.js @@ -0,0 +1,63 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { HOST, SITE, addRankAndLimit, openAndExtract, parseLimit, resolveCity } from './utils.js'; + +export function buildEventsExtractScript() { + return `(() => { + const clean = (value) => String(value || '').replace(/\\u20b9/g, '').replace(/\\s+/g, ' ').trim(); + const seen = new Set(); + const rows = []; + for (const anchor of Array.from(document.querySelectorAll('a[href]'))) { + const href = anchor.href || ''; + if (!href.includes('/events/') || !/ET\\d{6,}/.test(href)) continue; + const eventCode = (href.match(/ET\\d{6,}/) || [''])[0]; + if (!eventCode || seen.has(eventCode)) continue; + const lines = (anchor.innerText || '').split('\\n').map(clean).filter(Boolean); + const title = clean(anchor.querySelector('img')?.alt) || lines[0] || ''; + if (!title) continue; + seen.add(eventCode); + rows.push({ + title, + venue: lines[1] || '', + category: lines[2] || '', + price: lines.find((line) => /\\d|free|onwards/i.test(line) && line !== title && !/ET\\d/.test(line)) || '', + eventCode, + url: href, + }); + } + return { ok: true, rows }; + })()`; +} + +export const command = cli({ + site: SITE, + name: 'events', + access: 'read', + description: 'BookMyShow events in a city', + example: 'webcmd bookmyshow events --city mumbai --limit 5', + domain: 'in.bookmyshow.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'city', type: 'string', default: 'mumbai', help: 'BookMyShow city name or slug' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of events to return (max 20)' }, + ], + columns: ['rank', 'eventCode', 'title', 'venue', 'category', 'price', 'city', 'url'], + func: async (page, kwargs) => { + const city = resolveCity(kwargs.city); + const limit = parseLimit(kwargs.limit, 'bookmyshow events'); + const url = `${HOST}/explore/events-${city.slug}`; + const rows = await openAndExtract(page, url, buildEventsExtractScript(), 'bookmyshow events'); + return addRankAndLimit(rows, limit, city.slug, url, (row) => ({ + eventCode: row.eventCode || '', + title: row.title || '', + venue: row.venue || '', + category: row.category || '', + price: row.price || '', + })); + }, +}); + +export const __test__ = { + command, + buildEventsExtractScript, +}; diff --git a/clis/bookmyshow/movies.js b/clis/bookmyshow/movies.js new file mode 100644 index 0000000..ce59ec8 --- /dev/null +++ b/clis/bookmyshow/movies.js @@ -0,0 +1,58 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { HOST, SITE, addRankAndLimit, openAndExtract, parseLimit, resolveCity } from './utils.js'; + +export function buildMoviesExtractScript(citySlug) { + return `(() => { + const citySlug = ${JSON.stringify(citySlug)}; + const clean = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const seen = new Set(); + const rows = []; + for (const anchor of Array.from(document.querySelectorAll('a[href]'))) { + const href = anchor.href || ''; + if (!href.includes('/movies/' + citySlug + '/') || !/ET\\d{6,}/.test(href)) continue; + const eventCode = (href.match(/ET\\d{6,}/) || [''])[0]; + if (!eventCode || seen.has(eventCode)) continue; + const lines = (anchor.innerText || '').split('\\n').map(clean).filter(Boolean); + const image = anchor.querySelector('img')?.src || ''; + const title = clean(anchor.querySelector('img')?.alt) || lines[0] || ''; + const genres = lines.find((line) => line !== title && line.includes('/')) || ''; + if (!title) continue; + seen.add(eventCode); + rows.push({ title, genres, eventCode, url: href, image }); + } + return { ok: true, rows }; + })()`; +} + +export const command = cli({ + site: SITE, + name: 'movies', + access: 'read', + description: 'BookMyShow movies running in a city', + example: 'webcmd bookmyshow movies --city mumbai --limit 5', + domain: 'in.bookmyshow.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'city', type: 'string', default: 'mumbai', help: 'BookMyShow city name or slug' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of movies to return (max 20)' }, + ], + columns: ['rank', 'eventCode', 'title', 'genres', 'city', 'url', 'image'], + func: async (page, kwargs) => { + const city = resolveCity(kwargs.city); + const limit = parseLimit(kwargs.limit, 'bookmyshow movies'); + const url = `${HOST}/explore/home/${city.slug}`; + const rows = await openAndExtract(page, url, buildMoviesExtractScript(city.slug), 'bookmyshow movies'); + return addRankAndLimit(rows, limit, city.slug, url, (row) => ({ + eventCode: row.eventCode || '', + title: row.title || '', + genres: row.genres || '', + image: row.image || '', + })); + }, +}); + +export const __test__ = { + command, + buildMoviesExtractScript, +}; diff --git a/clis/bookmyshow/shows.js b/clis/bookmyshow/shows.js new file mode 100644 index 0000000..e7dd480 --- /dev/null +++ b/clis/bookmyshow/shows.js @@ -0,0 +1,135 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { ArgumentError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { buildMoviesExtractScript } from './movies.js'; +import { + HOST, + SITE, + addRankAndLimit, + cleanText, + openAndExtract, + parseDateCode, + parseLimit, + parseMovieRef, + resolveCity, + slugFromTitle, +} from './utils.js'; + +function movieMatches(row, query) { + const q = cleanText(query).toLowerCase(); + if (!q) + return false; + return String(row.eventCode || '').toLowerCase() === q + || cleanText(row.title).toLowerCase().includes(q) + || slugFromTitle(row.title) === slugFromTitle(query); +} + +export function buildShowsExtractScript(movieTitle, eventCode) { + return `(() => { + const movie = ${JSON.stringify(movieTitle)}; + const eventCode = ${JSON.stringify(eventCode)}; + const clean = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const timeRe = /^\\d{1,2}:\\d{2}\\s?(?:AM|PM)$/i; + const ignored = new Set([ + 'Search for Movies, Events, Plays, Sports and Activities', + 'Movies', 'Stream', 'Events', 'Plays', 'Sports', 'Activities', + 'ListYourShow', 'Corporates', 'Offers', 'Gift Cards', + 'Price Range', 'Special Formats', 'Other Filters', 'Preferred Time', + 'Sort By', 'Late night shows', 'Early morning shows', + 'Cancellation available', 'Non-cancellable', 'Cinema servers are not reachable', + ]); + const lines = (document.body.innerText || '').split('\\n').map(clean).filter(Boolean); + const rows = []; + let cinema = ''; + let status = 'AVAILABLE'; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (line === 'AVAILABLE' || line === 'FAST FILLING') { + status = line; + continue; + } + if (ignored.has(line) || /^\\w{3}$/.test(line) || /^\\d{2}$/.test(line)) continue; + if (timeRe.test(line)) { + if (!cinema) continue; + const next = lines[i + 1] || ''; + const format = next && !timeRe.test(next) && !ignored.has(next) && !next.includes(':') ? next : ''; + rows.push({ + movie, + eventCode, + cinema, + showTime: line.toUpperCase(), + format, + status, + url: location.href, + }); + continue; + } + if (line.includes(':') && !timeRe.test(line) && !/^https?:/i.test(line)) { + cinema = line; + } + } + return { ok: true, rows }; + })()`; +} + +export const command = cli({ + site: SITE, + name: 'shows', + access: 'read', + description: 'BookMyShow movie showtimes in a city', + example: 'webcmd bookmyshow shows alpha --city mumbai --limit 5', + domain: 'in.bookmyshow.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'movie', type: 'string', required: true, positional: true, help: 'Movie title, event code, or BookMyShow movie URL' }, + { name: 'city', type: 'string', default: 'mumbai', help: 'BookMyShow city name or slug' }, + { name: 'date', type: 'string', help: 'Show date as YYYYMMDD; defaults to today' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of showtimes to return (max 20)' }, + ], + columns: ['rank', 'eventCode', 'movie', 'cinema', 'showTime', 'format', 'status', 'city', 'url'], + func: async (page, kwargs) => { + const city = resolveCity(kwargs.city); + const limit = parseLimit(kwargs.limit, 'bookmyshow shows'); + const dateCode = parseDateCode(kwargs.date); + const movieQuery = cleanText(kwargs.movie); + if (!movieQuery) { + throw new ArgumentError('bookmyshow shows requires a movie title, event code, or movie URL', 'Example: webcmd bookmyshow shows alpha --city mumbai'); + } + + const parsedRef = parseMovieRef(movieQuery); + let match = null; + const homeUrl = `${HOST}/explore/home/${city.slug}`; + const movieRows = await openAndExtract(page, homeUrl, buildMoviesExtractScript(city.slug), 'bookmyshow movies'); + if (parsedRef.eventCode) { + match = movieRows.find((row) => row.eventCode === parsedRef.eventCode) || { + title: movieQuery, + eventCode: parsedRef.eventCode, + url: `${HOST}/movies/${city.slug}/${parsedRef.slug || slugFromTitle(movieQuery)}/${parsedRef.eventCode}`, + }; + } + else { + match = movieRows.find((row) => movieMatches(row, movieQuery)); + } + if (!match || !match.eventCode) { + throw new EmptyResultError('bookmyshow shows', `No BookMyShow movie matched "${movieQuery}" in ${city.slug}. Try an event code from "bookmyshow movies --city ${city.slug}".`); + } + + const slug = parsedRef.slug || slugFromTitle(match.title); + const showUrl = `${HOST}/movies/${city.slug}/${slug}/buytickets/${match.eventCode}/${dateCode}`; + const rows = await openAndExtract(page, showUrl, buildShowsExtractScript(match.title, match.eventCode), 'bookmyshow shows'); + return addRankAndLimit(rows, limit, city.slug, showUrl, (row) => ({ + eventCode: row.eventCode || match.eventCode, + movie: row.movie || match.title, + cinema: row.cinema || '', + showTime: row.showTime || '', + format: row.format || '', + status: row.status || '', + })); + }, +}); + +export const __test__ = { + command, + buildShowsExtractScript, + movieMatches, +}; diff --git a/clis/bookmyshow/utils.js b/clis/bookmyshow/utils.js new file mode 100644 index 0000000..7f365d1 --- /dev/null +++ b/clis/bookmyshow/utils.js @@ -0,0 +1,122 @@ +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; + +export const SITE = 'bookmyshow'; +export const HOST = 'https://in.bookmyshow.com'; +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 20; + +const CITY_ALIASES = new Map([ + ['mumbai', { slug: 'mumbai', regionCode: 'MUMBAI' }], + ['delhi', { slug: 'national-capital-region-ncr', regionCode: 'NCR' }], + ['delhi ncr', { slug: 'national-capital-region-ncr', regionCode: 'NCR' }], + ['ncr', { slug: 'national-capital-region-ncr', regionCode: 'NCR' }], + ['national capital region ncr', { slug: 'national-capital-region-ncr', regionCode: 'NCR' }], + ['bangalore', { slug: 'bengaluru', regionCode: 'BANG' }], + ['bengaluru', { slug: 'bengaluru', regionCode: 'BANG' }], + ['hyderabad', { slug: 'hyderabad', regionCode: 'HYD' }], + ['chennai', { slug: 'chennai', regionCode: 'CHEN' }], + ['pune', { slug: 'pune', regionCode: 'PUNE' }], + ['kolkata', { slug: 'kolkata', regionCode: 'KOLK' }], + ['ahmedabad', { slug: 'ahmedabad', regionCode: 'AHD' }], + ['kochi', { slug: 'kochi', regionCode: 'KOCH' }], +]); + +export function cleanText(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim(); +} + +export function resolveCity(value = 'mumbai') { + const raw = cleanText(value || 'mumbai').toLowerCase(); + if (!raw) + throw new ArgumentError('bookmyshow --city cannot be empty', 'Example: webcmd bookmyshow movies --city mumbai'); + const known = CITY_ALIASES.get(raw); + if (known) + return { ...known }; + const slug = raw.replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + if (!slug) + throw new ArgumentError(`bookmyshow --city "${value}" is not valid`, 'Use a city name or BookMyShow city slug, for example: mumbai'); + return { slug, regionCode: slug.toUpperCase().replace(/-/g, '_') }; +} + +export function parseLimit(value, commandName) { + const n = value == null || value === '' ? DEFAULT_LIMIT : Number(value); + if (!Number.isInteger(n) || n < 1 || n > MAX_LIMIT) { + throw new ArgumentError(`${commandName} --limit must be an integer between 1 and ${MAX_LIMIT}`, `Example: webcmd ${commandName} --limit 5`); + } + return n; +} + +export function parseDateCode(value) { + if (value == null || value === '') { + const d = new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${yyyy}${mm}${dd}`; + } + const raw = String(value).trim(); + if (!/^\d{8}$/.test(raw)) { + throw new ArgumentError('bookmyshow shows --date must be in YYYYMMDD format', 'Example: webcmd bookmyshow shows alpha --city mumbai --date 20260703'); + } + return raw; +} + +export function parseMovieRef(value) { + const raw = cleanText(value); + const eventCode = raw.match(/ET\d{6,}/i)?.[0]?.toUpperCase() || ''; + let slug = ''; + try { + const url = new URL(raw, HOST); + const parts = url.pathname.split('/').filter(Boolean); + const movieIndex = parts.indexOf('movies'); + if (movieIndex >= 0 && parts[movieIndex + 2]) + slug = parts[movieIndex + 2]; + } + catch { + slug = ''; + } + return { eventCode, slug }; +} + +export function slugFromTitle(value) { + return cleanText(value).toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +} + +export function requireRows(result, commandName) { + if (!result || typeof result !== 'object') { + throw new CommandExecutionError(`${commandName} page returned malformed extraction data`, 'BookMyShow may have changed the page structure.'); + } + if (result.ok === false) { + throw new CommandExecutionError(`${commandName} extraction failed: ${result.error || 'unknown error'}`, 'Open the same BookMyShow page in Chrome and retry.'); + } + const rows = Array.isArray(result.rows) ? result.rows : []; + if (!rows.length) { + throw new EmptyResultError(commandName, 'No visible BookMyShow rows were found. Try another city or open the page in Chrome once.'); + } + return rows; +} + +export function addRankAndLimit(rows, limit, citySlug, url, mapRow) { + return rows.slice(0, limit).map((row, index) => ({ + rank: index + 1, + ...mapRow(row), + city: citySlug, + url: row.url || url, + })); +} + +export async function openAndExtract(page, url, extractScript, commandName) { + await page.goto(url); + await page.wait(2); + return requireRows(await page.evaluate(extractScript), commandName); +} + +export const __test__ = { + cleanText, + resolveCity, + parseLimit, + parseDateCode, + parseMovieRef, + slugFromTitle, + requireRows, +};