@agentrhq/webcmd 0.2.0 → 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 (78) hide show
  1. package/README.md +58 -31
  2. package/cli-manifest.json +506 -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/clis/twitter/delete.js +14 -6
  16. package/clis/twitter/delete.test.js +74 -1
  17. package/clis/twitter/timeline.js +3 -1
  18. package/clis/twitter/timeline.test.js +4 -0
  19. package/dist/src/browser/bridge-readiness.test.js +1 -1
  20. package/dist/src/browser/bridge.d.ts +2 -0
  21. package/dist/src/browser/bridge.js +6 -4
  22. package/dist/src/browser/cdp.d.ts +1 -0
  23. package/dist/src/browser/daemon-client.d.ts +1 -0
  24. package/dist/src/browser/daemon-client.js +7 -2
  25. package/dist/src/browser/daemon-client.test.js +33 -30
  26. package/dist/src/browser/daemon-transport.js +3 -19
  27. package/dist/src/browser/page.d.ts +5 -1
  28. package/dist/src/browser/page.js +16 -1
  29. package/dist/src/browser/profile.d.ts +9 -0
  30. package/dist/src/browser/profile.js +18 -7
  31. package/dist/src/browser/profile.test.d.ts +1 -0
  32. package/dist/src/browser/profile.test.js +44 -0
  33. package/dist/src/browser/protocol.d.ts +3 -0
  34. package/dist/src/browser/runtime/local-cloak/actions.js +25 -10
  35. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +3 -0
  36. package/dist/src/browser/runtime/local-cloak/session-manager.js +15 -2
  37. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +145 -0
  38. package/dist/src/build-manifest.js +1 -0
  39. package/dist/src/build-manifest.test.js +34 -0
  40. package/dist/src/cli.js +133 -45
  41. package/dist/src/cli.test.js +2 -2
  42. package/dist/src/commands/daemon.test.js +13 -13
  43. package/dist/src/constants.d.ts +2 -3
  44. package/dist/src/constants.js +3 -10
  45. package/dist/src/daemon.js +1 -5
  46. package/dist/src/discovery.js +1 -0
  47. package/dist/src/doctor.js +12 -0
  48. package/dist/src/doctor.test.js +30 -3
  49. package/dist/src/engine.test.js +62 -0
  50. package/dist/src/execution.js +5 -3
  51. package/dist/src/external.js +19 -1
  52. package/dist/src/external.test.js +37 -1
  53. package/dist/src/main.js +0 -5
  54. package/dist/src/manifest-types.d.ts +2 -0
  55. package/dist/src/node-network.test.js +3 -3
  56. package/dist/src/registry.d.ts +10 -0
  57. package/dist/src/registry.js +5 -3
  58. package/dist/src/registry.test.js +25 -0
  59. package/dist/src/runtime-identity.test.js +0 -8
  60. package/dist/src/runtime.d.ts +4 -0
  61. package/dist/src/runtime.js +2 -0
  62. package/dist/src/skills.d.ts +23 -5
  63. package/dist/src/skills.js +87 -45
  64. package/dist/src/skills.test.js +80 -23
  65. package/package.json +3 -3
  66. package/skills/smart-search/SKILL.md +156 -0
  67. package/skills/smart-search/references/sources-ai.md +74 -0
  68. package/skills/smart-search/references/sources-info.md +43 -0
  69. package/skills/smart-search/references/sources-media.md +40 -0
  70. package/skills/smart-search/references/sources-other.md +32 -0
  71. package/skills/smart-search/references/sources-shopping.md +21 -0
  72. package/skills/smart-search/references/sources-social.md +36 -0
  73. package/skills/smart-search/references/sources-tech.md +38 -0
  74. package/skills/smart-search/references/sources-travel.md +26 -0
  75. package/skills/webcmd-adapter-author/SKILL.md +1 -0
  76. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
  77. package/skills/webcmd-autofix/SKILL.md +8 -0
  78. package/skills/webcmd-sitemap-author/SKILL.md +1 -1
@@ -0,0 +1,278 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import {
3
+ ArgumentError,
4
+ AuthRequiredError,
5
+ CommandExecutionError,
6
+ EmptyResultError,
7
+ TimeoutError,
8
+ } from '@agentrhq/webcmd/errors';
9
+ import {
10
+ BookingClosedError,
11
+ openSeatMap,
12
+ profileProbe,
13
+ refreshShowSession,
14
+ resolveSeatTarget,
15
+ validateTimeout,
16
+ waitFor,
17
+ } from './_lib.js';
18
+
19
+ const DEFAULT_TIMEOUT_SECONDS = 45;
20
+
21
+ function parseSeatList(raw) {
22
+ const seats = String(raw || '')
23
+ .split(',')
24
+ .map((seat) => seat.trim().toUpperCase())
25
+ .filter(Boolean);
26
+ if (!seats.length) throw new ArgumentError('seats is required, for example --seats I22,I21');
27
+ if (seats.length > 10) throw new ArgumentError('seats must contain 10 seats or fewer');
28
+ for (const seat of seats) {
29
+ if (!/^[A-Z]+[0-9]+$/.test(seat)) throw new ArgumentError(`invalid seat "${seat}"; use row+number like I22`);
30
+ }
31
+ if (new Set(seats).size !== seats.length) throw new ArgumentError('seats must not contain duplicates');
32
+ return seats;
33
+ }
34
+
35
+ async function dismissSeatDrawer(page) {
36
+ await page.evaluate(`
37
+ (() => {
38
+ const controls = [...document.querySelectorAll('button,[role="button"],[data-testid="close-icon"]')];
39
+ const continueButton = controls.find((el) => /continue booking/i.test(el.innerText || el.getAttribute('aria-label') || ''));
40
+ if (continueButton) {
41
+ continueButton.click();
42
+ return true;
43
+ }
44
+ return false;
45
+ })()
46
+ `);
47
+ await page.wait(0.5);
48
+ }
49
+
50
+ async function selectRequestedSeats(page, requestedSeats, timeout) {
51
+ const selected = [];
52
+ for (const seat of requestedSeats) {
53
+ const result = await page.evaluate(`
54
+ (() => {
55
+ const wanted = ${JSON.stringify(seat)};
56
+ const parse = (el) => {
57
+ const aria = el.getAttribute('aria-label') || '';
58
+ const row = ((aria.match(/row\\s+([^,\\s]+)/i) || [])[1] || '').trim().toUpperCase();
59
+ const number = (el.querySelector('label')?.innerText || el.innerText || '').replace(/\\s+/g, '').trim();
60
+ const seatState = /selected/i.test(aria)
61
+ ? 'selected'
62
+ : (/available/i.test(aria) ? 'available' : 'unavailable');
63
+ return { label: row && number ? row + number : '', seatState };
64
+ };
65
+ const candidates = [...document.querySelectorAll('#available-seat,[id="selected-seat"] span,[aria-label*="seat"]')];
66
+ const target = candidates.map((el) => ({ el, parsed: parse(el) })).find((item) => item.parsed.label === wanted);
67
+ if (!target) return { ok: false, code: 'not_found', message: wanted + ' was not found in the rendered seat map' };
68
+ if (target.parsed.seatState === 'selected') return { ok: true, action: 'already_selected' };
69
+ if (target.parsed.seatState !== 'available') return { ok: false, code: 'unavailable', message: wanted + ' is not available' };
70
+ target.el.click();
71
+ return { ok: true, action: 'clicked' };
72
+ })()
73
+ `);
74
+ if (!result?.ok) {
75
+ if (result?.code === 'not_found') throw new EmptyResultError('district checkout', result.message);
76
+ throw new CommandExecutionError(result?.message || `Could not select ${seat}`);
77
+ }
78
+
79
+ await waitFor(page, 'district checkout seat selection', timeout, `
80
+ (() => {
81
+ const wanted = ${JSON.stringify(seat)};
82
+ const selectedSeats = [...document.querySelectorAll('#selected-seat span,[aria-label^="selected class"]')].map((el) => {
83
+ const aria = el.getAttribute('aria-label') || '';
84
+ const row = ((aria.match(/row\\s+([^,\\s]+)/i) || [])[1] || '').trim().toUpperCase();
85
+ const number = (el.querySelector('label')?.innerText || el.innerText || '').replace(/\\s+/g, '').trim();
86
+ return row && number ? row + number : '';
87
+ }).filter(Boolean);
88
+ const bodyText = document.body ? document.body.innerText.replace(/\\s+/g, ' ').trim().slice(0, 240) : '';
89
+ return { ok: selectedSeats.includes(wanted), message: bodyText };
90
+ })()
91
+ `);
92
+ selected.push(seat);
93
+ }
94
+ return selected;
95
+ }
96
+
97
+ async function clickProceed(page, timeout) {
98
+ const result = await page.evaluate(`
99
+ (() => {
100
+ const controls = [...document.querySelectorAll('button,[role="button"],a')];
101
+ const proceed = controls.find((el) => {
102
+ const text = (el.innerText || '').replace(/\\s+/g, ' ').trim();
103
+ const label = el.getAttribute('aria-label') || '';
104
+ return /^Proceed$/i.test(text) || /^Proceed$/i.test(label);
105
+ });
106
+ if (!proceed) return { ok: false, message: 'Proceed button was not visible after selecting seats' };
107
+ proceed.click();
108
+ return { ok: true };
109
+ })()
110
+ `);
111
+ if (!result?.ok) throw new CommandExecutionError(result?.message || 'Could not click Proceed');
112
+
113
+ // District often interposes a food-and-drinks upsell drawer between seat
114
+ // selection and the review page; skip it while waiting.
115
+ await waitFor(page, 'district checkout review page', timeout, `
116
+ (() => {
117
+ const href = location.href;
118
+ const text = document.body ? document.body.innerText.replace(/\\s+/g, ' ').trim() : '';
119
+ if (!/\\/movies\\/order-review\\//.test(href) && /order food and drinks/i.test(text)) {
120
+ const skip = [...document.querySelectorAll('button,[role="button"]')]
121
+ .find((el) => /^skip$/i.test((el.innerText || '').trim()));
122
+ if (skip) skip.click();
123
+ }
124
+ return {
125
+ ok: /\\/movies\\/order-review\\//.test(href) && /Pay now|Payment summary|Review your booking/i.test(text),
126
+ message: text.slice(0, 240)
127
+ };
128
+ })()
129
+ `);
130
+ }
131
+
132
+ // The order-review page paints the amounts after the header, so a single-shot
133
+ // read can catch loading skeletons; poll until the payable total is visible.
134
+ async function extractReview(page, target, seats, timeout) {
135
+ const result = await waitFor(page, 'district checkout payment summary', timeout, `
136
+ (() => {
137
+ const showId = ${JSON.stringify(target.showId)};
138
+ const seats = ${JSON.stringify(seats.join(','))};
139
+ const lines = (document.body?.innerText || '').split('\\n').map((line) => line.trim()).filter(Boolean);
140
+ const amountAfter = (label) => {
141
+ const index = lines.findIndex((line) => line.toLowerCase().includes(label.toLowerCase()));
142
+ if (index < 0) return '';
143
+ const amount = lines.slice(index + 1, index + 5).find((line) => /^₹\\s*[0-9,.]+/.test(line));
144
+ return amount || '';
145
+ };
146
+ const movie = [...document.querySelectorAll('h1')]
147
+ .map((el) => el.innerText.trim())
148
+ .find((text) => text && !/review your booking/i.test(text)) || '';
149
+ const ticketCount = lines.find((line) => /^\\d+ tickets?$/i.test(line)) || String(${JSON.stringify(seats.length)});
150
+ const seatLine = lines.find((line) => / - [A-Z]+\\d+(?:\\s*,\\s*[A-Z]+\\d+)*/.test(line)) || '';
151
+ const cinema = lines.find((line) => /,/.test(line) && !/^₹/.test(line) && !/District|Booking|GST|approx/i.test(line)) || '';
152
+ const date = lines.find((line) => /today|tomorrow|\\b\\d{1,2}\\s+[A-Z][a-z]{2}\\b/i.test(line)) || '';
153
+ const time = lines.find((line) => /\\b\\d{1,2}:\\d{2}\\s*[AP]M\\b.*\\b\\d{1,2}:\\d{2}\\s*[AP]M\\b/i.test(line)) || '';
154
+ const review = {
155
+ status: 'ready_for_payment',
156
+ movie,
157
+ cinema,
158
+ date,
159
+ time,
160
+ seats: seatLine ? seatLine.replace(/^.*? - /, '').trim() : seats,
161
+ ticketCount,
162
+ orderAmount: amountAfter('Order amount'),
163
+ bookingCharge: amountAfter('Booking charge'),
164
+ total: amountAfter('To be paid') || amountAfter('TOTAL'),
165
+ paymentUrl: location.href,
166
+ showId,
167
+ };
168
+ return {
169
+ ok: Boolean(review.total && review.paymentUrl),
170
+ message: lines.slice(0, 12).join(' | ').slice(0, 240),
171
+ review,
172
+ };
173
+ })()
174
+ `);
175
+ return result.review;
176
+ }
177
+
178
+ /**
179
+ * Open the seat map, self-healing once when the show session looks stale:
180
+ * openSeatMap already fixes stale modals and city-mismatch; on a remaining
181
+ * closed-booking verdict or a seat map that never renders, one fresh
182
+ * showtimes lookup re-resolves the same cinema session before giving up.
183
+ * Only this phase retries — after seats are selected the flow never
184
+ * restarts, so a held selection is never doubled.
185
+ */
186
+ async function openSeatMapWithRefresh(page, target, timeout) {
187
+ try {
188
+ return await openSeatMap(page, target, timeout);
189
+ } catch (error) {
190
+ if (!(error instanceof BookingClosedError) && !(error instanceof TimeoutError)) throw error;
191
+ const fresh = await refreshShowSession(page, target);
192
+ if (!fresh) {
193
+ throw new CommandExecutionError(
194
+ 'District no longer offers this show session; re-run webcmd district showtimes and pick a current show',
195
+ );
196
+ }
197
+ return await openSeatMap(page, fresh, timeout);
198
+ }
199
+ }
200
+
201
+ cli({
202
+ site: 'district',
203
+ name: 'checkout',
204
+ access: 'write',
205
+ description: 'Select District movie seats and stop at the payment handoff page',
206
+ domain: 'www.district.in',
207
+ strategy: Strategy.COOKIE,
208
+ browser: true,
209
+ navigateBefore: false,
210
+ defaultWindowMode: 'foreground',
211
+ siteSession: 'persistent',
212
+ // Checkout is the most state-sensitive district command: always start on a
213
+ // clean page so modals/drawers left by earlier commands cannot poison it.
214
+ freshPage: true,
215
+ args: [
216
+ {
217
+ name: 'show',
218
+ positional: true,
219
+ required: true,
220
+ help: 'District seat-layout URL or showId from district showtimes',
221
+ },
222
+ {
223
+ name: 'seats',
224
+ required: true,
225
+ help: 'Comma-separated seat labels to select, e.g. I22,I21',
226
+ },
227
+ {
228
+ name: 'format-id',
229
+ help: 'District formatId from showtimes; required when show is a showId',
230
+ },
231
+ {
232
+ name: 'content-id',
233
+ help: 'District content id; required when show is a showId',
234
+ },
235
+ {
236
+ name: 'timeout',
237
+ type: 'int',
238
+ default: DEFAULT_TIMEOUT_SECONDS,
239
+ help: 'Maximum seconds to wait for selection and review page',
240
+ },
241
+ ],
242
+ columns: [
243
+ 'status',
244
+ 'movie',
245
+ 'cinema',
246
+ 'date',
247
+ 'time',
248
+ 'seats',
249
+ 'ticketCount',
250
+ 'orderAmount',
251
+ 'bookingCharge',
252
+ 'total',
253
+ 'paymentUrl',
254
+ 'showId',
255
+ ],
256
+ func: async (page, args) => {
257
+ const seats = parseSeatList(args.seats);
258
+ const timeout = validateTimeout(args.timeout, { def: DEFAULT_TIMEOUT_SECONDS, min: 10, max: 180 });
259
+
260
+ const target = await openSeatMapWithRefresh(page, resolveSeatTarget(args), timeout);
261
+
262
+ // Gate on login before touching seats: District bounces Proceed into the
263
+ // OTP flow, which would waste the whole selection.
264
+ try {
265
+ await profileProbe(page);
266
+ } catch (error) {
267
+ if (error instanceof AuthRequiredError) {
268
+ throw new AuthRequiredError('www.district.in', 'District login required before checkout. Run: webcmd district login');
269
+ }
270
+ throw error;
271
+ }
272
+
273
+ await dismissSeatDrawer(page);
274
+ const selected = await selectRequestedSeats(page, seats, timeout);
275
+ await clickProceed(page, timeout);
276
+ return extractReview(page, target, selected, timeout);
277
+ },
278
+ });
@@ -0,0 +1,158 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
3
+
4
+ const BASE = 'https://www.district.in';
5
+ const SECTIONS = new Map([
6
+ ['home', '/'],
7
+ ['for-you', '/'],
8
+ ['movies', '/movies/'],
9
+ ['events', '/events/'],
10
+ ]);
11
+
12
+ function decodeHtml(value) {
13
+ return String(value || '')
14
+ .replace(/<[^>]*>/g, ' ')
15
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
16
+ .replace(/&#(\d+);/g, (_, num) => String.fromCodePoint(Number.parseInt(num, 10)))
17
+ .replace(/&amp;/g, '&')
18
+ .replace(/&quot;/g, '"')
19
+ .replace(/&#39;/g, "'")
20
+ .replace(/&nbsp;/g, ' ')
21
+ .replace(/\s+/g, ' ')
22
+ .trim();
23
+ }
24
+
25
+ function absoluteUrl(href) {
26
+ try {
27
+ return new URL(href, BASE).toString();
28
+ } catch {
29
+ return '';
30
+ }
31
+ }
32
+
33
+ function validateLimit(raw) {
34
+ const limit = Number(raw ?? 20);
35
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
36
+ throw new ArgumentError('limit must be an integer from 1 to 100');
37
+ }
38
+ return limit;
39
+ }
40
+
41
+ function buildUrl(input) {
42
+ const value = String(input || 'home').trim();
43
+ const sectionPath = SECTIONS.get(value.toLowerCase());
44
+ if (sectionPath) return `${BASE}${sectionPath}`;
45
+
46
+ if (/^https?:\/\//i.test(value)) {
47
+ const parsed = new URL(value);
48
+ if (!/(^|\.)district\.in$/i.test(parsed.hostname)) {
49
+ throw new ArgumentError('input URL must be on district.in');
50
+ }
51
+ return parsed.toString();
52
+ }
53
+
54
+ if (value.startsWith('/')) return absoluteUrl(value);
55
+
56
+ throw new ArgumentError('input must be one of home, movies, events, a district.in URL, or a District path');
57
+ }
58
+
59
+ function inferCategory(url) {
60
+ if (url.includes('/movies/')) return 'movie';
61
+ if (url.includes('/events/')) return 'event';
62
+ if (url.includes('/dining/')) return 'dining';
63
+ return 'listing';
64
+ }
65
+
66
+ function isBookableListingUrl(url) {
67
+ return /\/movies\/[^/]+-movie-tickets-/i.test(url)
68
+ || /\/events\/[^/]+(?:buy-tickets|ipl-ticket-booking)/i.test(url)
69
+ || /\/dining\//i.test(url);
70
+ }
71
+
72
+ function parseListings(html, limit) {
73
+ const rows = [];
74
+ const anchorRe = /<a\b[^>]*href=["']([^"']+)["'][^>]*>(?=[\s\S]*?<div[^>]+class=["'][^"']*\bitem-cards\b)[\s\S]*?<\/a>/gi;
75
+
76
+ for (const match of html.matchAll(anchorRe)) {
77
+ const block = match[0];
78
+ const url = absoluteUrl(match[1]);
79
+ if (!url || !isBookableListingUrl(url)) continue;
80
+
81
+ const titleMatch = block.match(/<h5\b[^>]*>([\s\S]*?)<\/h5>/i);
82
+ const title = decodeHtml(titleMatch?.[1]);
83
+ if (!title) continue;
84
+
85
+ const spanValues = [...block.matchAll(/<span\b[^>]*>([\s\S]*?)<\/span>/gi)]
86
+ .map((span) => decodeHtml(span[1]))
87
+ .filter(Boolean);
88
+
89
+ const nonOfferSpans = spanValues.filter((text) => !/\boff\b|cashback|discount|coupon/i.test(text));
90
+ rows.push({
91
+ rank: rows.length + 1,
92
+ title,
93
+ category: inferCategory(url),
94
+ date: nonOfferSpans[0] || '',
95
+ venue: nonOfferSpans[1] || '',
96
+ price: nonOfferSpans[2] || '',
97
+ url,
98
+ });
99
+
100
+ if (rows.length >= limit) break;
101
+ }
102
+
103
+ return rows;
104
+ }
105
+
106
+ cli({
107
+ site: 'district',
108
+ name: 'listings',
109
+ aliases: ['ls'],
110
+ access: 'read',
111
+ description: 'List public District by Zomato movies, events, and nearby going-out cards',
112
+ domain: 'www.district.in',
113
+ strategy: Strategy.PUBLIC,
114
+ browser: false,
115
+ args: [
116
+ {
117
+ name: 'input',
118
+ positional: true,
119
+ required: false,
120
+ default: 'home',
121
+ help: 'home, movies, events, a district.in URL, or a District path',
122
+ },
123
+ {
124
+ name: 'limit',
125
+ type: 'int',
126
+ default: 20,
127
+ help: 'Maximum rows to return (1-100)',
128
+ },
129
+ ],
130
+ columns: ['rank', 'title', 'category', 'date', 'venue', 'price', 'url'],
131
+ func: async (args) => {
132
+ const limit = validateLimit(args.limit);
133
+ const url = buildUrl(args.input);
134
+
135
+ const resp = await fetch(url, {
136
+ headers: {
137
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
138
+ 'Accept-Language': 'en-IN,en;q=0.9',
139
+ 'User-Agent': 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari/537.36',
140
+ },
141
+ });
142
+
143
+ if (!resp.ok) {
144
+ throw new CommandExecutionError(`district listings request failed: HTTP ${resp.status}`);
145
+ }
146
+
147
+ const html = await resp.text();
148
+ if (!/<html[\s>]/i.test(html)) {
149
+ throw new CommandExecutionError('district listings expected HTML but received a different response');
150
+ }
151
+
152
+ const rows = parseListings(html, limit);
153
+ if (!rows.length) {
154
+ throw new EmptyResultError('district listings', 'No listing cards found on the District page');
155
+ }
156
+ return rows;
157
+ },
158
+ });
@@ -0,0 +1,211 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
3
+
4
+ const BASE = 'https://www.district.in';
5
+ const GUEST_TOKEN = '1212';
6
+
7
+ function validateQuery(raw) {
8
+ const query = String(raw || '').trim();
9
+ if (!query) throw new ArgumentError('query is required');
10
+ if (query.length > 120) throw new ArgumentError('query must be 120 characters or fewer');
11
+ return query;
12
+ }
13
+
14
+ function validateLimit(raw) {
15
+ const limit = Number(raw ?? 10);
16
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
17
+ throw new ArgumentError('limit must be an integer from 1 to 50');
18
+ }
19
+ return limit;
20
+ }
21
+
22
+ function headersFor() {
23
+ return {
24
+ Accept: 'application/json, text/plain, */*',
25
+ 'Accept-Language': 'en-IN,en;q=0.9',
26
+ 'Content-Type': 'application/json',
27
+ Origin: BASE,
28
+ Referer: `${BASE}/`,
29
+ 'User-Agent': 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari/537.36',
30
+ 'x-guest-token': GUEST_TOKEN,
31
+ 'x-app-type': 'ed_web',
32
+ 'x-client-id': 'district-web',
33
+ 'x-app-version': '11.11.1',
34
+ 'x-device-id': crypto.randomUUID(),
35
+ };
36
+ }
37
+
38
+ async function fetchLocationSearch(body) {
39
+ const resp = await fetch(`${BASE}/gw/web/get_location_search`, {
40
+ method: 'POST',
41
+ headers: headersFor(),
42
+ body: JSON.stringify(body),
43
+ });
44
+ if (!resp.ok) throw new CommandExecutionError(`district locations request failed: HTTP ${resp.status}`);
45
+
46
+ const data = await resp.json();
47
+ if (data?.status?.status === 'STATUS_FAILURE') {
48
+ throw new CommandExecutionError(data.status.message || 'district locations request failed');
49
+ }
50
+ return data;
51
+ }
52
+
53
+ function normalize(value) {
54
+ return String(value || '')
55
+ .trim()
56
+ .toLowerCase()
57
+ .replace(/[^a-z0-9]+/g, ' ')
58
+ .replace(/\s+/g, ' ')
59
+ .trim();
60
+ }
61
+
62
+ function distanceKm(aLat, aLng, bLat, bLng) {
63
+ const toRad = (deg) => (deg * Math.PI) / 180;
64
+ const r = 6371;
65
+ const dLat = toRad(bLat - aLat);
66
+ const dLng = toRad(bLng - aLng);
67
+ const lat1 = toRad(aLat);
68
+ const lat2 = toRad(bLat);
69
+ const a = Math.sin(dLat / 2) ** 2
70
+ + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
71
+ return 2 * r * Math.asin(Math.sqrt(a));
72
+ }
73
+
74
+ function kindFor(entity) {
75
+ const type = String(entity.entity_type || '');
76
+ if (type.includes('CITY')) return 'city';
77
+ if (type.includes('GOOGLE_PLACE')) return 'google_place';
78
+ if (type.includes('POI')) return String(entity.poi_type || 'poi').trim().toLowerCase() || 'poi';
79
+ return 'location';
80
+ }
81
+
82
+ function cityRecordFor(entity, cities) {
83
+ const entityName = normalize(entity.title || entity.fullname);
84
+ const entityState = normalize(String(entity.subtitle || '').split(',').at(-1));
85
+
86
+ const exact = cities.find((city) => {
87
+ const names = [
88
+ city.city_name,
89
+ city.cleaned_city_name,
90
+ city.city_key,
91
+ ].map(normalize);
92
+ return names.includes(entityName)
93
+ || (normalize(city.city_name) === entityName && (!entityState || normalize(city.state_name) === entityState));
94
+ });
95
+ if (exact) return exact;
96
+
97
+ const lat = Number(entity.lat);
98
+ const lng = Number(entity.long);
99
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
100
+
101
+ let nearest = null;
102
+ for (const city of cities) {
103
+ const cityLat = Number(city.city_lat);
104
+ const cityLng = Number(city.city_long);
105
+ if (!Number.isFinite(cityLat) || !Number.isFinite(cityLng)) continue;
106
+ const km = distanceKm(lat, lng, cityLat, cityLng);
107
+ if (!nearest || km < nearest.km) nearest = { city, km };
108
+ }
109
+ return nearest && nearest.km <= 80 ? nearest.city : null;
110
+ }
111
+
112
+ function scoreFor(entity, query, index) {
113
+ const q = normalize(query);
114
+ const title = normalize(entity.title);
115
+ const full = normalize(`${entity.title} ${entity.subtitle} ${entity.fullname}`);
116
+ let score = 1000 - index;
117
+ if (title === q) score += 5000;
118
+ else if (title.startsWith(q)) score += 3000;
119
+ else if (full.includes(q)) score += 1000;
120
+ if (kindFor(entity) === 'city') score += 600;
121
+ return score;
122
+ }
123
+
124
+ function rowFor(entity, index, query, cities, primaryCity) {
125
+ const city = cityRecordFor(entity, cities) || {};
126
+ const kind = kindFor(entity);
127
+ const placeId = String(entity.google_place_id || entity.poi_id || '');
128
+ const dist = Number(entity.distance);
129
+ const samePrimaryCity = primaryCity?.city_id && city.city_id === primaryCity.city_id;
130
+ return {
131
+ score: scoreFor(entity, query, index) + (samePrimaryCity ? 450 : 0),
132
+ row: {
133
+ rank: index + 1,
134
+ name: String(entity.title || entity.fullname || '').trim(),
135
+ kind,
136
+ city: String(city.city_name || (kind === 'city' ? entity.title : '') || '').trim(),
137
+ state: String(city.state_name || '').trim(),
138
+ cityKey: String(city.city_key || '').trim(),
139
+ cityId: String(city.city_id || '').trim(),
140
+ placeId,
141
+ lat: Number(entity.lat || 0),
142
+ lng: Number(entity.long || 0),
143
+ distanceKm: Number.isFinite(dist) ? Number((dist / 1000).toFixed(1)) : 0,
144
+ source: 'district_location_search',
145
+ },
146
+ };
147
+ }
148
+
149
+ cli({
150
+ site: 'district',
151
+ name: 'locations',
152
+ aliases: ['location-search'],
153
+ access: 'read',
154
+ description: 'Search District-supported cities, areas, malls, and places for booking filters',
155
+ domain: 'www.district.in',
156
+ strategy: Strategy.PUBLIC,
157
+ browser: false,
158
+ args: [
159
+ {
160
+ name: 'query',
161
+ positional: true,
162
+ required: true,
163
+ help: 'City, area, mall, or locality, for example "bangalore" or "indiranagar"',
164
+ },
165
+ {
166
+ name: 'limit',
167
+ type: 'int',
168
+ default: 10,
169
+ help: 'Maximum location rows to return (1-50)',
170
+ },
171
+ ],
172
+ columns: [
173
+ 'rank',
174
+ 'name',
175
+ 'kind',
176
+ 'city',
177
+ 'state',
178
+ 'cityKey',
179
+ 'cityId',
180
+ 'placeId',
181
+ 'lat',
182
+ 'lng',
183
+ 'distanceKm',
184
+ 'source',
185
+ ],
186
+ func: async (args) => {
187
+ const query = validateQuery(args.query);
188
+ const limit = validateLimit(args.limit);
189
+ const [searchData, allData] = await Promise.all([
190
+ fetchLocationSearch({ searched_text: query }),
191
+ fetchLocationSearch({}),
192
+ ]);
193
+
194
+ const cities = Array.isArray(allData?.cities) ? allData.cities : [];
195
+ const entities = Array.isArray(searchData?.entities) ? searchData.entities : [];
196
+ if (!entities.length) throw new EmptyResultError('district locations', `No locations found for "${query}"`);
197
+
198
+ const queryNorm = normalize(query);
199
+ const primaryCity = entities
200
+ .filter((entity) => kindFor(entity) === 'city')
201
+ .map((entity) => cityRecordFor(entity, cities))
202
+ .find((city) => city && [city.city_name, city.cleaned_city_name, city.city_key].map(normalize).includes(queryNorm));
203
+
204
+ return entities
205
+ .map((entity, index) => rowFor(entity, index, query, cities, primaryCity))
206
+ .filter((item) => item.row.name && Number.isFinite(item.row.lat) && Number.isFinite(item.row.lng))
207
+ .sort((a, b) => b.score - a.score)
208
+ .slice(0, limit)
209
+ .map((item, index) => ({ ...item.row, rank: index + 1 }));
210
+ },
211
+ });