@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,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
+ });
@@ -12,6 +12,7 @@ cli({
12
12
  ],
13
13
  columns: ['rank', 'id', 'title', 'subreddit', 'score', 'comments', 'author', 'url', 'created_utc', 'selftext', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
14
14
  pipeline: [
15
+ { navigate: 'https://www.reddit.com' },
15
16
  { evaluate: `(async () => {
16
17
  function decodeHtml(s) {
17
18
  if (typeof s !== 'string' || !s) return '';
@@ -43,7 +44,17 @@ cli({
43
44
  const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', {
44
45
  credentials: 'include'
45
46
  });
46
- const d = await res.json();
47
+ const text = await res.text();
48
+ if (!res.ok) {
49
+ throw new Error('Reddit popular request failed: HTTP ' + res.status + ' ' + text.replace(/\\s+/g, ' ').slice(0, 160));
50
+ }
51
+ let d;
52
+ try {
53
+ d = JSON.parse(text);
54
+ } catch {
55
+ const kind = /^\\s*</.test(text) ? 'HTML' : 'non-JSON';
56
+ throw new Error('Reddit popular expected JSON but received ' + kind + ': ' + text.replace(/\\s+/g, ' ').slice(0, 160));
57
+ }
47
58
  return (d?.data?.children || []).map(c => ({
48
59
  id: c.data.id,
49
60
  title: c.data.title,
@@ -4,6 +4,8 @@ import './popular.js';
4
4
 
5
5
  describe('reddit popular adapter', () => {
6
6
  const command = getRegistry().get('reddit/popular');
7
+ const evaluate = command?.pipeline?.find((step) => step.evaluate)?.evaluate;
8
+ const map = command?.pipeline?.find((step) => step.map)?.map;
7
9
 
8
10
  it('exposes the full post-list shape including the 4 media columns', () => {
9
11
  expect(command?.columns).toEqual([
@@ -14,13 +16,20 @@ describe('reddit popular adapter', () => {
14
16
  });
15
17
 
16
18
  it('surfaces media via extractRedditMedia in evaluate + map', () => {
17
- expect(command?.pipeline?.[0]?.evaluate).toContain('function extractRedditMedia');
18
- expect(command?.pipeline?.[0]?.evaluate).toContain('...extractRedditMedia(c.data)');
19
- expect(command?.pipeline?.[1]?.map).toMatchObject({
19
+ expect(evaluate).toContain('function extractRedditMedia');
20
+ expect(evaluate).toContain('...extractRedditMedia(c.data)');
21
+ expect(map).toMatchObject({
20
22
  post_hint: '${{ item.post_hint }}',
21
23
  url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
22
24
  preview_image_url: '${{ item.preview_image_url }}',
23
25
  gallery_urls: '${{ item.gallery_urls }}',
24
26
  });
25
27
  });
28
+
29
+ it('navigates to Reddit and guards HTML responses before JSON parsing', () => {
30
+ expect(command?.pipeline?.[0]).toEqual({ navigate: 'https://www.reddit.com' });
31
+ expect(evaluate).toContain('await res.text()');
32
+ expect(evaluate).toContain('Reddit popular expected JSON');
33
+ expect(evaluate).not.toContain('await res.json()');
34
+ });
26
35
  });
@@ -1,28 +1,36 @@
1
1
  import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
2
  import { CommandExecutionError } from '@agentrhq/webcmd/errors';
3
- import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
3
+ import { parseTweetUrl, buildTwitterArticleScopeSource, unwrapBrowserResult } from './shared.js';
4
4
 
5
5
  function buildDeleteScript(tweetId) {
6
6
  return `(async () => {
7
7
  try {
8
8
  const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
9
9
  ${buildTwitterArticleScopeSource(tweetId)}
10
- const targetArticle = findTargetArticle();
10
+ let targetArticle = findTargetArticle();
11
+ for (let i = 0; i < 20 && !targetArticle; i++) {
12
+ await new Promise(r => setTimeout(r, 250));
13
+ targetArticle = findTargetArticle();
14
+ }
11
15
 
12
16
  if (!targetArticle) {
13
17
  return { ok: false, message: 'Could not find the tweet card matching the requested URL.' };
14
18
  }
15
19
 
16
- const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]'));
17
- const moreMenu = buttons.find((el) => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More');
20
+ const belongsToTargetArticle = (el) => el.closest('article') === targetArticle;
21
+ const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]')).filter(belongsToTargetArticle);
22
+ const moreMenu = Array.from(targetArticle.querySelectorAll('[data-testid="caret"]')).filter(belongsToTargetArticle).find(visible)
23
+ || buttons.find((el) => visible(el) && /^More/.test((el.getAttribute('aria-label') || '').trim()));
18
24
  if (!moreMenu) {
19
25
  return { ok: false, message: 'Could not find the "More" context menu on the matched tweet. Are you sure you are logged in and looking at a valid tweet?' };
20
26
  }
21
27
 
28
+ const beforeMenuItems = new Set(document.querySelectorAll('[role="menuitem"]'));
22
29
  moreMenu.click();
23
30
  await new Promise(r => setTimeout(r, 1000));
24
31
 
25
- const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
32
+ const items = Array.from(document.querySelectorAll('[role="menuitem"]'))
33
+ .filter((item) => visible(item) && !beforeMenuItems.has(item));
26
34
  const deleteBtn = items.find((item) => {
27
35
  const text = (item.textContent || '').trim();
28
36
  return text.includes('Delete') && !text.includes('List');
@@ -69,7 +77,7 @@ cli({
69
77
  const target = parseTweetUrl(kwargs.url);
70
78
  await page.goto(target.url);
71
79
  await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
72
- const result = await page.evaluate(buildDeleteScript(target.id));
80
+ const result = unwrapBrowserResult(await page.evaluate(buildDeleteScript(target.id)));
73
81
  if (result.ok) {
74
82
  // Wait for the deletion request to be processed
75
83
  await page.wait(2);
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
2
3
  import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
3
4
  import { getRegistry } from '@agentrhq/webcmd/registry';
4
- import './delete.js';
5
+ import { __test__ } from './delete.js';
5
6
  describe('twitter delete command', () => {
6
7
  it('targets the matched tweet article instead of the first More button on the page', async () => {
7
8
  const cmd = getRegistry().get('twitter/delete');
@@ -27,6 +28,12 @@ describe('twitter delete command', () => {
27
28
  expect(script).toContain('__twGetStatusIdFromHref');
28
29
  expect(script).toContain("document.querySelectorAll('article')");
29
30
  expect(script).toContain("targetArticle.querySelectorAll('button,[role=\"button\"]')");
31
+ expect(script).toContain("closest('article') === targetArticle");
32
+ expect(script).toContain(".filter(belongsToTargetArticle)");
33
+ expect(script).toContain('[data-testid="caret"]');
34
+ expect(script).toContain('/^More/');
35
+ expect(script).toContain('i < 20');
36
+ expect(script).toContain('beforeMenuItems');
30
37
  // Substring match must NOT appear — exact-id match only.
31
38
  expect(script).not.toContain("'/status/' + tweetId");
32
39
  expect(result).toEqual([
@@ -58,6 +65,72 @@ describe('twitter delete command', () => {
58
65
  ]);
59
66
  expect(page.wait).toHaveBeenCalledTimes(1);
60
67
  });
68
+ it('unwraps browser runtime evaluate envelopes before checking delete success', async () => {
69
+ const cmd = getRegistry().get('twitter/delete');
70
+ expect(cmd?.func).toBeTypeOf('function');
71
+ const page = {
72
+ goto: vi.fn().mockResolvedValue(undefined),
73
+ wait: vi.fn().mockResolvedValue(undefined),
74
+ evaluate: vi.fn().mockResolvedValue({
75
+ session: 'twitter',
76
+ data: { ok: true, message: 'Tweet successfully deleted.' },
77
+ }),
78
+ };
79
+ const result = await cmd.func(page, {
80
+ url: 'https://x.com/alice/status/2040254679301718161',
81
+ });
82
+ expect(result).toEqual([
83
+ {
84
+ status: 'success',
85
+ message: 'Tweet successfully deleted.',
86
+ },
87
+ ]);
88
+ expect(page.wait).toHaveBeenNthCalledWith(2, 2);
89
+ });
90
+ it('ignores stale non-target delete menu items that existed before opening the matched tweet menu', async () => {
91
+ const dom = new JSDOM(`
92
+ <body>
93
+ <div role="menuitem" data-stale-delete>Delete</div>
94
+ <article>
95
+ <a href="https://x.com/bob/status/999">wrong tweet</a>
96
+ <button data-testid="caret" aria-label="More"></button>
97
+ </article>
98
+ <article>
99
+ <a href="https://x.com/alice/status/2040254679301718161">target tweet</a>
100
+ <button data-testid="caret" aria-label="More" data-target-caret></button>
101
+ </article>
102
+ </body>
103
+ `, { runScripts: 'outside-only', url: 'https://x.com/alice/status/2040254679301718161' });
104
+ dom.window.setTimeout = (handler) => {
105
+ if (typeof handler === 'function') handler();
106
+ return 0;
107
+ };
108
+ Object.defineProperty(dom.window.HTMLElement.prototype, 'getClientRects', {
109
+ configurable: true,
110
+ value() {
111
+ return [{ bottom: 1, height: 1, left: 0, right: 1, top: 0, width: 1 }];
112
+ },
113
+ });
114
+ let staleDeleteClicked = false;
115
+ let targetCaretClicked = false;
116
+ dom.window.document.querySelector('[data-stale-delete]')?.addEventListener('click', () => {
117
+ staleDeleteClicked = true;
118
+ });
119
+ dom.window.document.querySelector('[data-target-caret]')?.addEventListener('click', () => {
120
+ targetCaretClicked = true;
121
+ const item = dom.window.document.createElement('div');
122
+ item.setAttribute('role', 'menuitem');
123
+ item.textContent = 'Pin to your profile';
124
+ dom.window.document.body.appendChild(item);
125
+ });
126
+ const result = await dom.window.eval(__test__.buildDeleteScript('2040254679301718161'));
127
+ expect(targetCaretClicked).toBe(true);
128
+ expect(staleDeleteClicked).toBe(false);
129
+ expect(result).toEqual({
130
+ ok: false,
131
+ message: 'The matched tweet menu did not contain Delete. This tweet may not belong to you.',
132
+ });
133
+ });
61
134
  it('rejects malformed or off-domain URLs with ArgumentError before navigation', async () => {
62
135
  const cmd = getRegistry().get('twitter/delete');
63
136
  expect(cmd?.func).toBeTypeOf('function');
@@ -85,6 +85,8 @@ function extractTweet(result, seen) {
85
85
  likes: l.favorite_count || 0,
86
86
  retweets: l.retweet_count || 0,
87
87
  replies: l.reply_count || 0,
88
+ quotes: l.quote_count || 0,
89
+ bookmarks: l.bookmark_count || 0,
88
90
  views,
89
91
  created_at: l.created_at || '',
90
92
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
@@ -156,7 +158,7 @@ cli({
156
158
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of tweets to return (default 20).' },
157
159
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
158
160
  ],
159
- columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'card', 'quoted_tweet'],
161
+ columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'quotes', 'bookmarks', 'views', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'card', 'quoted_tweet'],
160
162
  func: async (page, kwargs) => {
161
163
  const limit = kwargs.limit || 20;
162
164
  const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
@@ -50,6 +50,8 @@ describe('twitter timeline helpers', () => {
50
50
  favorite_count: 3,
51
51
  retweet_count: 2,
52
52
  reply_count: 1,
53
+ quote_count: 4,
54
+ bookmark_count: 5,
53
55
  created_at: 'now',
54
56
  },
55
57
  core: {
@@ -96,6 +98,8 @@ describe('twitter timeline helpers', () => {
96
98
  likes: 3,
97
99
  retweets: 2,
98
100
  replies: 1,
101
+ quotes: 4,
102
+ bookmarks: 5,
99
103
  views: 9,
100
104
  created_at: 'now',
101
105
  url: 'https://x.com/alice/status/1',