@agentrhq/webcmd 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +39 -24
  2. package/cli-manifest.json +504 -0
  3. package/clis/_shared/site-auth.js +6 -1
  4. package/clis/district/_lib.js +566 -0
  5. package/clis/district/auth.js +49 -0
  6. package/clis/district/checkout.js +278 -0
  7. package/clis/district/listings.js +158 -0
  8. package/clis/district/locations.js +211 -0
  9. package/clis/district/search.js +218 -0
  10. package/clis/district/seats.js +233 -0
  11. package/clis/district/set-location.js +82 -0
  12. package/clis/district/showtimes.js +433 -0
  13. package/clis/reddit/popular.js +12 -1
  14. package/clis/reddit/popular.test.js +12 -3
  15. package/dist/src/browser/bridge.d.ts +1 -0
  16. package/dist/src/browser/bridge.js +1 -1
  17. package/dist/src/browser/page.d.ts +4 -1
  18. package/dist/src/browser/page.js +12 -1
  19. package/dist/src/browser/protocol.d.ts +2 -0
  20. package/dist/src/browser/runtime/local-cloak/actions.js +1 -0
  21. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +2 -0
  22. package/dist/src/browser/runtime/local-cloak/session-manager.js +12 -2
  23. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +58 -0
  24. package/dist/src/build-manifest.js +1 -0
  25. package/dist/src/build-manifest.test.js +34 -0
  26. package/dist/src/cli.js +111 -28
  27. package/dist/src/discovery.js +1 -0
  28. package/dist/src/engine.test.js +62 -0
  29. package/dist/src/execution.js +1 -1
  30. package/dist/src/manifest-types.d.ts +2 -0
  31. package/dist/src/registry.d.ts +10 -0
  32. package/dist/src/registry.js +5 -3
  33. package/dist/src/registry.test.js +25 -0
  34. package/dist/src/runtime.d.ts +2 -0
  35. package/dist/src/runtime.js +1 -0
  36. package/dist/src/skills.d.ts +23 -5
  37. package/dist/src/skills.js +87 -45
  38. package/dist/src/skills.test.js +80 -23
  39. package/package.json +2 -2
  40. package/skills/smart-search/SKILL.md +156 -0
  41. package/skills/smart-search/references/sources-ai.md +74 -0
  42. package/skills/smart-search/references/sources-info.md +43 -0
  43. package/skills/smart-search/references/sources-media.md +40 -0
  44. package/skills/smart-search/references/sources-other.md +32 -0
  45. package/skills/smart-search/references/sources-shopping.md +21 -0
  46. package/skills/smart-search/references/sources-social.md +36 -0
  47. package/skills/smart-search/references/sources-tech.md +38 -0
  48. package/skills/smart-search/references/sources-travel.md +26 -0
  49. package/skills/webcmd-adapter-author/SKILL.md +1 -0
  50. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
  51. package/skills/webcmd-autofix/SKILL.md +8 -0
  52. package/skills/webcmd-sitemap-author/SKILL.md +1 -1
@@ -0,0 +1,566 @@
1
+ // Shared District helpers. Not a command module — discovery skips files that
2
+ // do not register a cli() command, so this is import-only for the adapters in
3
+ // this directory. Keep every seat-layout URL and session-state rule here so
4
+ // seats.js and checkout.js cannot drift apart (a dropped `fromdate` in one
5
+ // copy once made District report "booking closed" for a future-dated show).
6
+ import {
7
+ ArgumentError,
8
+ AuthRequiredError,
9
+ CommandExecutionError,
10
+ EmptyResultError,
11
+ TimeoutError,
12
+ getErrorMessage,
13
+ } from '@agentrhq/webcmd/errors';
14
+
15
+ export const BASE = 'https://www.district.in';
16
+ export const GUEST_TOKEN = '1212';
17
+
18
+ export const DEFAULT_LOCATION = {
19
+ cityId: 1,
20
+ pCityId: '57',
21
+ long: 77.1165992,
22
+ lat: 28.4080424,
23
+ pCityKey: 'gurgaon',
24
+ pStateKey: 'haryana',
25
+ countryId: '1',
26
+ placeType: 'GOOGLE_PLACE',
27
+ placeId: 'ChIJQ3GqXwAZDTkR2IGBwuLDcCk',
28
+ subzoneId: '645',
29
+ cityName: 'Delhi NCR',
30
+ pCityName: 'Gurugram',
31
+ source: 'adapter-default',
32
+ };
33
+
34
+ // ── validation ──
35
+
36
+ export function validateTimeout(raw, { def, min, max }) {
37
+ const timeout = Number(raw ?? def);
38
+ if (!Number.isInteger(timeout) || timeout < min || timeout > max) {
39
+ throw new ArgumentError(`timeout must be an integer from ${min} to ${max} seconds`);
40
+ }
41
+ return timeout;
42
+ }
43
+
44
+ export function validateShowId(raw) {
45
+ const showId = String(raw || '').trim();
46
+ if (!showId) throw new ArgumentError('show is required');
47
+ if (!/^\d+-\d+-[a-z0-9]+-\d+$/i.test(showId)) {
48
+ throw new ArgumentError('show must be a District seat-layout URL or showId like 1067859-5829-ob1emi-1067859');
49
+ }
50
+ return showId;
51
+ }
52
+
53
+ export function validateId(raw, name) {
54
+ const value = String(raw || '').trim();
55
+ if (!value) throw new ArgumentError(`${name} is required when show is not a seat-layout URL`);
56
+ if (!/^[a-z0-9]+$/i.test(value)) throw new ArgumentError(`${name} must contain only letters and numbers`);
57
+ return value;
58
+ }
59
+
60
+ // ── seat-layout target and URL ──
61
+
62
+ export function parseSeatUrl(raw) {
63
+ try {
64
+ const url = new URL(String(raw || '').trim(), BASE);
65
+ const match = url.pathname.match(/^\/movies\/seat-layout\/([^/]+)$/i);
66
+ if (!url.hostname.endsWith('district.in') || !match) return null;
67
+
68
+ const showId = url.searchParams.get('encsessionid') || '';
69
+ const contentId = url.searchParams.get('contentid') || url.searchParams.get('content_id') || '';
70
+ if (!showId) throw new ArgumentError('seat-layout URL must include encsessionid');
71
+ if (!contentId) throw new ArgumentError('seat-layout URL must include contentid');
72
+
73
+ return {
74
+ showId: validateShowId(showId),
75
+ formatId: validateId(match[1], 'format-id'),
76
+ contentId: validateId(contentId, 'content-id'),
77
+ fromDate: url.searchParams.get('fromdate') || url.searchParams.get('fromDate') || '',
78
+ cityKey: (url.searchParams.get('citykey') || '').toLowerCase(),
79
+ };
80
+ } catch (error) {
81
+ if (error instanceof ArgumentError) throw error;
82
+ return null;
83
+ }
84
+ }
85
+
86
+ export function makeSeatUrl({ showId, formatId, contentId, fromDate, cityKey }) {
87
+ const url = new URL(`${BASE}/movies/seat-layout/${formatId}`);
88
+ url.searchParams.set('encsessionid', showId);
89
+ url.searchParams.set('freeseating', 'false');
90
+ url.searchParams.set('fromsessions', 'true');
91
+ url.searchParams.set('type', 'MOVIES');
92
+ url.searchParams.set('contentid', contentId);
93
+ if (fromDate) url.searchParams.set('fromdate', fromDate);
94
+ // District ignores this param; it rides along so seat-layout URLs stay
95
+ // self-contained and the adapters can align the browser location to the
96
+ // show's city (see openSeatMap).
97
+ if (cityKey) url.searchParams.set('citykey', cityKey);
98
+ return url.toString();
99
+ }
100
+
101
+ export function resolveSeatTarget(args) {
102
+ const fromUrl = parseSeatUrl(args.show);
103
+ if (fromUrl) return fromUrl;
104
+ return {
105
+ showId: validateShowId(args.show),
106
+ formatId: validateId(args['format-id'], 'format-id'),
107
+ contentId: validateId(args['content-id'], 'content-id'),
108
+ fromDate: '',
109
+ cityKey: '',
110
+ };
111
+ }
112
+
113
+ // ── navigation and page-state waits ──
114
+
115
+ export async function safeGoto(page, url) {
116
+ try {
117
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
118
+ } catch (error) {
119
+ if (!/net::ERR_ABORTED/i.test(getErrorMessage(error))) throw error;
120
+ }
121
+ }
122
+
123
+ export class BookingClosedError extends CommandExecutionError {
124
+ constructor() {
125
+ super('District says booking is now closed for this show');
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Poll a predicate until it reports ok. The predicate may return
131
+ * `{ ok, message, closed }`; `closed: true` means District's "booking is now
132
+ * closed" modal is showing on the genuine seat-layout page. Predicates that
133
+ * do not compute `closed` fall back to a body-text match.
134
+ */
135
+ export async function waitFor(page, label, timeout, predicateSource) {
136
+ const deadline = Date.now() + timeout * 1000;
137
+ let lastState = '';
138
+ while (Date.now() < deadline) {
139
+ const result = await page.evaluate(predicateSource);
140
+ if (result?.ok) return result;
141
+ lastState = result?.message || lastState;
142
+ if (result?.closed ?? /booking is now closed/i.test(lastState)) {
143
+ throw new BookingClosedError();
144
+ }
145
+ await page.wait(Math.min(1, Math.max(0.2, (deadline - Date.now()) / 1000)));
146
+ }
147
+ throw new TimeoutError(label, timeout, lastState || 'Timed out waiting for District page state');
148
+ }
149
+
150
+ // The persistent site tab can carry a stale "Booking is now closed" modal from a
151
+ // previous command; dismiss it so it is not mistaken for this show's state.
152
+ export async function dismissClosedModal(page) {
153
+ return page.evaluate(`
154
+ (() => {
155
+ const text = document.body ? document.body.innerText : '';
156
+ if (!/booking is now closed/i.test(text)) return false;
157
+ const okay = [...document.querySelectorAll('button,[role="button"]')]
158
+ .find((el) => /^okay$/i.test((el.innerText || '').trim()));
159
+ if (okay) okay.click();
160
+ return true;
161
+ })()
162
+ `);
163
+ }
164
+
165
+ /**
166
+ * Navigate to a seat-layout URL and wait until the seat map has rendered.
167
+ * Clears any stale closed-booking modal inherited from the persistent tab and
168
+ * re-navigates once before treating the modal as this show's real state.
169
+ * Throws BookingClosedError when District genuinely reports the show closed.
170
+ */
171
+ export async function ensureSeatLayout(page, url, timeout) {
172
+ await safeGoto(page, url);
173
+ if (await dismissClosedModal(page)) {
174
+ await page.wait(0.5);
175
+ await safeGoto(page, url);
176
+ }
177
+ await waitFor(page, 'district seat map', timeout, `
178
+ (() => {
179
+ const available = document.querySelectorAll('#available-seat,[id="selected-seat"] span').length;
180
+ const bodyText = document.body ? document.body.innerText.replace(/\\s+/g, ' ').trim().slice(0, 240) : '';
181
+ const closed = /booking is now closed/i.test(bodyText)
182
+ && /\\/movies\\/seat-layout\\//.test(location.href);
183
+ return { ok: available > 0, closed, message: bodyText };
184
+ })()
185
+ `);
186
+ }
187
+
188
+ // ── location picker (shared by set-location and the seat-map alignment) ──
189
+
190
+ export async function openLocationPicker(page) {
191
+ const result = await page.evaluate(`
192
+ (() => {
193
+ const header = document.querySelector('#master-header') || document;
194
+ const buttons = [...header.querySelectorAll('button[aria-label]')];
195
+ const target = buttons.find((button) => {
196
+ const text = (button.innerText || '').replace(/\\s+/g, ' ').trim();
197
+ const label = button.getAttribute('aria-label') || '';
198
+ return text && label && !/button|user avatar|search/i.test(label);
199
+ });
200
+ if (!target) return { ok: false, message: 'Could not find the District location button' };
201
+ target.click();
202
+ return { ok: true };
203
+ })()
204
+ `);
205
+ if (!result?.ok) throw new CommandExecutionError(result?.message || 'Could not open District location picker');
206
+ }
207
+
208
+ export async function searchLocation(page, location, timeout) {
209
+ await waitFor(page, 'district location picker', timeout, `
210
+ (() => {
211
+ const input = document.querySelector('input[placeholder*="Search city"], input[placeholder*="area"], input[placeholder*="locality"]');
212
+ const message = document.body ? document.body.innerText.replace(/\\s+/g, ' ').trim().slice(0, 240) : '';
213
+ return { ok: !!input, message };
214
+ })()
215
+ `);
216
+
217
+ const filled = await page.evaluate(`
218
+ (() => {
219
+ const input = document.querySelector('input[placeholder*="Search city"], input[placeholder*="area"], input[placeholder*="locality"]');
220
+ if (!input) return false;
221
+ const value = ${JSON.stringify(location)};
222
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
223
+ input.focus();
224
+ if (setter) setter.call(input, '');
225
+ else input.value = '';
226
+ input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
227
+ if (setter) setter.call(input, value);
228
+ else input.value = value;
229
+ input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
230
+ input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: value.at(-1) || '' }));
231
+ input.dispatchEvent(new Event('change', { bubbles: true }));
232
+ return true;
233
+ })()
234
+ `);
235
+ if (!filled) throw new CommandExecutionError('Could not type into the District location search input');
236
+
237
+ await waitFor(page, 'district location search results', timeout, `
238
+ (() => {
239
+ const normalize = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\\s+/g, ' ').trim();
240
+ const query = normalize(${JSON.stringify(location)});
241
+ const buttons = [...document.querySelectorAll('[role="dialog"] button[aria-label]')];
242
+ const results = buttons.filter((button) => {
243
+ const label = button.getAttribute('aria-label') || '';
244
+ return label && !/Use Current Location|Clear input/i.test(label);
245
+ });
246
+ const hasMatchingResult = results.some((button) => normalize(button.innerText || button.getAttribute('aria-label')).includes(query));
247
+ const message = document.body ? document.body.innerText.replace(/\\s+/g, ' ').trim().slice(0, 240) : '';
248
+ return { ok: results.length > 0 && hasMatchingResult, message };
249
+ })()
250
+ `);
251
+ }
252
+
253
+ export async function chooseLocationResult(page, location, rank) {
254
+ const result = await page.evaluate(`
255
+ (() => {
256
+ const rank = ${JSON.stringify(rank)};
257
+ const normalize = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\\s+/g, ' ').trim();
258
+ const query = normalize(${JSON.stringify(location)});
259
+ const buttons = [...document.querySelectorAll('[role="dialog"] button[aria-label]')].filter((button) => {
260
+ const label = button.getAttribute('aria-label') || '';
261
+ return label && !/Use Current Location|Clear input/i.test(label);
262
+ });
263
+ const records = buttons.map((button) => {
264
+ const spans = [...button.querySelectorAll('span')].map((span) => span.innerText.trim()).filter(Boolean);
265
+ const title = spans[0] || button.getAttribute('aria-label') || '';
266
+ const subtitle = spans[1] || '';
267
+ return { button, title, subtitle };
268
+ });
269
+ const exact = rank === 1 ? records.find((record) => normalize(record.title) === query) : null;
270
+ const picked = exact || records[rank - 1];
271
+ const target = picked?.button;
272
+ if (!target) return { ok: false, message: 'No District location result at rank ' + rank };
273
+ const title = picked.title;
274
+ const subtitle = picked.subtitle;
275
+ target.click();
276
+ return { ok: true, title, subtitle };
277
+ })()
278
+ `);
279
+ if (!result?.ok) throw new EmptyResultError('district location picker', result?.message || `No location result at rank ${rank}`);
280
+ return result;
281
+ }
282
+
283
+ export async function extractAppliedLocation(page, timeout, expectedTitle) {
284
+ const result = await waitFor(page, 'district location applied', timeout, `
285
+ (() => {
286
+ const parseCookie = (name) => {
287
+ const item = document.cookie.split('; ').find((part) => part.startsWith(name + '='));
288
+ if (!item) return null;
289
+ try { return JSON.parse(decodeURIComponent(item.slice(name.length + 1))); } catch { return null; }
290
+ };
291
+ const cookieLoc = parseCookie('location');
292
+ let storageLoc = null;
293
+ try {
294
+ const raw = sessionStorage.getItem('locationdata');
295
+ storageLoc = raw ? JSON.parse(raw)?.data?.location_data || null : null;
296
+ } catch {}
297
+ const header = document.querySelector('#master-header button[aria-label]');
298
+ const headerText = header ? header.innerText.replace(/\\s+/g, ' ').trim() : '';
299
+ const expected = ${JSON.stringify(expectedTitle)};
300
+ const applied = cookieLoc && storageLoc && (!expected || headerText.toLowerCase().includes(expected.toLowerCase()));
301
+ return {
302
+ ok: !!applied,
303
+ message: headerText || (document.body ? document.body.innerText.replace(/\\s+/g, ' ').trim().slice(0, 240) : ''),
304
+ cookieLoc,
305
+ storageLoc,
306
+ headerText
307
+ };
308
+ })()
309
+ `);
310
+
311
+ const cookieLoc = result.cookieLoc || {};
312
+ const storageLoc = result.storageLoc || {};
313
+ return {
314
+ status: 'location_set',
315
+ name: String(cookieLoc.title || storageLoc.display_title || ''),
316
+ city: String(cookieLoc.cityName || storageLoc.city_name || storageLoc.p_city_name || ''),
317
+ state: String(cookieLoc.pStateName || storageLoc.p_state_name || ''),
318
+ cityKey: String(cookieLoc.pCityKey || storageLoc.p_city_key || ''),
319
+ cityId: String(cookieLoc.cityId || storageLoc.city_id || ''),
320
+ placeId: String(cookieLoc.placeId || storageLoc.place_id || ''),
321
+ subzoneId: String(cookieLoc.subzoneId || storageLoc.z_subzone_id || ''),
322
+ lat: Number(cookieLoc.lat || storageLoc.user_lat || storageLoc.gps_lat || 0),
323
+ lng: Number(cookieLoc.long || storageLoc.user_lng || storageLoc.gps_lng || 0),
324
+ availableTabs: String(cookieLoc.availableTabsStr || ''),
325
+ source: 'district_location_picker',
326
+ };
327
+ }
328
+
329
+ /** Drive District's location picker end-to-end from the home page. */
330
+ export async function applyLocationViaPicker(page, query, timeout, rank = 1) {
331
+ await safeGoto(page, BASE);
332
+ await page.wait(1);
333
+ await openLocationPicker(page);
334
+ await searchLocation(page, query, timeout);
335
+ const selected = await chooseLocationResult(page, query, rank);
336
+ return extractAppliedLocation(page, timeout, selected.title);
337
+ }
338
+
339
+ /** Read the pCityKey from the District location cookie; the page must be on district.in. */
340
+ export async function currentCityKey(page) {
341
+ const result = await page.evaluate(`
342
+ (() => {
343
+ const item = document.cookie.split('; ').find((part) => part.startsWith('location='));
344
+ if (!item) return '';
345
+ try { return String(JSON.parse(decodeURIComponent(item.slice('location='.length))).pCityKey || ''); } catch { return ''; }
346
+ })()
347
+ `);
348
+ return String(result || '').toLowerCase();
349
+ }
350
+
351
+ /**
352
+ * Open a show's seat map, healing the two known false "booking closed"
353
+ * verdicts: a stale modal in the persistent tab (ensureSeatLayout handles
354
+ * that) and a browser location pointing at a different city than the show —
355
+ * District scopes the seat layout to the selected city and renders a bogus
356
+ * closed modal for out-of-city sessions. When the target carries a cityKey
357
+ * (showtimes embeds it in seat-layout URLs), a mismatch is fixed by driving
358
+ * the location picker, then retrying once.
359
+ */
360
+ export async function openSeatMap(page, target, timeout) {
361
+ const url = makeSeatUrl(target);
362
+ try {
363
+ await ensureSeatLayout(page, url, timeout);
364
+ return target;
365
+ } catch (error) {
366
+ if (!(error instanceof BookingClosedError) && !(error instanceof TimeoutError)) throw error;
367
+ if (!target.cityKey || (await currentCityKey(page)) === target.cityKey) throw error;
368
+ await applyLocationViaPicker(page, target.cityKey.replace(/-/g, ' '), timeout);
369
+ await ensureSeatLayout(page, url, timeout);
370
+ return target;
371
+ }
372
+ }
373
+
374
+ // ── auth ──
375
+
376
+ function normalizeIdentity(userData) {
377
+ return {
378
+ user_id: String(userData.user_id || userData.id || ''),
379
+ name: String(userData.name || ''),
380
+ phone_number: String(userData.phone_number || userData.phoneNumber || ''),
381
+ email: String(userData.email_id || userData.emailId || ''),
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Probe District's profile endpoint from the page context. Returns the
387
+ * identity when logged in; throws AuthRequiredError when the session is
388
+ * anonymous. The page must already be on a district.in URL.
389
+ */
390
+ export async function profileProbe(page) {
391
+ const result = await page.evaluate(`
392
+ (async () => {
393
+ const headers = {
394
+ 'content-type': 'application/json',
395
+ 'x-client-id': 'district-web',
396
+ 'x-app-type': 'ed_web',
397
+ 'x-app-version': '11.11.1'
398
+ };
399
+ if (window.accessToken) headers['x-access-token'] = window.accessToken;
400
+ if (window.refreshToken) headers['x-refresh-token'] = window.refreshToken;
401
+ const resp = await fetch('https://www.district.in/gw/consumer/profile/billing/details', {
402
+ headers,
403
+ credentials: 'include'
404
+ });
405
+ const text = await resp.text();
406
+ let data = null;
407
+ try { data = JSON.parse(text); } catch {}
408
+ return { status: resp.status, text, data };
409
+ })()
410
+ `);
411
+
412
+ if (!result || typeof result !== 'object') {
413
+ throw new CommandExecutionError('District profile probe returned an unexpected response');
414
+ }
415
+ if (result.status === 401 || result.status === 403) {
416
+ throw new AuthRequiredError('www.district.in', 'District profile endpoint requires login');
417
+ }
418
+ if (result.status < 200 || result.status >= 300) {
419
+ throw new CommandExecutionError(`District profile probe failed: HTTP ${result.status}`);
420
+ }
421
+
422
+ const userData = result.data?.user_data;
423
+ const identity = userData && typeof userData === 'object' ? normalizeIdentity(userData) : null;
424
+ if (!identity || (!identity.user_id && !identity.name && !identity.phone_number && !identity.email)) {
425
+ throw new AuthRequiredError('www.district.in', 'Waiting for District user identity after login');
426
+ }
427
+
428
+ return {
429
+ logged_in: true,
430
+ site: 'district',
431
+ ...identity,
432
+ };
433
+ }
434
+
435
+ // ── District API (location headers + movie sessions) ──
436
+
437
+ function locationHeaders(loc) {
438
+ return {
439
+ 'x-city-id': String(loc.cityId || ''),
440
+ 'x-pcity-id': String(loc.pCityId || loc.cityId || ''),
441
+ 'x-user-lng': String(loc.long),
442
+ 'x-user-lat': String(loc.lat),
443
+ 'x-pcity-key': loc.pCityKey || '',
444
+ 'x-pstate-key': loc.pStateKey || '',
445
+ 'x-country-id': String(loc.countryId || '1'),
446
+ 'x-place-type': loc.placeType || 'GOOGLE_PLACE',
447
+ 'x-place-id': loc.placeId || '',
448
+ 'x-gps-lat': String(loc.lat),
449
+ 'x-gps-lng': String(loc.long),
450
+ 'x-subzone-id': String(loc.subzoneId || ''),
451
+ 'x-available-tabs': loc.availableTabs || 'movies,events,dining,attr_home,attraction,play,shopping,ipl',
452
+ 'x-city-name': loc.cityName || loc.pCityName || '',
453
+ 'x-pcity-name': loc.pCityName || loc.cityName || '',
454
+ };
455
+ }
456
+
457
+ export function districtHeaders({ loc = DEFAULT_LOCATION, accept = 'application/json, text/plain, */*', referer = `${BASE}/movies/` } = {}) {
458
+ return {
459
+ Accept: accept,
460
+ 'Accept-Language': 'en-IN,en;q=0.9',
461
+ Referer: referer,
462
+ 'User-Agent': 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari/537.36',
463
+ 'x-guest-token': GUEST_TOKEN,
464
+ 'x-app-type': 'ed_web',
465
+ 'x-client-id': 'district-web',
466
+ 'x-app-version': '11.11.1',
467
+ 'x-device-id': crypto.randomUUID(),
468
+ 'x-is-granular-loc': 'false',
469
+ 'x-is-events-supported': 'true',
470
+ 'x-is-movies-supported': 'true',
471
+ 'x-is-dining-supported': 'true',
472
+ 'x-gps-permission-given': '0',
473
+ ...locationHeaders(loc),
474
+ };
475
+ }
476
+
477
+ /** Read the District location cookie from the browser session, falling back to the adapter default. */
478
+ export async function browserLocation(page) {
479
+ await safeGoto(page, BASE);
480
+ const result = await page.evaluate(`
481
+ (() => {
482
+ const cookie = document.cookie.split('; ').find((part) => part.startsWith('location='));
483
+ if (!cookie) return null;
484
+ try { return JSON.parse(decodeURIComponent(cookie.slice('location='.length))); } catch { return null; }
485
+ })()
486
+ `);
487
+ if (!result) return DEFAULT_LOCATION;
488
+ return {
489
+ cityId: result.cityId || result.id || DEFAULT_LOCATION.cityId,
490
+ pCityId: result.pCityId || result.cityId || result.id || DEFAULT_LOCATION.pCityId,
491
+ long: Number(result.long || DEFAULT_LOCATION.long),
492
+ lat: Number(result.lat || DEFAULT_LOCATION.lat),
493
+ pCityKey: result.pCityKey || DEFAULT_LOCATION.pCityKey,
494
+ pStateKey: result.pStateKey || DEFAULT_LOCATION.pStateKey,
495
+ countryId: result.countryId || '1',
496
+ placeType: result.placeType || result.entity_type || DEFAULT_LOCATION.placeType,
497
+ placeId: result.placeId || result.google_place_id || DEFAULT_LOCATION.placeId,
498
+ subzoneId: result.subzoneId || DEFAULT_LOCATION.subzoneId,
499
+ cityName: result.cityName || result.title || DEFAULT_LOCATION.cityName,
500
+ pCityName: result.pCityName || result.cityName || DEFAULT_LOCATION.pCityName,
501
+ availableTabs: result.availableTabsStr || DEFAULT_LOCATION.availableTabs,
502
+ source: 'browser-location',
503
+ };
504
+ }
505
+
506
+ export async function fetchMovieSessions({ loc, contentId, formatId, date, referer }) {
507
+ const url = new URL(`${BASE}/gw/consumer/movies/v5/movie`);
508
+ const params = {
509
+ version: '3',
510
+ site_id: '1',
511
+ channel: 'mweb',
512
+ child_site_id: '1',
513
+ platform: 'district',
514
+ movieCode: formatId || contentId,
515
+ city_key: loc.pCityKey,
516
+ content_id: contentId,
517
+ latitude: String(loc.lat),
518
+ longitude: String(loc.long),
519
+ cinemaOrderLogic: '3',
520
+ };
521
+ if (date) params.date = date;
522
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
523
+
524
+ const resp = await fetch(url, { headers: districtHeaders({ loc, referer }) });
525
+ if (!resp.ok) throw new CommandExecutionError(`district showtimes request failed: HTTP ${resp.status}`);
526
+ const data = await resp.json();
527
+ if (data?.status?.status === 'STATUS_FAILURE') {
528
+ throw new CommandExecutionError(data.status.message || 'district showtimes request failed');
529
+ }
530
+ return data;
531
+ }
532
+
533
+ /**
534
+ * Re-resolve a possibly stale show session. A District showId encodes
535
+ * `cid-sid-mid-cid` (cinema, session, movie-format); this looks the same
536
+ * session up again via the sessions API and returns a fresh target for
537
+ * makeSeatUrl, or null when the show is no longer offered.
538
+ */
539
+ export async function refreshShowSession(page, target) {
540
+ const [cid, sid] = String(target.showId).split('-');
541
+ const loc = await browserLocation(page);
542
+ const payload = await fetchMovieSessions({
543
+ loc,
544
+ contentId: target.contentId,
545
+ formatId: target.formatId,
546
+ date: target.fromDate,
547
+ referer: `${BASE}/movies/`,
548
+ });
549
+ const cinemas = [
550
+ ...(Array.isArray(payload?.pageData?.nearbyCinemas) ? payload.pageData.nearbyCinemas : []),
551
+ ...(Array.isArray(payload?.pageData?.farCinemas) ? payload.pageData.farCinemas : []),
552
+ ];
553
+ for (const cinema of cinemas) {
554
+ for (const session of Array.isArray(cinema.sessions) ? cinema.sessions : []) {
555
+ if (String(session.sid) !== sid || String(session.cid) !== cid) continue;
556
+ const encSessionId = String(session.encSessionId || `${session.cid}-${session.sid}-${String(session.mid || '').toLowerCase()}-${session.cid}`);
557
+ return {
558
+ ...target,
559
+ showId: encSessionId,
560
+ formatId: String(target.formatId || session.mcd || ''),
561
+ fromDate: target.fromDate || String(payload?.meta?.selectedShowDate || ''),
562
+ };
563
+ }
564
+ }
565
+ return null;
566
+ }
@@ -0,0 +1,49 @@
1
+ import { CommandExecutionError } from '@agentrhq/webcmd/errors';
2
+ import { registerSiteAuthCommands } from '../_shared/site-auth.js';
3
+ import { BASE, profileProbe, safeGoto } from './_lib.js';
4
+
5
+ // District's login is an OTP modal opened from the header avatar, not a page.
6
+ async function openLoginModal(page) {
7
+ await safeGoto(page, BASE);
8
+ await page.wait(1);
9
+
10
+ const clicked = await page.evaluate(`
11
+ (() => {
12
+ const candidates = [
13
+ ...document.querySelectorAll('[role="button"][aria-label="User Avatar"]'),
14
+ ...document.querySelectorAll('[aria-label="User Avatar"]')
15
+ ];
16
+ const target = candidates.find((el) => el instanceof HTMLElement);
17
+ if (!target) return false;
18
+ target.click();
19
+ return true;
20
+ })()
21
+ `);
22
+
23
+ if (!clicked) {
24
+ throw new CommandExecutionError('Could not find the District user avatar login entry point');
25
+ }
26
+ }
27
+
28
+ registerSiteAuthCommands({
29
+ site: 'district',
30
+ domain: 'www.district.in',
31
+ loginUrl: BASE,
32
+ openLogin: openLoginModal,
33
+ columns: ['user_id', 'name', 'phone_number', 'email'],
34
+ // Identity check needs a district.in page context for the profile fetch.
35
+ verify: async (page) => {
36
+ await safeGoto(page, BASE);
37
+ await page.wait(1);
38
+ return profileProbe(page);
39
+ },
40
+ // While the OTP modal is open, probe without navigating away from it.
41
+ poll: async (page) => profileProbe(page),
42
+ quickCheck: async (page) => {
43
+ try {
44
+ return { logged_in: true, ...await profileProbe(page) };
45
+ } catch {
46
+ return false;
47
+ }
48
+ },
49
+ });