@agentrhq/webcmd 0.4.2 → 0.4.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 (59) hide show
  1. package/cli-manifest.json +755 -174
  2. package/clis/_shared/site-auth.js +0 -1
  3. package/clis/_shared/site-auth.test.js +0 -1
  4. package/clis/amazon-in/checkout.js +0 -1
  5. package/clis/blinkit/checkout.js +0 -1
  6. package/clis/blinkit/place-order.js +0 -1
  7. package/clis/chatgpt/model.js +2 -2
  8. package/clis/chatgpt/model.test.js +7 -1
  9. package/clis/chatgpt/utils.js +8 -0
  10. package/clis/chatgpt/utils.test.js +92 -1
  11. package/clis/district/checkout.js +0 -1
  12. package/clis/district/seats.js +0 -1
  13. package/clis/district/set-location.js +0 -1
  14. package/clis/district/showtimes.js +0 -1
  15. package/clis/google/images.js +456 -0
  16. package/clis/google/images.test.js +375 -0
  17. package/clis/instagram/user.js +5 -13
  18. package/clis/instagram/user.test.js +66 -0
  19. package/clis/mercury/check-login.js +0 -1
  20. package/clis/mercury/reimbursement-draft.js +0 -1
  21. package/clis/practo/book-confirm.js +0 -1
  22. package/clis/practo/cancel.js +0 -1
  23. package/clis/trip/attraction.js +74 -0
  24. package/clis/trip/car.js +74 -0
  25. package/clis/trip/deals.js +61 -0
  26. package/clis/trip/flight-round.js +88 -0
  27. package/clis/trip/flight.js +83 -0
  28. package/clis/trip/hotel-search.js +80 -0
  29. package/clis/trip/hotel.js +54 -0
  30. package/clis/trip/package.js +93 -0
  31. package/clis/trip/search.js +43 -0
  32. package/clis/trip/tour.js +84 -0
  33. package/clis/trip/train.js +76 -0
  34. package/clis/trip/transfer.js +82 -0
  35. package/clis/trip/trip.test.js +1420 -0
  36. package/clis/trip/utils.js +911 -0
  37. package/dist/src/build-manifest.js +0 -1
  38. package/dist/src/build-manifest.test.js +4 -0
  39. package/dist/src/cli.js +7 -7
  40. package/dist/src/cli.test.js +26 -10
  41. package/dist/src/command-presentation.js +1 -1
  42. package/dist/src/command-presentation.test.js +3 -0
  43. package/dist/src/command-surface.js +1 -1
  44. package/dist/src/command-surface.test.js +4 -0
  45. package/dist/src/commands/auth.js +0 -2
  46. package/dist/src/commands/auth.test.js +0 -2
  47. package/dist/src/discovery.js +0 -1
  48. package/dist/src/execution.js +3 -3
  49. package/dist/src/execution.test.js +68 -15
  50. package/dist/src/hosted/browser-args.js +2 -2
  51. package/dist/src/hosted/browser-args.test.js +10 -0
  52. package/dist/src/hosted/client.js +2 -2
  53. package/dist/src/manifest-types.d.ts +0 -2
  54. package/dist/src/registry.d.ts +0 -2
  55. package/dist/src/registry.js +0 -1
  56. package/hosted-contract.json +767 -3
  57. package/package.json +2 -2
  58. package/skills/webcmd-browser/SKILL.md +2 -1
  59. package/skills/webcmd-usage/SKILL.md +1 -1
@@ -0,0 +1,375 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+
4
+ const { __test__ } = await import('./images.js');
5
+ const { command, extractGoogleImageRows, inspectGoogleImagesPage, normalizeImageRows } = __test__;
6
+
7
+ function createPageMock(evaluateResult = []) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ closeWindow: vi.fn().mockResolvedValue(undefined),
11
+ newTab: vi.fn().mockResolvedValue('fresh-page'),
12
+ setActivePage: vi.fn().mockResolvedValue(undefined),
13
+ wait: vi.fn().mockResolvedValue(undefined),
14
+ scroll: vi.fn().mockResolvedValue(undefined),
15
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
16
+ };
17
+ }
18
+
19
+ describe('google images', () => {
20
+ it('registers as a browser-backed public Google command', () => {
21
+ expect(command).toBeDefined();
22
+ expect(command.site).toBe('google');
23
+ expect(command.name).toBe('images');
24
+ expect(command.access).toBe('read');
25
+ expect(command.browser).toBe(true);
26
+ expect(command.strategy).toBe('public');
27
+ expect(command.columns).toEqual(['rank', 'title', 'imageUrl', 'thumbnailUrl', 'sourceUrl', 'source', 'width', 'height']);
28
+ expect(command.args.find(arg => arg.name === 'resolve')).toMatchObject({
29
+ type: 'bool',
30
+ default: true,
31
+ });
32
+ });
33
+
34
+ it('rejects empty query and invalid limits before navigation', async () => {
35
+ const page = createPageMock();
36
+ await expect(command.func(page, { keyword: ' ', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
37
+ await expect(command.func(page, { keyword: 'webcmd', limit: 0 })).rejects.toMatchObject({ code: 'ARGUMENT' });
38
+ await expect(command.func(page, { keyword: 'webcmd', limit: 101 })).rejects.toMatchObject({ code: 'ARGUMENT' });
39
+ expect(page.goto).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it('extracts image and source URLs from Google imgres links', async () => {
43
+ const dom = new JSDOM(`
44
+ <div id="rso">
45
+ <a href="/imgres?imgurl=https%3A%2F%2Fcdn.example.com%2Fcat.jpg&imgrefurl=https%3A%2F%2Fexample.com%2Fcats">
46
+ <img alt="A tabby cat" src="https://encrypted-tbn0.gstatic.com/images?q=tbn:abc" width="320" height="240">
47
+ </a>
48
+ </div>
49
+ `, { url: 'https://www.google.com/search?tbm=isch&q=cats' });
50
+
51
+ await expect(extractGoogleImageRows(5, false, dom.window.document)).resolves.toEqual([[
52
+ 'A tabby cat',
53
+ 'https://cdn.example.com/cat.jpg',
54
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:abc',
55
+ 'https://example.com/cats',
56
+ 'example.com',
57
+ 320,
58
+ 240,
59
+ ]]);
60
+ });
61
+
62
+ it('falls back to visible thumbnails for dynamic image layouts', async () => {
63
+ const dom = new JSDOM(`
64
+ <div id="islrg">
65
+ <div data-lpage="https://example.org/post">
66
+ <img alt="Mountain photo" src="https://encrypted-tbn0.gstatic.com/images?q=tbn:mountain" width="160" height="90">
67
+ </div>
68
+ </div>
69
+ `, { url: 'https://www.google.com/search?tbm=isch&q=mountain' });
70
+
71
+ await expect(extractGoogleImageRows(5, false, dom.window.document)).resolves.toEqual([[
72
+ 'Mountain photo',
73
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:mountain',
74
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:mountain',
75
+ 'https://example.org/post',
76
+ 'example.org',
77
+ 160,
78
+ 90,
79
+ ]]);
80
+ });
81
+
82
+ it('extracts current udm=2 cards outside the legacy rso image grid', async () => {
83
+ const dom = new JSDOM(`
84
+ <div id="center_col">
85
+ <a href="https://shop.example.com/blue-bottle">
86
+ <div>
87
+ <img alt="Blue Bottle product" data-src="https://encrypted-tbn0.gstatic.com/images?q=tbn:blue" width="206" height="274">
88
+ <span>Blue Bottle product</span>
89
+ </div>
90
+ </a>
91
+ </div>
92
+ `, { url: 'https://www.google.com/search?q=blue-bottle&udm=2' });
93
+
94
+ await expect(extractGoogleImageRows(5, false, dom.window.document)).resolves.toEqual([[
95
+ 'Blue Bottle product',
96
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:blue',
97
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:blue',
98
+ 'https://shop.example.com/blue-bottle',
99
+ 'shop.example.com',
100
+ 206,
101
+ 274,
102
+ ]]);
103
+ });
104
+
105
+ it('finds source links beside current Google Images cards and skips small refinement chips', async () => {
106
+ const dom = new JSDOM(`
107
+ <div role="list">
108
+ <a aria-label="Add Pink" href="/search?q=pink+cats&udm=2">
109
+ <img alt="" src="https://encrypted-tbn0.gstatic.com/images?q=tbn:chip" width="46" height="46">
110
+ </a>
111
+ </div>
112
+ <div id="rso">
113
+ <div>
114
+ <div>
115
+ <a><img alt="10 Facts About Cats" src="https://encrypted-tbn0.gstatic.com/images?q=tbn:cat" width="320" height="214"></a>
116
+ <a href="https://www.fourpawsusa.org/campaigns-topics/topics/companion-animals/10-facts-about-cats">
117
+ <div>10 Facts About Cats - FOUR PAWS in US</div>
118
+ </a>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ `, { url: 'https://www.google.com/search?tbm=isch&q=cats' });
123
+
124
+ await expect(extractGoogleImageRows(5, false, dom.window.document)).resolves.toEqual([[
125
+ '10 Facts About Cats',
126
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:cat',
127
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:cat',
128
+ 'https://www.fourpawsusa.org/campaigns-topics/topics/companion-animals/10-facts-about-cats',
129
+ 'fourpawsusa.org',
130
+ 320,
131
+ 214,
132
+ ]]);
133
+ });
134
+
135
+ it('clicks preview cards to resolve the original imgurl when requested', async () => {
136
+ const dom = new JSDOM(`
137
+ <div id="rso">
138
+ <div>
139
+ <div>
140
+ <div role="button">
141
+ <img alt="Original cat" src="https://encrypted-tbn0.gstatic.com/images?q=tbn:cat" width="320" height="214">
142
+ </div>
143
+ <a href="https://example.com/cats"><div>Original cat</div></a>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ `, {
148
+ url: 'https://www.google.com/search?tbm=isch&q=cats',
149
+ pretendToBeVisual: true,
150
+ });
151
+ const button = dom.window.document.querySelector('[role="button"]');
152
+ button.addEventListener('click', () => {
153
+ const preview = dom.window.document.createElement('a');
154
+ preview.href = '/imgres?imgurl=https%3A%2F%2Fcdn.example.com%2Foriginal-cat.jpg&imgrefurl=https%3A%2F%2Fexample.com%2Fcats&w=1920&h=1080';
155
+ dom.window.document.body.appendChild(preview);
156
+ });
157
+
158
+ await expect(extractGoogleImageRows(1, true, dom.window.document)).resolves.toEqual([[
159
+ 'Original cat',
160
+ 'https://cdn.example.com/original-cat.jpg',
161
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:cat',
162
+ 'https://example.com/cats',
163
+ 'example.com',
164
+ 1920,
165
+ 1080,
166
+ ]]);
167
+ });
168
+
169
+ it('normalizes browser rows into command columns', async () => {
170
+ const page = createPageMock({
171
+ session: 'site:google',
172
+ data: [[
173
+ 'A tabby cat',
174
+ 'https://cdn.example.com/cat.jpg',
175
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:abc',
176
+ 'https://example.com/cats',
177
+ 'example.com',
178
+ 320,
179
+ 240,
180
+ ]],
181
+ });
182
+
183
+ await expect(command.func(page, { keyword: 'cats', limit: 1 })).resolves.toEqual([{
184
+ rank: 1,
185
+ title: 'A tabby cat',
186
+ imageUrl: 'https://cdn.example.com/cat.jpg',
187
+ thumbnailUrl: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:abc',
188
+ sourceUrl: 'https://example.com/cats',
189
+ source: 'example.com',
190
+ width: 320,
191
+ height: 240,
192
+ }]);
193
+ expect(page.goto.mock.calls[0][0]).toContain('num=20');
194
+ });
195
+
196
+ it('retries extraction while Google Images hydrates placeholder images', async () => {
197
+ const page = createPageMock();
198
+ page.evaluate
199
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
200
+ .mockResolvedValueOnce({
201
+ session: 'site:google',
202
+ data: [[
203
+ 'A tabby cat',
204
+ 'https://cdn.example.com/cat.jpg',
205
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:abc',
206
+ 'https://example.com/cats',
207
+ 'example.com',
208
+ 320,
209
+ 240,
210
+ ]],
211
+ });
212
+
213
+ await expect(command.func(page, { keyword: 'cats', limit: 1, resolve: false })).resolves.toHaveLength(1);
214
+
215
+ expect(page.evaluate).toHaveBeenCalledTimes(2);
216
+ expect(page.scroll).toHaveBeenCalledWith('down', 900);
217
+ expect(page.wait).toHaveBeenCalledWith(1);
218
+ });
219
+
220
+ it('drops the current tab lease and retries once when navigation is rejected', async () => {
221
+ const page = createPageMock([[
222
+ 'A tabby cat',
223
+ 'https://cdn.example.com/cat.jpg',
224
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:abc',
225
+ 'https://example.com/cats',
226
+ 'example.com',
227
+ 320,
228
+ 240,
229
+ ]]);
230
+ page.goto
231
+ .mockRejectedValueOnce(new Error('Navigation rejected.'))
232
+ .mockResolvedValueOnce(undefined);
233
+
234
+ await expect(command.func(page, { keyword: 'cats', limit: 1, resolve: false })).resolves.toHaveLength(1);
235
+
236
+ expect(page.goto).toHaveBeenCalledTimes(2);
237
+ expect(page.closeWindow).toHaveBeenCalledTimes(1);
238
+ expect(page.goto.mock.calls[1][0]).toContain('tbm=isch');
239
+ });
240
+
241
+ it('opens a fresh tab when Google Images navigation is repeatedly rejected', async () => {
242
+ const page = createPageMock([[
243
+ 'A tabby cat',
244
+ 'https://cdn.example.com/cat.jpg',
245
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:abc',
246
+ 'https://example.com/cats',
247
+ 'example.com',
248
+ 320,
249
+ 240,
250
+ ]]);
251
+ page.goto
252
+ .mockRejectedValueOnce(new Error('Navigation rejected.'))
253
+ .mockRejectedValueOnce(new Error('Navigation rejected.'));
254
+
255
+ await expect(command.func(page, { keyword: 'cats', limit: 1, resolve: false })).resolves.toHaveLength(1);
256
+
257
+ expect(page.goto).toHaveBeenCalledTimes(2);
258
+ expect(page.closeWindow).toHaveBeenCalledTimes(1);
259
+ expect(page.newTab).toHaveBeenCalledTimes(1);
260
+ expect(page.newTab.mock.calls[0][0]).toContain('tbm=isch');
261
+ expect(page.setActivePage).toHaveBeenCalledWith('fresh-page');
262
+ });
263
+
264
+ it('fails typed instead of silently returning empty rows', () => {
265
+ expect(() => normalizeImageRows([], 'nothing', 5)).toThrow(expect.objectContaining({ code: 'EMPTY_RESULT' }));
266
+ expect(() => normalizeImageRows([{ bad: true }], 'bad', 5)).toThrow(expect.objectContaining({ code: 'COMMAND_EXEC' }));
267
+ expect(() => normalizeImageRows([[
268
+ 'Missing source',
269
+ 'https://cdn.example.com/cat.jpg',
270
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:abc',
271
+ '',
272
+ '',
273
+ 320,
274
+ 240,
275
+ ]], 'bad', 5)).toThrow(expect.objectContaining({ code: 'COMMAND_EXEC' }));
276
+ expect(() => normalizeImageRows([[
277
+ 'Google internal source',
278
+ 'https://cdn.example.com/cat.jpg',
279
+ 'https://encrypted-tbn0.gstatic.com/images?q=tbn:abc',
280
+ 'https://www.google.com/search?q=cats',
281
+ 'google.com',
282
+ 320,
283
+ 240,
284
+ ]], 'bad', 5)).toThrow(expect.objectContaining({ code: 'COMMAND_EXEC' }));
285
+ });
286
+
287
+ it('classifies Google interstitial and explicit no-result pages', () => {
288
+ const captchaDom = new JSDOM(`
289
+ <form action="/sorry/index">
290
+ <div>Our systems have detected unusual traffic from your computer network.</div>
291
+ </form>
292
+ `, { url: 'https://www.google.com/search?tbm=isch&q=cats' });
293
+ const emptyDom = new JSDOM('<div id="center_col">No results found for impossible-query.</div>', {
294
+ url: 'https://www.google.com/search?tbm=isch&q=impossible-query',
295
+ });
296
+
297
+ expect(inspectGoogleImagesPage(captchaDom.window.document)).toMatchObject({
298
+ captchaOrConsent: true,
299
+ explicitNoResults: false,
300
+ });
301
+ expect(inspectGoogleImagesPage(emptyDom.window.document)).toMatchObject({
302
+ captchaOrConsent: false,
303
+ explicitNoResults: true,
304
+ });
305
+ });
306
+
307
+ it('typed-fails no rows unless Google exposes an explicit no-results state', async () => {
308
+ const page = createPageMock();
309
+ page.evaluate = vi.fn()
310
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
311
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
312
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
313
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
314
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
315
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
316
+ .mockResolvedValueOnce({
317
+ session: 'site:google',
318
+ data: {
319
+ hasResultRoot: true,
320
+ hasImageCandidates: true,
321
+ captchaOrConsent: false,
322
+ explicitNoResults: false,
323
+ },
324
+ });
325
+
326
+ await expect(command.func(page, { keyword: 'cats', limit: 1, resolve: false }))
327
+ .rejects.toThrow(expect.objectContaining({ code: 'COMMAND_EXEC' }));
328
+ });
329
+
330
+ it('preserves true no-results as EmptyResultError when explicit marker is visible', async () => {
331
+ const page = createPageMock();
332
+ page.evaluate = vi.fn()
333
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
334
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
335
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
336
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
337
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
338
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
339
+ .mockResolvedValueOnce({
340
+ session: 'site:google',
341
+ data: {
342
+ hasResultRoot: true,
343
+ hasImageCandidates: false,
344
+ captchaOrConsent: false,
345
+ explicitNoResults: true,
346
+ },
347
+ });
348
+
349
+ await expect(command.func(page, { keyword: 'impossible-query', limit: 1, resolve: false }))
350
+ .rejects.toThrow(expect.objectContaining({ code: 'EMPTY_RESULT' }));
351
+ });
352
+
353
+ it('typed-fails CAPTCHA or consent pages instead of reporting empty image results', async () => {
354
+ const page = createPageMock();
355
+ page.evaluate = vi.fn()
356
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
357
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
358
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
359
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
360
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
361
+ .mockResolvedValueOnce({ session: 'site:google', data: [] })
362
+ .mockResolvedValueOnce({
363
+ session: 'site:google',
364
+ data: {
365
+ hasResultRoot: false,
366
+ hasImageCandidates: false,
367
+ captchaOrConsent: true,
368
+ explicitNoResults: false,
369
+ },
370
+ });
371
+
372
+ await expect(command.func(page, { keyword: 'cats', limit: 1, resolve: false }))
373
+ .rejects.toThrow(expect.objectContaining({ code: 'COMMAND_EXEC' }));
374
+ });
375
+ });
@@ -18,22 +18,14 @@ cli({
18
18
  const headers = { 'X-IG-App-ID': '936619743392459' };
19
19
  const opts = { credentials: 'include', headers };
20
20
 
21
- // Get user ID first
22
- const r1 = await fetch(
23
- 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),
24
- opts
25
- );
26
- if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram');
27
- const d1 = await r1.json();
28
- const userId = d1?.data?.user?.id;
29
- if (!userId) throw new Error('User not found: ' + username);
30
-
31
- // Get user feed
21
+ // Fetch directly by username. web_profile_info is gated for some public
22
+ // accounts even with a valid session, while this feed endpoint still returns
23
+ // the same media item shape this command maps.
32
24
  const r2 = await fetch(
33
- 'https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + limit,
25
+ 'https://www.instagram.com/api/v1/feed/user/' + encodeURIComponent(username) + '/username/?count=' + limit,
34
26
  opts
35
27
  );
36
- if (!r2.ok) throw new Error('Failed to fetch user feed: HTTP ' + r2.status);
28
+ if (!r2.ok) throw new Error('HTTP ' + r2.status + ' - make sure you are logged in to Instagram');
37
29
  const d2 = await r2.json();
38
30
  return (d2?.items || []).slice(0, limit).map((p, i) => ({
39
31
  index: i + 1,
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@agentrhq/webcmd/registry';
3
+ import './user.js';
4
+
5
+ function getUserEvaluateJs() {
6
+ const cmd = getRegistry().get('instagram/user');
7
+ const evalStep = cmd.pipeline.find((s) => s.evaluate);
8
+ return evalStep.evaluate;
9
+ }
10
+
11
+ async function runUserEvaluate(fetchFn, args = { username: 'sleepcycleapp', limit: 2 }) {
12
+ const js = getUserEvaluateJs()
13
+ .replace('${{ args.username | json }}', JSON.stringify(args.username))
14
+ .replace('${{ args.limit }}', String(args.limit));
15
+ const originalFetch = globalThis.fetch;
16
+ globalThis.fetch = fetchFn;
17
+ try {
18
+ return await eval(js);
19
+ } finally {
20
+ globalThis.fetch = originalFetch;
21
+ }
22
+ }
23
+
24
+ describe('instagram/user feed fetch', () => {
25
+ it('fetches recent posts directly by username', async () => {
26
+ const fetchFn = vi.fn().mockResolvedValue({
27
+ ok: true,
28
+ json: async () => ({
29
+ items: [
30
+ {
31
+ caption: { text: 'First post\nwith newline' },
32
+ like_count: 12,
33
+ comment_count: 3,
34
+ media_type: 1,
35
+ taken_at: 1784451600,
36
+ },
37
+ {
38
+ caption: { text: 'Second post' },
39
+ like_count: 4,
40
+ comment_count: 1,
41
+ media_type: 2,
42
+ },
43
+ ],
44
+ }),
45
+ });
46
+
47
+ const result = await runUserEvaluate(fetchFn, { username: 'sleep cycle/app', limit: 2 });
48
+
49
+ expect(fetchFn).toHaveBeenCalledTimes(1);
50
+ expect(fetchFn.mock.calls[0][0]).toBe('https://www.instagram.com/api/v1/feed/user/sleep%20cycle%2Fapp/username/?count=2');
51
+ expect(fetchFn.mock.calls[0][1]).toMatchObject({
52
+ credentials: 'include',
53
+ headers: { 'X-IG-App-ID': '936619743392459' },
54
+ });
55
+ expect(fetchFn.mock.calls[0][0]).not.toContain('web_profile_info');
56
+ expect(result).toEqual([
57
+ { index: 1, caption: 'First post with newline', likes: 12, comments: 3, type: 'photo', date: expect.any(String) },
58
+ { index: 2, caption: 'Second post', likes: 4, comments: 1, type: 'video', date: '' },
59
+ ]);
60
+ });
61
+
62
+ it('keeps the login-oriented error for failed feed requests', async () => {
63
+ const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 400 });
64
+ await expect(runUserEvaluate(fetchFn)).rejects.toThrow('HTTP 400 - make sure you are logged in to Instagram');
65
+ });
66
+ });
@@ -11,7 +11,6 @@ cli({
11
11
  strategy: Strategy.UI,
12
12
  browser: true,
13
13
  siteSession: 'persistent',
14
- defaultWindowMode: 'foreground',
15
14
  navigateBefore: false,
16
15
  args: [],
17
16
  columns: ['status', 'loggedIn', 'url', 'hasSubmitExpense', 'hasReimbursements', 'title'],
@@ -23,7 +23,6 @@ cli({
23
23
  strategy: Strategy.UI,
24
24
  browser: true,
25
25
  siteSession: 'persistent',
26
- defaultWindowMode: 'foreground',
27
26
  navigateBefore: false,
28
27
  args: [
29
28
  {
@@ -11,7 +11,6 @@ cli({
11
11
  strategy: Strategy.COOKIE,
12
12
  browser: true,
13
13
  navigateBefore: false,
14
- defaultWindowMode: 'foreground',
15
14
  siteSession: 'persistent',
16
15
  args: [
17
16
  { name: 'practice_doctor_id', positional: true, required: true, help: 'Practo practice_doctor_id' },
@@ -11,7 +11,6 @@ cli({
11
11
  strategy: Strategy.COOKIE,
12
12
  browser: true,
13
13
  navigateBefore: false,
14
- defaultWindowMode: 'foreground',
15
14
  siteSession: 'persistent',
16
15
  args: [
17
16
  { name: 'appointment_id', positional: true, required: true, help: 'Appointment id from `practo appointments`' },
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Trip.com (international) attractions and experiences search by destination
3
+ * keyword.
4
+ *
5
+ * The things-to-do products load client-side into hashed CSS-module cards, so
6
+ * this anchors on each card's stable `things-to-do/detail/<id>` link (which also
7
+ * gives a real per-row `url`) and reads rating / reviews / booked / price from
8
+ * the card text by data-format pattern (see `buildAttractionExtractJs` in utils).
9
+ */
10
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
11
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
12
+ import {
13
+ WAIT_FOR_ATTRACTIONS_JS,
14
+ buildAttractionExtractJs,
15
+ buildAttractionSearchUrl,
16
+ parseKeyword,
17
+ parseListLimit,
18
+ } from './utils.js';
19
+
20
+ cli({
21
+ site: 'trip',
22
+ name: 'attraction',
23
+ access: 'read',
24
+ description: 'Search Trip.com attractions and experiences by destination keyword',
25
+ domain: 'trip.com',
26
+ strategy: Strategy.COOKIE,
27
+ browser: true,
28
+ navigateBefore: false,
29
+ args: [
30
+ { name: 'query', required: true, positional: true, help: 'Destination or attraction keyword (e.g. Tokyo / Paris / Louvre)' },
31
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results (1-50)' },
32
+ ],
33
+ columns: [
34
+ 'rank',
35
+ 'name',
36
+ 'rating', 'reviews', 'booked',
37
+ 'price', 'currency',
38
+ 'url',
39
+ ],
40
+ func: async (page, kwargs) => {
41
+ const query = parseKeyword('query', kwargs.query);
42
+ const limit = parseListLimit(kwargs.limit);
43
+
44
+ const searchUrl = buildAttractionSearchUrl(query);
45
+ await page.goto(searchUrl);
46
+ const waitResult = await page.evaluate(WAIT_FOR_ATTRACTIONS_JS);
47
+ if (waitResult === 'captcha') {
48
+ throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry');
49
+ }
50
+ if (waitResult === 'empty') {
51
+ throw new EmptyResultError('trip attraction', `No attractions for "${query}"`);
52
+ }
53
+ if (waitResult !== 'content') {
54
+ throw new CommandExecutionError(`Trip.com things-to-do page did not render product cards (state=${String(waitResult)})`);
55
+ }
56
+ const raw = await page.evaluate(buildAttractionExtractJs());
57
+ if (!Array.isArray(raw)) {
58
+ throw new CommandExecutionError('Trip.com attraction DOM extraction returned malformed rows');
59
+ }
60
+ if (raw.length === 0) {
61
+ throw new CommandExecutionError('Trip.com attraction cards rendered but parser did not find required detail-link anchors');
62
+ }
63
+ return raw.slice(0, limit).map((r, i) => ({
64
+ rank: i + 1,
65
+ name: r.name,
66
+ rating: r.rating,
67
+ reviews: r.reviews,
68
+ booked: r.booked,
69
+ price: r.price,
70
+ currency: 'USD',
71
+ url: r.url,
72
+ }));
73
+ },
74
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Trip.com (international) car-rental listing by city.
3
+ *
4
+ * Trip.com files car-rental listings under an SEO path whose text slugs are
5
+ * cosmetic; only the numeric carhire city id routes the page, so this takes that
6
+ * id (discover it via the carhire search box) and reads the rendered `.card-item`
7
+ * cards by stable class fields (see `buildCarExtractJs` in utils). The listing
8
+ * carries the site's near-term representative daily price; a dated pickup /
9
+ * drop-off quote sits behind the booking step and is out of scope here.
10
+ */
11
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
12
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
13
+ import {
14
+ WAIT_FOR_CARS_JS,
15
+ buildCarExtractJs,
16
+ buildCarListUrl,
17
+ parseCityId,
18
+ parseListLimit,
19
+ } from './utils.js';
20
+
21
+ cli({
22
+ site: 'trip',
23
+ name: 'car',
24
+ access: 'read',
25
+ description: 'List Trip.com car-rental vehicles for a city (category, model, seats, daily price)',
26
+ domain: 'trip.com',
27
+ strategy: Strategy.COOKIE,
28
+ browser: true,
29
+ navigateBefore: false,
30
+ args: [
31
+ { name: 'city', required: true, positional: true, help: 'Numeric Trip.com carhire city id (discover via the carhire search box)' },
32
+ { name: 'limit', type: 'int', default: 20, help: 'Number of vehicles (1-50)' },
33
+ ],
34
+ columns: [
35
+ 'rank',
36
+ 'category', 'vehicle',
37
+ 'seats',
38
+ 'price', 'currency',
39
+ 'url',
40
+ ],
41
+ func: async (page, kwargs) => {
42
+ const cityId = parseCityId('city', kwargs.city);
43
+ const limit = parseListLimit(kwargs.limit);
44
+
45
+ const listUrl = buildCarListUrl(cityId);
46
+ await page.goto(listUrl);
47
+ const waitResult = await page.evaluate(WAIT_FOR_CARS_JS);
48
+ if (waitResult === 'captcha') {
49
+ throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry');
50
+ }
51
+ if (waitResult === 'empty') {
52
+ throw new EmptyResultError('trip car', `No car rentals for city id ${cityId}`);
53
+ }
54
+ if (waitResult !== 'content') {
55
+ throw new CommandExecutionError(`Trip.com car listing did not render (state=${String(waitResult)}); check the carhire city id`);
56
+ }
57
+ const raw = await page.evaluate(buildCarExtractJs());
58
+ if (!Array.isArray(raw)) {
59
+ throw new CommandExecutionError('Trip.com car DOM extraction returned malformed rows');
60
+ }
61
+ if (raw.length === 0) {
62
+ throw new CommandExecutionError('Trip.com car cards rendered but parser did not find required price anchors');
63
+ }
64
+ return raw.slice(0, limit).map((r, i) => ({
65
+ rank: i + 1,
66
+ category: r.category,
67
+ vehicle: r.vehicle,
68
+ seats: r.seats,
69
+ price: r.price,
70
+ currency: r.currency,
71
+ url: listUrl,
72
+ }));
73
+ },
74
+ });