@agentrhq/webcmd 0.2.1 → 0.2.3

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 (100) hide show
  1. package/README.md +39 -24
  2. package/cli-manifest.json +2308 -650
  3. package/clis/_shared/site-auth.js +6 -1
  4. package/clis/bigbasket/add-to-cart.js +82 -0
  5. package/clis/bigbasket/bigbasket.test.js +255 -0
  6. package/clis/bigbasket/cart.js +81 -0
  7. package/clis/bigbasket/category.js +30 -0
  8. package/clis/bigbasket/checkout.js +71 -0
  9. package/clis/bigbasket/location.js +30 -0
  10. package/clis/bigbasket/product.js +79 -0
  11. package/clis/bigbasket/search.js +30 -0
  12. package/clis/bigbasket/utils.js +207 -0
  13. package/clis/blinkit/add-to-cart.js +123 -0
  14. package/clis/blinkit/auth.js +99 -0
  15. package/clis/blinkit/blinkit.test.js +168 -0
  16. package/clis/blinkit/cart.js +34 -0
  17. package/clis/blinkit/checkout.js +32 -0
  18. package/clis/blinkit/location.js +29 -0
  19. package/clis/blinkit/place-order.js +78 -0
  20. package/clis/blinkit/product.js +63 -0
  21. package/clis/blinkit/search.js +89 -0
  22. package/clis/blinkit/utils.js +223 -0
  23. package/clis/district/_lib.js +566 -0
  24. package/clis/district/auth.js +49 -0
  25. package/clis/district/checkout.js +348 -0
  26. package/clis/district/listings.js +158 -0
  27. package/clis/district/locations.js +211 -0
  28. package/clis/district/search.js +218 -0
  29. package/clis/district/seats.js +233 -0
  30. package/clis/district/set-location.js +82 -0
  31. package/clis/district/showtimes.js +433 -0
  32. package/clis/practo/appointment.js +21 -0
  33. package/clis/practo/appointments.js +27 -0
  34. package/clis/practo/book-confirm.js +35 -0
  35. package/clis/practo/book-preview.js +24 -0
  36. package/clis/practo/booking-link.js +24 -0
  37. package/clis/practo/cancel.js +29 -0
  38. package/clis/practo/contact.js +21 -0
  39. package/clis/practo/login.js +21 -0
  40. package/clis/practo/practo.test.js +136 -0
  41. package/clis/practo/profile.js +31 -0
  42. package/clis/practo/search.js +30 -0
  43. package/clis/practo/slots.js +19 -0
  44. package/clis/practo/utils.js +374 -0
  45. package/clis/practo/whoami.js +28 -0
  46. package/clis/reddit/popular.js +12 -1
  47. package/clis/reddit/popular.test.js +12 -3
  48. package/clis/zepto/add-to-cart.js +53 -0
  49. package/clis/zepto/auth.js +59 -0
  50. package/clis/zepto/cart.js +23 -0
  51. package/clis/zepto/checkout.js +60 -0
  52. package/clis/zepto/location.js +20 -0
  53. package/clis/zepto/place-order.js +47 -0
  54. package/clis/zepto/product.js +52 -0
  55. package/clis/zepto/search.js +30 -0
  56. package/clis/zepto/utils.js +228 -0
  57. package/clis/zepto/zepto.test.js +238 -0
  58. package/dist/src/browser/bridge.d.ts +1 -0
  59. package/dist/src/browser/bridge.js +1 -1
  60. package/dist/src/browser/page.d.ts +4 -1
  61. package/dist/src/browser/page.js +12 -1
  62. package/dist/src/browser/protocol.d.ts +2 -0
  63. package/dist/src/browser/runtime/local-cloak/actions.js +1 -0
  64. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +2 -0
  65. package/dist/src/browser/runtime/local-cloak/session-manager.js +12 -2
  66. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +58 -0
  67. package/dist/src/build-manifest.js +1 -0
  68. package/dist/src/build-manifest.test.js +34 -0
  69. package/dist/src/cli.js +111 -28
  70. package/dist/src/discovery.js +1 -0
  71. package/dist/src/engine.test.js +62 -0
  72. package/dist/src/execution.js +1 -1
  73. package/dist/src/generate-release-notes-cli.test.js +55 -1
  74. package/dist/src/manifest-types.d.ts +2 -0
  75. package/dist/src/registry.d.ts +10 -0
  76. package/dist/src/registry.js +5 -3
  77. package/dist/src/registry.test.js +25 -0
  78. package/dist/src/release-notes.d.ts +3 -1
  79. package/dist/src/release-notes.js +44 -1
  80. package/dist/src/release-notes.test.js +39 -1
  81. package/dist/src/runtime.d.ts +2 -0
  82. package/dist/src/runtime.js +1 -0
  83. package/dist/src/skills.d.ts +23 -5
  84. package/dist/src/skills.js +87 -45
  85. package/dist/src/skills.test.js +80 -23
  86. package/package.json +2 -2
  87. package/scripts/generate-release-notes.ts +31 -0
  88. package/skills/smart-search/SKILL.md +156 -0
  89. package/skills/smart-search/references/sources-ai.md +74 -0
  90. package/skills/smart-search/references/sources-info.md +43 -0
  91. package/skills/smart-search/references/sources-media.md +40 -0
  92. package/skills/smart-search/references/sources-other.md +32 -0
  93. package/skills/smart-search/references/sources-shopping.md +21 -0
  94. package/skills/smart-search/references/sources-social.md +36 -0
  95. package/skills/smart-search/references/sources-tech.md +38 -0
  96. package/skills/smart-search/references/sources-travel.md +26 -0
  97. package/skills/webcmd-adapter-author/SKILL.md +10 -0
  98. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
  99. package/skills/webcmd-autofix/SKILL.md +8 -0
  100. package/skills/webcmd-sitemap-author/SKILL.md +1 -1
@@ -0,0 +1,82 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
3
+ import {
4
+ applyLocationViaPicker,
5
+ validateTimeout,
6
+ } from './_lib.js';
7
+
8
+ const DEFAULT_TIMEOUT_SECONDS = 45;
9
+
10
+ function validateLocation(raw) {
11
+ const location = String(raw || '').trim();
12
+ if (!location) throw new ArgumentError('location is required');
13
+ if (location.length > 120) throw new ArgumentError('location must be 120 characters or fewer');
14
+ return location;
15
+ }
16
+
17
+ function validateRank(raw) {
18
+ const rank = Number(raw ?? 1);
19
+ if (!Number.isInteger(rank) || rank < 1 || rank > 20) {
20
+ throw new ArgumentError('rank must be an integer from 1 to 20');
21
+ }
22
+ return rank;
23
+ }
24
+
25
+ cli({
26
+ site: 'district',
27
+ name: 'set-location',
28
+ aliases: ['setlocation'],
29
+ access: 'write',
30
+ description: 'Set the District browser session location for movie booking filters',
31
+ domain: 'www.district.in',
32
+ strategy: Strategy.COOKIE,
33
+ browser: true,
34
+ navigateBefore: false,
35
+ defaultWindowMode: 'foreground',
36
+ siteSession: 'persistent',
37
+ args: [
38
+ {
39
+ name: 'location',
40
+ positional: true,
41
+ required: true,
42
+ help: 'City, area, mall, or locality, for example "Bangalore" or "Indiranagar"',
43
+ },
44
+ {
45
+ name: 'rank',
46
+ type: 'int',
47
+ default: 1,
48
+ help: 'Pick the Nth District location result (1-20), default: 1',
49
+ },
50
+ {
51
+ name: 'timeout',
52
+ type: 'int',
53
+ default: DEFAULT_TIMEOUT_SECONDS,
54
+ help: 'Maximum seconds to wait for the picker and location change',
55
+ },
56
+ ],
57
+ columns: [
58
+ 'status',
59
+ 'name',
60
+ 'city',
61
+ 'state',
62
+ 'cityKey',
63
+ 'cityId',
64
+ 'placeId',
65
+ 'subzoneId',
66
+ 'lat',
67
+ 'lng',
68
+ 'availableTabs',
69
+ 'source',
70
+ ],
71
+ func: async (page, args) => {
72
+ const location = validateLocation(args.location);
73
+ const rank = validateRank(args.rank);
74
+ const timeout = validateTimeout(args.timeout, { def: DEFAULT_TIMEOUT_SECONDS, min: 10, max: 180 });
75
+
76
+ const row = await applyLocationViaPicker(page, location, timeout, rank);
77
+ if (!row.cityKey || !row.cityId) {
78
+ throw new CommandExecutionError('District location was selected, but normalized city metadata could not be read');
79
+ }
80
+ return row;
81
+ },
82
+ });
@@ -0,0 +1,433 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
3
+ import {
4
+ BASE,
5
+ DEFAULT_LOCATION,
6
+ browserLocation,
7
+ districtHeaders,
8
+ fetchMovieSessions,
9
+ } from './_lib.js';
10
+
11
+ function validateText(raw, name, max = 160) {
12
+ const value = String(raw || '').trim();
13
+ if (!value) throw new ArgumentError(`${name} is required`);
14
+ if (value.length > max) throw new ArgumentError(`${name} must be ${max} characters or fewer`);
15
+ return value;
16
+ }
17
+
18
+ function validateOptionalText(raw, name, max = 120) {
19
+ if (raw == null || raw === '') return '';
20
+ return validateText(raw, name, max);
21
+ }
22
+
23
+ function validateLimit(raw) {
24
+ const limit = Number(raw ?? 50);
25
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
26
+ throw new ArgumentError('limit must be an integer from 1 to 200');
27
+ }
28
+ return limit;
29
+ }
30
+
31
+ function validateMoney(raw, name) {
32
+ if (raw == null || raw === '') return 0;
33
+ const value = Number(raw);
34
+ if (!Number.isFinite(value) || value < 0) throw new ArgumentError(`${name} must be a positive number`);
35
+ return value;
36
+ }
37
+
38
+ function validateCityKey(raw) {
39
+ if (raw == null || raw === '') return '';
40
+ const cityKey = String(raw).trim().toLowerCase();
41
+ if (!/^[a-z0-9-]+$/.test(cityKey)) {
42
+ throw new ArgumentError('city-key must contain only lowercase letters, numbers, and hyphens');
43
+ }
44
+ return cityKey;
45
+ }
46
+
47
+ function validateDate(raw) {
48
+ if (raw == null || raw === '') return '';
49
+ const date = String(raw).trim();
50
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new ArgumentError('date must be in YYYY-MM-DD format');
51
+ return date;
52
+ }
53
+
54
+ function validateClock(raw, name) {
55
+ if (raw == null || raw === '') return null;
56
+ const value = String(raw).trim();
57
+ const match = value.match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
58
+ if (!match) throw new ArgumentError(`${name} must be in HH:MM 24-hour format`);
59
+ return Number(match[1]) * 60 + Number(match[2]);
60
+ }
61
+
62
+ function normalize(value) {
63
+ return String(value || '')
64
+ .trim()
65
+ .toLowerCase()
66
+ .replace(/[^a-z0-9]+/g, ' ')
67
+ .replace(/\s+/g, ' ')
68
+ .trim();
69
+ }
70
+
71
+ function stateKey(value) {
72
+ return normalize(value).replace(/\s+/g, '-');
73
+ }
74
+
75
+ function slugify(value) {
76
+ return String(value || '')
77
+ .trim()
78
+ .replace(/[\W_]+/g, '-')
79
+ .replace(/^-+|-+$/g, '')
80
+ .toLowerCase();
81
+ }
82
+
83
+ function extractContentId(value) {
84
+ const match = String(value || '').match(/(?:MV)?(\d{4,})/i);
85
+ return match ? match[1] : '';
86
+ }
87
+
88
+ function isDistrictMovieUrl(value) {
89
+ try {
90
+ const url = new URL(value, BASE);
91
+ return url.hostname.endsWith('district.in') && /\/movies\/.+MV\d+/i.test(url.pathname);
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ function makeBookingUrl(movieUrl, cityKey) {
98
+ const url = new URL(movieUrl, BASE);
99
+ const match = url.pathname.match(/^\/movies\/(.+?)-movie-tickets(?:-in-[a-z0-9-]+)?-MV(\d+)/i);
100
+ if (!match) throw new ArgumentError('movie must be a District movie URL or a movie search query');
101
+ url.pathname = `/movies/${match[1]}-movie-tickets-in-${cityKey}-MV${match[2]}`;
102
+ return url;
103
+ }
104
+
105
+ async function searchMovie(query, loc) {
106
+ const body = {
107
+ get_search_results_request_type: 1,
108
+ post_body: { hp_selected_tab_id: 'home_v2' },
109
+ search_id: crypto.randomUUID(),
110
+ keyword: query,
111
+ tab_id: 'movies',
112
+ };
113
+
114
+ const resp = await fetch(`${BASE}/gw/web/search`, {
115
+ method: 'POST',
116
+ headers: {
117
+ ...districtHeaders({ loc, referer: `${BASE}/search` }),
118
+ 'Content-Type': 'application/json',
119
+ Origin: BASE,
120
+ 'x-available-tabs': 'movies',
121
+ },
122
+ body: JSON.stringify(body),
123
+ });
124
+
125
+ if (!resp.ok) throw new CommandExecutionError(`district movie search failed: HTTP ${resp.status}`);
126
+ const data = await resp.json();
127
+ const movie = (Array.isArray(data?.results) ? data.results : []).find((item) => {
128
+ return String(item.entity_type || '') === 'EntityTypeMovie' && item.id;
129
+ });
130
+ if (!movie) throw new EmptyResultError('district showtimes', `No movie result found for "${query}"`);
131
+ return `${BASE}/movies/${slugify(movie.display_title)}-movie-tickets-MV${movie.id}`;
132
+ }
133
+
134
+ async function fetchLocationSearch(body) {
135
+ const resp = await fetch(`${BASE}/gw/web/get_location_search`, {
136
+ method: 'POST',
137
+ headers: {
138
+ ...districtHeaders({ loc: DEFAULT_LOCATION, referer: BASE }),
139
+ 'Content-Type': 'application/json',
140
+ Origin: BASE,
141
+ },
142
+ body: JSON.stringify(body),
143
+ });
144
+ if (!resp.ok) throw new CommandExecutionError(`district location search failed: HTTP ${resp.status}`);
145
+ return resp.json();
146
+ }
147
+
148
+ function distanceKm(aLat, aLng, bLat, bLng) {
149
+ const toRad = (deg) => (deg * Math.PI) / 180;
150
+ const r = 6371;
151
+ const dLat = toRad(bLat - aLat);
152
+ const dLng = toRad(bLng - aLng);
153
+ const lat1 = toRad(aLat);
154
+ const lat2 = toRad(bLat);
155
+ const a = Math.sin(dLat / 2) ** 2
156
+ + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
157
+ return 2 * r * Math.asin(Math.sqrt(a));
158
+ }
159
+
160
+ function nearestCity(entity, cities) {
161
+ const lat = Number(entity.lat);
162
+ const lng = Number(entity.long);
163
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
164
+ let nearest = null;
165
+ for (const city of cities) {
166
+ const cityLat = Number(city.city_lat);
167
+ const cityLng = Number(city.city_long);
168
+ if (!Number.isFinite(cityLat) || !Number.isFinite(cityLng)) continue;
169
+ const km = distanceKm(lat, lng, cityLat, cityLng);
170
+ if (!nearest || km < nearest.km) nearest = { city, km };
171
+ }
172
+ return nearest && nearest.km <= 80 ? nearest.city : null;
173
+ }
174
+
175
+ function cityToLocation(city, source = 'city') {
176
+ return {
177
+ cityId: city.city_id,
178
+ pCityId: city.city_id,
179
+ long: Number(city.city_long),
180
+ lat: Number(city.city_lat),
181
+ pCityKey: String(city.city_key || '').toLowerCase(),
182
+ pStateKey: stateKey(city.state_name),
183
+ countryId: '1',
184
+ placeType: 'GOOGLE_PLACE',
185
+ placeId: '',
186
+ subzoneId: '',
187
+ cityName: String(city.city_name || ''),
188
+ pCityName: String(city.city_name || ''),
189
+ source,
190
+ };
191
+ }
192
+
193
+ function entityToLocation(entity, city, source = 'near') {
194
+ const base = city ? cityToLocation(city, source) : { ...DEFAULT_LOCATION, source };
195
+ return {
196
+ ...base,
197
+ long: Number(entity.long || base.long),
198
+ lat: Number(entity.lat || base.lat),
199
+ placeType: entity.google_place_id ? 'GOOGLE_PLACE' : 'POI',
200
+ placeId: String(entity.google_place_id || entity.poi_id || ''),
201
+ cityName: String(city?.city_name || base.cityName || ''),
202
+ pCityName: String(city?.city_name || base.pCityName || ''),
203
+ source,
204
+ };
205
+ }
206
+
207
+ async function resolveLocationQuery(query, preferCity) {
208
+ const [searchData, allData] = await Promise.all([
209
+ fetchLocationSearch({ searched_text: query }),
210
+ fetchLocationSearch({}),
211
+ ]);
212
+ const cities = Array.isArray(allData?.cities) ? allData.cities : [];
213
+ const q = normalize(query);
214
+ if (preferCity) {
215
+ const exactCity = cities.find((city) => {
216
+ return [city.city_name, city.cleaned_city_name, city.city_key].map(normalize).includes(q);
217
+ });
218
+ if (exactCity) return cityToLocation(exactCity, 'city');
219
+ }
220
+
221
+ const entities = Array.isArray(searchData?.entities) ? searchData.entities : [];
222
+ const entity = preferCity
223
+ ? entities.find((item) => String(item.entity_type || '').includes('CITY')) || entities[0]
224
+ : entities.find((item) => normalize(item.title) === q) || entities[0];
225
+ if (!entity) throw new EmptyResultError('district showtimes', `No location found for "${query}"`);
226
+ const city = String(entity.entity_type || '').includes('CITY')
227
+ ? cities.find((item) => normalize(item.city_name) === normalize(entity.title) || normalize(item.city_key) === q)
228
+ : nearestCity(entity, cities);
229
+ return String(entity.entity_type || '').includes('CITY')
230
+ ? cityToLocation(city || { city_id: '', city_key: '', city_lat: entity.lat, city_long: entity.long, city_name: entity.title, state_name: '' }, 'city')
231
+ : entityToLocation(entity, city, 'near');
232
+ }
233
+
234
+ async function resolveCityKey(cityKey) {
235
+ const allData = await fetchLocationSearch({});
236
+ const city = (Array.isArray(allData?.cities) ? allData.cities : []).find((item) => {
237
+ return normalize(item.city_key) === normalize(cityKey);
238
+ });
239
+ return city ? cityToLocation(city, 'city-key') : { ...DEFAULT_LOCATION, pCityKey: cityKey, source: 'city-key' };
240
+ }
241
+
242
+ function extractNextData(html) {
243
+ const match = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
244
+ if (!match) throw new CommandExecutionError('District booking page did not include session data');
245
+ try {
246
+ return JSON.parse(match[1]);
247
+ } catch (err) {
248
+ throw new CommandExecutionError(`District session data could not be parsed: ${err.message}`);
249
+ }
250
+ }
251
+
252
+ function pricesFor(areas) {
253
+ return (Array.isArray(areas) ? areas : [])
254
+ .map((area) => Number(area.price))
255
+ .filter((price) => Number.isFinite(price));
256
+ }
257
+
258
+ function moneyRange(areas) {
259
+ const prices = pricesFor(areas);
260
+ if (!prices.length) return '';
261
+ const min = Math.min(...prices);
262
+ const max = Math.max(...prices);
263
+ return min === max ? `INR ${min}` : `INR ${min}-${max}`;
264
+ }
265
+
266
+ function formatDateTime(value, options) {
267
+ if (!value) return '';
268
+ const date = new Date(`${value}Z`);
269
+ if (Number.isNaN(date.getTime())) return '';
270
+ return new Intl.DateTimeFormat('en-IN', { timeZone: 'Asia/Kolkata', ...options }).format(date);
271
+ }
272
+
273
+ function sessionMinute(value) {
274
+ const time = formatDateTime(value, { hour: '2-digit', minute: '2-digit', hour12: false });
275
+ const match = time.match(/^(\d{2}):(\d{2})$/);
276
+ return match ? Number(match[1]) * 60 + Number(match[2]) : null;
277
+ }
278
+
279
+ function languageFor(movie) {
280
+ const languages = Array.isArray(movie.languages) ? movie.languages : [];
281
+ return String(movie.primary_language || languages[0] || '').trim();
282
+ }
283
+
284
+ function matchesText(value, filter) {
285
+ return !filter || normalize(value).includes(normalize(filter));
286
+ }
287
+
288
+ function rowsFromPayload(payload, loc, formatId, contentId, filters, limit) {
289
+ const movie = payload?.meta?.movie || {};
290
+ const selectedDate = payload?.meta?.selectedShowDate || '';
291
+ const language = languageFor(movie);
292
+ const cinemas = [
293
+ ...(Array.isArray(payload?.pageData?.nearbyCinemas) ? payload.pageData.nearbyCinemas : []),
294
+ ...(Array.isArray(payload?.pageData?.farCinemas) ? payload.pageData.farCinemas : []),
295
+ ];
296
+
297
+ const rows = [];
298
+ for (const cinema of cinemas) {
299
+ const info = cinema.cinemaInfo || {};
300
+ const cinemaName = String(info.name || info.label || '');
301
+ if (!matchesText(cinemaName, filters.cinema)) continue;
302
+
303
+ for (const session of Array.isArray(cinema.sessions) ? cinema.sessions : []) {
304
+ const minute = sessionMinute(session.showTime);
305
+ const format = String(session.scrnFmt || '');
306
+ const prices = pricesFor(session.areas);
307
+ if (filters.after != null && minute != null && minute < filters.after) continue;
308
+ if (filters.before != null && minute != null && minute > filters.before) continue;
309
+ if (filters.maxPrice && prices.length && Math.min(...prices) > filters.maxPrice) continue;
310
+ if (!matchesText(language, filters.language)) continue;
311
+ if (!matchesText(format, filters.quality)) continue;
312
+
313
+ const encSessionId = String(session.encSessionId || `${session.cid}-${session.sid}-${String(session.mid || '').toLowerCase()}-${session.cid}`);
314
+ rows.push({
315
+ rank: rows.length + 1,
316
+ movie: String(movie.name || ''),
317
+ language,
318
+ date: selectedDate || formatDateTime(session.showTime, { year: 'numeric', month: '2-digit', day: '2-digit' }),
319
+ time: formatDateTime(session.showTime, { hour: '2-digit', minute: '2-digit', hour12: true }),
320
+ cinema: cinemaName,
321
+ format,
322
+ priceRange: moneyRange(session.areas),
323
+ available: Number(session.avail || 0),
324
+ showId: encSessionId,
325
+ formatId: String(formatId || session.mcd || ''),
326
+ url: `${BASE}/movies/seat-layout/${formatId || session.mcd}?encsessionid=${encodeURIComponent(encSessionId)}&freeseating=false&fromsessions=true&type=MOVIES&contentid=${contentId}&fromdate=${selectedDate}&citykey=${encodeURIComponent(loc.pCityKey || '')}`,
327
+ });
328
+ if (rows.length >= limit) return rows;
329
+ }
330
+ }
331
+ return rows;
332
+ }
333
+
334
+ async function resolveLocation(page, args) {
335
+ const city = validateOptionalText(args.city, 'city');
336
+ const near = validateOptionalText(args.near, 'near');
337
+ const cityKey = validateCityKey(args['city-key']);
338
+ if (near) return resolveLocationQuery(near, false);
339
+ if (city) return resolveLocationQuery(city, true);
340
+ if (cityKey) return resolveCityKey(cityKey);
341
+ return browserLocation(page);
342
+ }
343
+
344
+ cli({
345
+ site: 'district',
346
+ name: 'showtimes',
347
+ aliases: ['shows'],
348
+ access: 'read',
349
+ description: 'List District movie showtimes with location, time, cinema, language, price, and format filters',
350
+ domain: 'www.district.in',
351
+ strategy: Strategy.COOKIE,
352
+ browser: true,
353
+ navigateBefore: false,
354
+ defaultWindowMode: 'foreground',
355
+ siteSession: 'persistent',
356
+ args: [
357
+ { name: 'movie', positional: true, required: true, help: 'Movie name or District movie URL' },
358
+ { name: 'date', help: 'Show date in YYYY-MM-DD format; defaults to District selected date' },
359
+ { name: 'city', help: 'District city name/key, for example Bangalore or Bengaluru' },
360
+ { name: 'near', help: 'Area, mall, or locality to search near, for example Indiranagar' },
361
+ { name: 'city-key', help: 'Legacy District city key override, for example bengaluru' },
362
+ { name: 'after', help: 'Only shows at or after HH:MM, 24-hour time' },
363
+ { name: 'before', help: 'Only shows at or before HH:MM, 24-hour time' },
364
+ { name: 'cinema', help: 'Filter cinema/theatre name, for example PVR, INOX, Orion' },
365
+ { name: 'language', help: 'Filter movie language, for example English, Hindi, Kannada' },
366
+ { name: 'max-price', type: 'float', help: 'Only shows with at least one ticket class at or below this price' },
367
+ { name: 'quality', help: 'Generic format/quality filter, for example 2D, 3D, IMAX, IMAX 3D, 4DX' },
368
+ { name: 'limit', type: 'int', default: 50, help: 'Maximum showtime rows to return (1-200)' },
369
+ ],
370
+ columns: [
371
+ 'rank',
372
+ 'movie',
373
+ 'language',
374
+ 'date',
375
+ 'time',
376
+ 'cinema',
377
+ 'format',
378
+ 'priceRange',
379
+ 'available',
380
+ 'showId',
381
+ 'formatId',
382
+ 'url',
383
+ ],
384
+ func: async (page, args) => {
385
+ const movieInput = validateText(args.movie, 'movie');
386
+ const date = validateDate(args.date);
387
+ const limit = validateLimit(args.limit);
388
+ const filters = {
389
+ after: validateClock(args.after, 'after'),
390
+ before: validateClock(args.before, 'before'),
391
+ cinema: validateOptionalText(args.cinema, 'cinema'),
392
+ language: validateOptionalText(args.language, 'language'),
393
+ maxPrice: validateMoney(args['max-price'], 'max-price'),
394
+ quality: validateOptionalText(args.quality, 'quality'),
395
+ };
396
+ const loc = await resolveLocation(page, args);
397
+ if (!loc.pCityKey) throw new CommandExecutionError('Could not resolve District city key for showtimes');
398
+
399
+ const movieUrl = isDistrictMovieUrl(movieInput) ? movieInput : await searchMovie(movieInput, loc);
400
+ const contentId = extractContentId(movieUrl);
401
+ const bookingUrl = makeBookingUrl(movieUrl, loc.pCityKey);
402
+ if (date) bookingUrl.searchParams.set('fromdate', date);
403
+
404
+ const resp = await fetch(bookingUrl, {
405
+ headers: districtHeaders({
406
+ loc,
407
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
408
+ referer: movieUrl,
409
+ }),
410
+ });
411
+ if (!resp.ok) throw new CommandExecutionError(`district booking page failed: HTTP ${resp.status}`);
412
+
413
+ const nextData = extractNextData(await resp.text());
414
+ const state = nextData.props?.pageProps?.initialState?.movies || {};
415
+ const formatId = state.queryState?.frmtid || Object.keys(state.movieSessions || {})[0] || '';
416
+ const payload = await fetchMovieSessions({
417
+ loc,
418
+ contentId,
419
+ formatId,
420
+ date,
421
+ referer: bookingUrl.toString(),
422
+ });
423
+
424
+ const showDates = Array.isArray(payload?.meta?.showDates) ? payload.meta.showDates : [];
425
+ if (date && showDates.length && !showDates.includes(date)) {
426
+ throw new EmptyResultError('district showtimes', `No showtimes found for ${date}; available dates: ${showDates.join(', ')}`);
427
+ }
428
+
429
+ const rows = rowsFromPayload(payload, loc, formatId, contentId, filters, limit);
430
+ if (!rows.length) throw new EmptyResultError('district showtimes', `No showtimes matched the requested filters for "${movieInput}"`);
431
+ return rows;
432
+ },
433
+ });
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { driveJson, normalizeAppointmentId } from './utils.js';
3
+
4
+ cli({
5
+ site: 'practo',
6
+ name: 'appointment',
7
+ access: 'read',
8
+ description: 'Show logged-in Practo Drive appointment details',
9
+ domain: 'drive.practo.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ siteSession: 'persistent',
14
+ args: [{ name: 'appointment_id', positional: true, required: true, help: 'Appointment id from `practo appointments`' }],
15
+ columns: ['appointment_id', 'status', 'summary'],
16
+ func: async (page, kwargs) => {
17
+ const id = normalizeAppointmentId(kwargs.appointment_id);
18
+ const data = await driveJson(page, `/api/record/v2/appointment/${encodeURIComponent(id)}`);
19
+ return [{ appointment_id: id, status: data?.status || data?.data?.status || '', summary: JSON.stringify(data?.data ?? data).slice(0, 1000) }];
20
+ },
21
+ });
@@ -0,0 +1,27 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { driveJson } from './utils.js';
3
+
4
+ cli({
5
+ site: 'practo',
6
+ name: 'appointments',
7
+ access: 'read',
8
+ description: 'List logged-in Practo Drive appointments',
9
+ domain: 'drive.practo.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ siteSession: 'persistent',
14
+ args: [],
15
+ columns: ['appointment_id', 'doctor', 'practice', 'time', 'status'],
16
+ func: async (page) => {
17
+ const data = await driveJson(page, '/api/record/v2/appointments');
18
+ const list = Array.isArray(data?.data) ? data.data : [];
19
+ return list.map((item) => ({
20
+ appointment_id: String(item.id || item.appointment_id || item.object_identifier || ''),
21
+ doctor: item.doctor_name || item.provider_name || item.doctor?.name || '',
22
+ practice: item.practice_name || item.establishment_name || item.practice?.name || '',
23
+ time: item.appointment_time || item.scheduled_at || item.from_time || '',
24
+ status: item.status || item.state || '',
25
+ }));
26
+ },
27
+ });
@@ -0,0 +1,35 @@
1
+ import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
2
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
+ import { bookingPreviewFromPageData, normalizeConfirm, prepareBookingPage, submitBookingPage } from './utils.js';
4
+
5
+ cli({
6
+ site: 'practo',
7
+ name: 'book-confirm',
8
+ access: 'write',
9
+ description: 'Confirm a Practo clinic visit booking after explicit confirmation',
10
+ domain: 'www.practo.com',
11
+ strategy: Strategy.COOKIE,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ defaultWindowMode: 'foreground',
15
+ siteSession: 'persistent',
16
+ args: [
17
+ { name: 'practice_doctor_id', positional: true, required: true, help: 'Practo practice_doctor_id' },
18
+ { name: 'time', required: true, help: 'Slot time YYYY-MM-DD HH:mm:ss' },
19
+ { name: 'profile-url', required: false, help: 'Doctor profile_url from `practo search`, used to build a canonical booking URL' },
20
+ { name: 'confirm', type: 'boolean', default: false, help: 'Required. Set --confirm true to create the appointment.' },
21
+ ],
22
+ columns: ['status', 'practice_doctor_id', 'time', 'url'],
23
+ func: async (page, kwargs) => {
24
+ if (!normalizeConfirm(kwargs.confirm)) {
25
+ throw new ArgumentError('Refusing to book appointment without --confirm true', 'Example: webcmd practo book-confirm 859054 --time "2026-07-10 10:30:00" --confirm true');
26
+ }
27
+ const prepared = await prepareBookingPage(page, kwargs);
28
+ const preview = bookingPreviewFromPageData(prepared.data, prepared.context)[0];
29
+ if (preview.requires_payment) {
30
+ throw new CommandExecutionError('This Practo slot appears to require online payment; stopping before payment.');
31
+ }
32
+ const result = await submitBookingPage(page);
33
+ return [{ status: 'submitted', practice_doctor_id: prepared.context.practiceDoctorId, time: prepared.context.slotTime, url: result.url || prepared.context.url }];
34
+ },
35
+ });
@@ -0,0 +1,24 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { bookingPreviewFromPageData, prepareBookingPage } from './utils.js';
3
+
4
+ cli({
5
+ site: 'practo',
6
+ name: 'book-preview',
7
+ access: 'read',
8
+ description: 'Preview Practo booking details for a selected slot without confirming',
9
+ domain: 'www.practo.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ siteSession: 'persistent',
14
+ args: [
15
+ { name: 'practice_doctor_id', positional: true, required: true, help: 'Practo practice_doctor_id' },
16
+ { name: 'time', required: true, help: 'Slot time YYYY-MM-DD HH:mm:ss' },
17
+ { name: 'profile-url', required: false, help: 'Doctor profile_url from `practo search`, used to build a canonical booking URL' },
18
+ ],
19
+ columns: ['practice_doctor_id', 'time', 'amount', 'prepaid', 'payment_mode', 'requires_payment', 'confirm_button', 'booking_url'],
20
+ func: async (page, kwargs) => {
21
+ const prepared = await prepareBookingPage(page, kwargs);
22
+ return bookingPreviewFromPageData(prepared.data, prepared.context);
23
+ },
24
+ });
@@ -0,0 +1,24 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { buildBookingUrl, findSlot, normalizePracticeDoctorId, rowsForSlots } from './utils.js';
3
+
4
+ cli({
5
+ site: 'practo',
6
+ name: 'booking-link',
7
+ access: 'read',
8
+ description: 'Build a Practo booking URL for a selected slot without confirming it',
9
+ domain: 'www.practo.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [
14
+ { name: 'practice_doctor_id', positional: true, required: true, help: 'Practo practice_doctor_id' },
15
+ { name: 'time', required: true, help: 'Slot time YYYY-MM-DD HH:mm:ss' },
16
+ { name: 'profile-url', required: false, help: 'Doctor profile_url from `practo search`, used to build a canonical booking URL' },
17
+ ],
18
+ columns: ['practice_doctor_id', 'time', 'booking_url'],
19
+ func: async (page, kwargs) => {
20
+ const practiceDoctorId = normalizePracticeDoctorId(kwargs.practice_doctor_id);
21
+ const slot = findSlot(await rowsForSlots(page, practiceDoctorId), kwargs.time);
22
+ return [{ practice_doctor_id: practiceDoctorId, time: slot.time, booking_url: buildBookingUrl({ practiceDoctorId, slotTime: slot.time, appointmentToken: slot.appointment_token, amount: slot.amount, prepaid: slot.prepaid, profileUrl: kwargs['profile-url'] }) }];
23
+ },
24
+ });
@@ -0,0 +1,29 @@
1
+ import { ArgumentError } from '@agentrhq/webcmd/errors';
2
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
+ import { cancelAppointment, normalizeAppointmentId, normalizeConfirm } from './utils.js';
4
+
5
+ cli({
6
+ site: 'practo',
7
+ name: 'cancel',
8
+ access: 'write',
9
+ description: 'Cancel a logged-in Practo Drive appointment after explicit confirmation',
10
+ domain: 'drive.practo.com',
11
+ strategy: Strategy.COOKIE,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ defaultWindowMode: 'foreground',
15
+ siteSession: 'persistent',
16
+ args: [
17
+ { name: 'appointment_id', positional: true, required: true, help: 'Appointment id from `practo appointments`' },
18
+ { name: 'confirm', type: 'boolean', default: false, help: 'Required. Set --confirm true to cancel the appointment.' },
19
+ ],
20
+ columns: ['status', 'appointment_id'],
21
+ func: async (page, kwargs) => {
22
+ const id = normalizeAppointmentId(kwargs.appointment_id);
23
+ if (!normalizeConfirm(kwargs.confirm)) {
24
+ throw new ArgumentError('Refusing to cancel appointment without --confirm true', 'Example: webcmd practo cancel <appointment_id> --confirm true');
25
+ }
26
+ await cancelAppointment(page, id);
27
+ return [{ status: 'cancelled', appointment_id: id }];
28
+ },
29
+ });