@agentrhq/webcmd 0.2.5 → 0.3.1

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 (200) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/_shared/site-auth.js +3 -3
  4. package/clis/_shared/site-auth.test.js +4 -4
  5. package/clis/chatgpt/ask.js +1 -1
  6. package/clis/chatgpt/ask.test.js +11 -0
  7. package/clis/chatgpt/commands.test.js +102 -1
  8. package/clis/chatgpt/deep-research-result.js +45 -3
  9. package/clis/chatgpt/image.js +22 -2
  10. package/clis/chatgpt/project-file-add.js +24 -1
  11. package/clis/chatgpt/utils.js +258 -52
  12. package/clis/chatgpt/utils.test.js +259 -1
  13. package/clis/claude/ask.js +21 -1
  14. package/clis/facebook/feed.js +110 -24
  15. package/clis/facebook/feed.test.js +62 -0
  16. package/clis/facebook/search.js +185 -38
  17. package/clis/facebook/search.test.js +95 -50
  18. package/clis/instagram/explore.js +30 -12
  19. package/clis/instagram/explore.test.js +77 -0
  20. package/clis/instagram/post.js +14 -1
  21. package/clis/instagram/reel.js +13 -1
  22. package/clis/linkedin/company.js +153 -0
  23. package/clis/linkedin/company.test.js +111 -0
  24. package/clis/linkedin/connections.js +135 -0
  25. package/clis/linkedin/connections.test.js +141 -0
  26. package/clis/mercury/reimbursement-draft.js +12 -1
  27. package/clis/slock/whoami.test.js +2 -2
  28. package/clis/twitter/article-evaluate.test.js +27 -0
  29. package/clis/twitter/article.js +73 -16
  30. package/clis/twitter/article.test.js +209 -0
  31. package/clis/twitter/download.js +6 -1
  32. package/clis/twitter/post.js +14 -1
  33. package/clis/twitter/profile.js +1 -1
  34. package/clis/twitter/profile.test.js +8 -0
  35. package/clis/twitter/quote.js +11 -1
  36. package/clis/twitter/reply.js +11 -1
  37. package/dist/src/browser/base-page.d.ts +26 -0
  38. package/dist/src/browser/base-page.js +23 -8
  39. package/dist/src/browser/base-page.test.js +45 -12
  40. package/dist/src/browser/command-catalog.d.ts +6 -0
  41. package/dist/src/browser/command-catalog.js +259 -0
  42. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  43. package/dist/src/browser/command-catalog.test.js +79 -0
  44. package/dist/src/browser/daemon-client.d.ts +2 -1
  45. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  47. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  48. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  49. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  50. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  51. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +10 -2
  52. package/dist/src/browser/runtime/local-cloak/session-manager.js +25 -9
  53. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  54. package/dist/src/browser/target-resolver.d.ts +1 -0
  55. package/dist/src/browser/target-resolver.js +99 -4
  56. package/dist/src/browser/target-resolver.test.js +141 -1
  57. package/dist/src/build-manifest.d.ts +6 -0
  58. package/dist/src/build-manifest.js +18 -1
  59. package/dist/src/build-manifest.test.js +57 -1
  60. package/dist/src/builtin-command-surface.d.ts +9 -0
  61. package/dist/src/builtin-command-surface.js +16 -0
  62. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  63. package/dist/src/check-hosted-contract.test.js +56 -0
  64. package/dist/src/cli.js +63 -110
  65. package/dist/src/cli.test.js +82 -6
  66. package/dist/src/command-presentation.d.ts +95 -0
  67. package/dist/src/command-presentation.js +486 -0
  68. package/dist/src/command-presentation.test.d.ts +1 -0
  69. package/dist/src/command-presentation.test.js +97 -0
  70. package/dist/src/command-surface.d.ts +43 -0
  71. package/dist/src/command-surface.js +205 -0
  72. package/dist/src/command-surface.test.d.ts +1 -0
  73. package/dist/src/command-surface.test.js +406 -0
  74. package/dist/src/commanderAdapter.d.ts +5 -1
  75. package/dist/src/commanderAdapter.js +12 -55
  76. package/dist/src/commanderAdapter.test.js +15 -3
  77. package/dist/src/commands/auth.js +3 -2
  78. package/dist/src/commands/auth.test.js +6 -6
  79. package/dist/src/community-plugin-sync.d.ts +11 -0
  80. package/dist/src/community-plugin-sync.js +138 -0
  81. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  82. package/dist/src/community-plugin-sync.test.js +123 -0
  83. package/dist/src/completion-fast.d.ts +4 -4
  84. package/dist/src/completion-fast.js +15 -34
  85. package/dist/src/completion-shared.d.ts +4 -0
  86. package/dist/src/completion-shared.js +38 -0
  87. package/dist/src/completion.js +3 -27
  88. package/dist/src/completion.test.js +31 -2
  89. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  90. package/dist/src/docs-sync-review-cli.test.js +322 -0
  91. package/dist/src/docs-sync-review.d.ts +117 -0
  92. package/dist/src/docs-sync-review.js +475 -0
  93. package/dist/src/docs-sync-review.test.d.ts +1 -0
  94. package/dist/src/docs-sync-review.test.js +324 -0
  95. package/dist/src/errors.d.ts +12 -3
  96. package/dist/src/errors.js +11 -7
  97. package/dist/src/errors.test.js +14 -1
  98. package/dist/src/execution.d.ts +1 -2
  99. package/dist/src/execution.js +4 -45
  100. package/dist/src/generate-release-notes-cli.test.js +11 -1
  101. package/dist/src/help.d.ts +4 -0
  102. package/dist/src/help.js +50 -255
  103. package/dist/src/help.test.js +27 -1
  104. package/dist/src/hosted/args.d.ts +4 -0
  105. package/dist/src/hosted/args.js +4 -0
  106. package/dist/src/hosted/args.test.d.ts +1 -0
  107. package/dist/src/hosted/args.test.js +49 -0
  108. package/dist/src/hosted/availability.d.ts +13 -0
  109. package/dist/src/hosted/availability.js +16 -0
  110. package/dist/src/hosted/availability.test.d.ts +1 -0
  111. package/dist/src/hosted/availability.test.js +180 -0
  112. package/dist/src/hosted/browser-args.d.ts +18 -0
  113. package/dist/src/hosted/browser-args.js +152 -0
  114. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  115. package/dist/src/hosted/browser-args.test.js +182 -0
  116. package/dist/src/hosted/client.d.ts +59 -0
  117. package/dist/src/hosted/client.js +510 -0
  118. package/dist/src/hosted/client.test.d.ts +1 -0
  119. package/dist/src/hosted/client.test.js +738 -0
  120. package/dist/src/hosted/config.d.ts +55 -0
  121. package/dist/src/hosted/config.js +112 -0
  122. package/dist/src/hosted/config.test.d.ts +1 -0
  123. package/dist/src/hosted/config.test.js +81 -0
  124. package/dist/src/hosted/contract.d.ts +94 -0
  125. package/dist/src/hosted/contract.js +208 -0
  126. package/dist/src/hosted/contract.test.d.ts +1 -0
  127. package/dist/src/hosted/contract.test.js +361 -0
  128. package/dist/src/hosted/credentials.d.ts +38 -0
  129. package/dist/src/hosted/credentials.js +248 -0
  130. package/dist/src/hosted/credentials.test.d.ts +1 -0
  131. package/dist/src/hosted/credentials.test.js +93 -0
  132. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  133. package/dist/src/hosted/file-contract.test.js +174 -0
  134. package/dist/src/hosted/files.d.ts +29 -0
  135. package/dist/src/hosted/files.js +296 -0
  136. package/dist/src/hosted/files.test.d.ts +1 -0
  137. package/dist/src/hosted/files.test.js +231 -0
  138. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  139. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  140. package/dist/src/hosted/manifest.d.ts +14 -0
  141. package/dist/src/hosted/manifest.js +47 -0
  142. package/dist/src/hosted/manifest.test.d.ts +1 -0
  143. package/dist/src/hosted/manifest.test.js +164 -0
  144. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  145. package/dist/src/hosted/output-parity.test.js +108 -0
  146. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  147. package/dist/src/hosted/root-command-surface.test.js +646 -0
  148. package/dist/src/hosted/runner.d.ts +19 -0
  149. package/dist/src/hosted/runner.js +705 -0
  150. package/dist/src/hosted/runner.test.d.ts +1 -0
  151. package/dist/src/hosted/runner.test.js +1607 -0
  152. package/dist/src/hosted/setup.d.ts +10 -0
  153. package/dist/src/hosted/setup.js +79 -0
  154. package/dist/src/hosted/setup.test.d.ts +1 -0
  155. package/dist/src/hosted/setup.test.js +195 -0
  156. package/dist/src/hosted/types.d.ts +175 -0
  157. package/dist/src/hosted/types.js +1 -0
  158. package/dist/src/main.js +123 -97
  159. package/dist/src/manifest-types.d.ts +2 -0
  160. package/dist/src/output.d.ts +17 -2
  161. package/dist/src/output.js +88 -81
  162. package/dist/src/output.test.js +141 -51
  163. package/dist/src/pipeline/executor.test.js +1 -0
  164. package/dist/src/pipeline/steps/download.test.js +1 -0
  165. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  166. package/dist/src/plugin-create-cli.test.js +37 -0
  167. package/dist/src/plugin-manifest.d.ts +12 -0
  168. package/dist/src/plugin-manifest.js +16 -0
  169. package/dist/src/plugin-manifest.test.js +19 -1
  170. package/dist/src/plugin-scaffold.d.ts +4 -1
  171. package/dist/src/plugin-scaffold.js +4 -1
  172. package/dist/src/plugin-scaffold.test.js +23 -8
  173. package/dist/src/plugin.js +4 -1
  174. package/dist/src/plugin.test.js +13 -0
  175. package/dist/src/registry.d.ts +2 -0
  176. package/dist/src/release-notes.d.ts +6 -1
  177. package/dist/src/release-notes.js +192 -4
  178. package/dist/src/release-notes.test.js +143 -4
  179. package/dist/src/root-command-surface.d.ts +31 -0
  180. package/dist/src/root-command-surface.js +106 -0
  181. package/dist/src/serialization.d.ts +1 -16
  182. package/dist/src/serialization.js +5 -55
  183. package/dist/src/stream-write.d.ts +12 -0
  184. package/dist/src/stream-write.js +91 -0
  185. package/dist/src/stream-write.test.d.ts +1 -0
  186. package/dist/src/stream-write.test.js +186 -0
  187. package/dist/src/types.d.ts +5 -0
  188. package/dist/src/utils.d.ts +1 -1
  189. package/dist/src/utils.js +2 -8
  190. package/dist/src/utils.test.js +50 -0
  191. package/hosted-contract.json +36748 -0
  192. package/package.json +6 -1
  193. package/scripts/check-hosted-contract.mjs +108 -0
  194. package/scripts/docs-sync-review.ts +332 -0
  195. package/scripts/generate-release-notes.ts +1 -1
  196. package/scripts/sync-community-plugins.ts +14 -0
  197. package/skills/smart-search/SKILL.md +14 -3
  198. package/skills/webcmd-autofix/SKILL.md +26 -37
  199. package/skills/webcmd-usage/SKILL.md +21 -7
  200. package/clis/antigravity/SKILL.md +0 -38
@@ -166,4 +166,66 @@ describe('facebook feed', () => {
166
166
  await expect(__test__.command.func(createPage({ rows: null }), { limit: 1 }))
167
167
  .rejects.toBeInstanceOf(CommandExecutionError);
168
168
  });
169
+
170
+ it('extracts modern feed posts anchored on the Actions for this post menu', () => {
171
+ const payload = runExtract(`
172
+ <main role="main">
173
+ <div>
174
+ <div>
175
+ <h3><a role="link" href="https://www.facebook.com/carol">Carol Poster</a></h3>
176
+ <div dir="auto">A modern feed post with no article wrapper anywhere on it.</div>
177
+ <a href="https://www.facebook.com/carol/posts/999">2h</a>
178
+ <div aria-label="Actions for this post" role="button"></div>
179
+ </div>
180
+ <div>
181
+ <h3><a role="link" href="https://www.facebook.com/dave">Dave Danger</a></h3>
182
+ <div dir="auto">Second streamed post body that should also be extracted.</div>
183
+ <div aria-label="Actions for this post" role="button"></div>
184
+ </div>
185
+ </div>
186
+ </main>
187
+ `);
188
+
189
+ expect(payload.status).toBe('ok');
190
+ expect(payload.diagnostics.actionMenuCount).toBe(2);
191
+ expect(payload.rows.map((row) => row.author)).toEqual(['Carol Poster', 'Dave Danger']);
192
+ expect(payload.rows[0].content).toContain('modern feed post');
193
+ });
194
+
195
+ it('does not climb to the main landmark on a single-post page', () => {
196
+ const payload = runExtract(`
197
+ <main role="main">
198
+ <div>
199
+ <div>
200
+ <h3><a role="link" href="https://www.facebook.com/solo">Solo Poster</a></h3>
201
+ <div dir="auto">The only post on the page must remain bounded to its own card.</div>
202
+ <div aria-label="Actions for this post" role="button"></div>
203
+ </div>
204
+ </div>
205
+ </main>
206
+ `);
207
+
208
+ expect(payload.rows).toHaveLength(1);
209
+ expect(payload.rows[0].author).toBe('Solo Poster');
210
+ });
211
+
212
+ it('keeps legitimate numeric names while dropping hidden and numeric decoys', () => {
213
+ const payload = runExtract(`
214
+ <main role="main">
215
+ <div>
216
+ <div>
217
+ <h3><a role="link" href="https://www.facebook.com/class2024">Class of 2024</a></h3>
218
+ <span>\u200b\u200b\u200b</span>
219
+ <div dir="auto">Genuine reunion post content that survives decoy filtering.</div>
220
+ <div dir="auto">1234567890123</div>
221
+ <div aria-label="Actions for this post" role="button"></div>
222
+ </div>
223
+ </div>
224
+ </main>
225
+ `);
226
+
227
+ expect(payload.status).toBe('ok');
228
+ expect(payload.rows[0].author).toBe('Class of 2024');
229
+ expect(payload.rows[0].content).not.toContain('1234567890123');
230
+ });
169
231
  });
@@ -1,39 +1,186 @@
1
- import { cli } from '@agentrhq/webcmd/registry';
2
- cli({
3
- site: 'facebook',
4
- name: 'search',
5
- access: 'read',
6
- description: 'Search Facebook for people, pages, or posts',
7
- domain: 'www.facebook.com',
8
- args: [
9
- { name: 'query', required: true, positional: true, help: 'Search query' },
10
- { name: 'limit', type: 'int', default: 10, help: 'Number of results' },
11
- ],
12
- columns: ['index', 'title', 'text', 'url'],
13
- pipeline: [
14
- { navigate: 'https://www.facebook.com' },
15
- { navigate: { url: 'https://www.facebook.com/search/top?q=${{ args.query | urlencode }}', settleMs: 4000 } },
16
- { evaluate: `(async () => {
17
- const limit = \${{ args.limit }};
18
- // Search results are typically in role="article" or role="listitem"
19
- let items = document.querySelectorAll('[role="article"]');
20
- if (items.length === 0) {
21
- items = document.querySelectorAll('[role="listitem"]');
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
2
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
+
4
+ const FACEBOOK_HOME = 'https://www.facebook.com';
5
+ const MAX_LIMIT = 50;
6
+
7
+ function requireLimit(value) {
8
+ const n = Number(value);
9
+ if (!Number.isInteger(n) || n < 1 || n > MAX_LIMIT) {
10
+ throw new ArgumentError(`facebook search --limit must be an integer between 1 and ${MAX_LIMIT}`);
22
11
  }
23
- return Array.from(items)
24
- .filter(el => el.textContent.trim().length > 20)
25
- .slice(0, limit)
26
- .map((el, i) => {
27
- const link = el.querySelector('a[href*="facebook.com/"]');
28
- const heading = el.querySelector('h2, h3, h4, strong');
29
- return {
30
- index: i + 1,
31
- title: heading ? heading.textContent.trim().substring(0, 80) : '',
32
- text: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 150),
33
- url: link ? link.href.split('?')[0] : '',
34
- };
35
- });
36
- })()
37
- ` },
38
- ],
39
- });
12
+ return n;
13
+ }
14
+
15
+ function requireQuery(value) {
16
+ const q = String(value ?? '').trim();
17
+ if (!q) throw new ArgumentError('facebook search requires a non-empty query');
18
+ return q;
19
+ }
20
+
21
+ function unwrapBrowserResult(value) {
22
+ if (value && typeof value === 'object' && 'data' in value) return value.data;
23
+ return value;
24
+ }
25
+
26
+ // Modern facebook.com /search/top renders results inside [role="feed"] as
27
+ // entity/content links (people, pages, groups, posts) — [role="article"] /
28
+ // [role="listitem"] no longer wrap them — and FB seeds scrambled hidden-char
29
+ // decoy links back to /search/top. Collect anchors inside the feed, keep only
30
+ // real entity/content hrefs, and drop the decoys plus obfuscated text. See #2090.
31
+ function buildSearchExtractScript(limit) {
32
+ return `(() => {
33
+ const limit = ${limit};
34
+
35
+ function clean(value) {
36
+ return String(value || '')
37
+ .replace(/[\\u200b-\\u200f\\u202a-\\u202e\\u2060\\ufeff]/g, '')
38
+ .replace(/\\s+/g, ' ')
39
+ .trim();
40
+ }
41
+
42
+ // FB anti-scrape obfuscation: long spaceless digit tokens and spaced
43
+ // single-char strings ("a b c d e"). Real titles never look like this.
44
+ function isObfuscated(text) {
45
+ if (!text) return true;
46
+ if (/^\\d{8,}$/.test(text)) return true;
47
+ if (/^(?:\\S ){4,}\\S$/.test(text) && text.replace(/\\s/g, '').length <= 12) return true;
48
+ return false;
49
+ }
50
+
51
+ function isEntityHref(href) {
52
+ if (!href) return false;
53
+ let u;
54
+ try { u = new URL(href, 'https://www.facebook.com'); } catch (e) { return false; }
55
+ // drop hidden-domain .com spam — real results stay on facebook.com
56
+ if (!/(^|\\.)facebook\\.com$/i.test(u.hostname)) return false;
57
+ const p = u.pathname;
58
+ if (/^\\/search(\\/|$)/i.test(p)) return false; // decoy links back to search (incl. bare /search)
59
+ // chrome / non-result destinations that the catch-all below would keep
60
+ if (/^\\/(login|checkpoint|help|policies|privacy|settings|bookmarks|messages|notifications|marketplace|gaming|friends|requests|saved|me)\\b/i.test(p)) return false;
61
+ return /^\\/(profile\\.php|groups\\/|events\\/|watch\\/|reel\\/|pages\\/|permalink\\.php|story\\.php|[^/]+\\/posts\\/|[^/]+\\/videos\\/|[A-Za-z0-9.\\-]{2,}\\/?$)/i.test(p);
62
+ }
63
+
64
+ function isAuthPage() {
65
+ const path = window.location && window.location.pathname ? window.location.pathname : '';
66
+ const body = clean(document.body && document.body.textContent);
67
+ return /^\\/(login|checkpoint)(\\/|$|\\.php)/.test(path)
68
+ || /^(Log in to Facebook|Facebook Log in|Log in Facebook)/i.test(body)
69
+ || /You must log in to continue/i.test(body);
70
+ }
71
+
72
+ if (isAuthPage()) return { status: 'auth', rows: [], diagnostics: {} };
73
+
74
+ const feed = document.querySelector('[role="feed"]')
75
+ || document.querySelector('[role="main"]')
76
+ || document.body;
77
+ const anchors = Array.from(feed.querySelectorAll('a[href]'));
78
+ const seen = new Set();
79
+ const rows = [];
80
+ for (const a of anchors) {
81
+ const rawHref = a.href || a.getAttribute('href') || '';
82
+ if (!isEntityHref(rawHref)) continue;
83
+ let key;
84
+ try { const u = new URL(rawHref, 'https://www.facebook.com'); key = u.origin + u.pathname; }
85
+ catch (e) { key = rawHref.split('?')[0].split('#')[0]; }
86
+ if (seen.has(key)) continue;
87
+
88
+ const title = clean(a.textContent).substring(0, 80);
89
+ if (!title || isObfuscated(title)) continue;
90
+
91
+ // climb a few levels for the surrounding card text
92
+ let card = a;
93
+ for (let i = 0; i < 4 && card.parentElement; i += 1) {
94
+ card = card.parentElement;
95
+ if (clean(card.textContent).length > title.length + 20) break;
96
+ }
97
+ const text = clean(card.textContent).substring(0, 150);
98
+ if (isObfuscated(text)) continue;
99
+
100
+ seen.add(key);
101
+ rows.push({ index: rows.length + 1, title, text, url: key });
102
+ if (rows.length >= limit) break;
103
+ }
104
+
105
+ return {
106
+ status: rows.length ? 'ok' : 'no_rows',
107
+ rows,
108
+ diagnostics: {
109
+ feedFound: !!document.querySelector('[role="feed"]'),
110
+ anchorCount: anchors.length,
111
+ mainTextLength: clean((document.querySelector('[role="main"]') || {}).textContent).length,
112
+ },
113
+ };
114
+ })()`;
115
+ }
116
+
117
+ async function searchFacebook(page, kwargs) {
118
+ const query = requireQuery(kwargs.query);
119
+ const limit = requireLimit(kwargs.limit ?? 10);
120
+
121
+ // Navigate home first so the SPA is warm, then to the search results.
122
+ // Regression guard for #625: extraction must run *after* this navigation.
123
+ try {
124
+ await page.goto(FACEBOOK_HOME);
125
+ await page.goto(`https://www.facebook.com/search/top?q=${encodeURIComponent(query)}`, { settleMs: 4000 });
126
+ } catch (err) {
127
+ throw new CommandExecutionError(
128
+ `Failed to open facebook search: ${err instanceof Error ? err.message : err}`,
129
+ 'Check that facebook.com is reachable and the Cloak browser session is running.',
130
+ );
131
+ }
132
+
133
+ let payload;
134
+ try {
135
+ payload = unwrapBrowserResult(await page.evaluate(buildSearchExtractScript(limit)));
136
+ } catch (err) {
137
+ throw new CommandExecutionError(
138
+ `Failed to read facebook search results: ${err instanceof Error ? err.message : err}`,
139
+ 'Facebook may not have rendered or the search markup may have changed.',
140
+ );
141
+ }
142
+
143
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.rows)) {
144
+ throw new CommandExecutionError('facebook search returned malformed extraction payload');
145
+ }
146
+ if (payload.status === 'auth') {
147
+ throw new AuthRequiredError('www.facebook.com', 'Log in to Facebook in the active Cloak browser session before retrying.');
148
+ }
149
+ if (payload.rows.length > 0) return payload.rows;
150
+
151
+ const d = payload.diagnostics || {};
152
+ if (d.anchorCount || d.mainTextLength > 200) {
153
+ throw new CommandExecutionError(
154
+ 'facebook search page rendered but no entity results could be extracted',
155
+ `Diagnostics: feed=${!!d.feedFound}, anchors=${d.anchorCount || 0}, mainTextLength=${d.mainTextLength || 0}.`,
156
+ );
157
+ }
158
+ throw new EmptyResultError('facebook search', `No Facebook results were visible for "${query}".`);
159
+ }
160
+
161
+ const command = {
162
+ site: 'facebook',
163
+ name: 'search',
164
+ access: 'read',
165
+ description: 'Search Facebook for people, pages, or posts',
166
+ domain: 'www.facebook.com',
167
+ strategy: Strategy.COOKIE,
168
+ browser: true,
169
+ navigateBefore: false,
170
+ args: [
171
+ { name: 'query', required: true, positional: true, help: 'Search query' },
172
+ { name: 'limit', type: 'int', default: 10, help: 'Number of results' },
173
+ ],
174
+ columns: ['index', 'title', 'text', 'url'],
175
+ func: searchFacebook,
176
+ };
177
+
178
+ cli(command);
179
+
180
+ export const __test__ = {
181
+ buildSearchExtractScript,
182
+ command,
183
+ searchFacebook,
184
+ requireLimit,
185
+ requireQuery,
186
+ };
@@ -1,55 +1,100 @@
1
- /**
2
- * Regression test for issue #625.
3
- * Facebook search must navigate in the pipeline before DOM extraction.
4
- */
5
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
6
4
  import { getRegistry } from '@agentrhq/webcmd/registry';
7
- import { executePipeline } from '@agentrhq/webcmd/pipeline';
8
- // Import the adapter to register it
9
5
  import './search.js';
10
- /**
11
- * Minimal browser mock for pipeline execution tests.
12
- * Only methods touched by this adapter path are implemented.
13
- */
14
- function createMockPage() {
15
- return {
16
- goto: vi.fn(),
17
- evaluate: vi.fn().mockResolvedValue([]),
18
- getCookies: vi.fn().mockResolvedValue([]),
19
- snapshot: vi.fn().mockResolvedValue(''),
20
- click: vi.fn(),
21
- typeText: vi.fn(),
22
- pressKey: vi.fn(),
23
- scrollTo: vi.fn(),
24
- getFormState: vi.fn().mockResolvedValue({}),
25
- wait: vi.fn(),
26
- tabs: vi.fn().mockResolvedValue([]),
27
- selectTab: vi.fn(),
28
- networkRequests: vi.fn().mockResolvedValue([]),
29
- consoleMessages: vi.fn().mockResolvedValue(''),
30
- scroll: vi.fn(),
31
- autoScroll: vi.fn(),
32
- installInterceptor: vi.fn(),
33
- getInterceptedRequests: vi.fn().mockResolvedValue([]),
34
- waitForCapture: vi.fn().mockResolvedValue(undefined),
35
- screenshot: vi.fn().mockResolvedValue(''),
36
- };
6
+
7
+ function createPage(payload) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn().mockResolvedValue(payload),
11
+ };
37
12
  }
38
- describe('facebook search pipeline', () => {
39
- it('navigates to search results before extracting DOM data', async () => {
40
- const cmd = getRegistry().get('facebook/search');
41
- expect(cmd).toBeDefined();
42
- const pipeline = cmd.pipeline ?? [];
43
- const page = createMockPage();
44
- await executePipeline(page, pipeline, {
45
- args: { query: 'AI agent', limit: 3 },
46
- });
47
- expect(page.goto).toHaveBeenNthCalledWith(1, 'https://www.facebook.com');
48
- expect(page.goto).toHaveBeenNthCalledWith(2, 'https://www.facebook.com/search/top?q=AI%20agent', {
49
- waitUntil: undefined,
50
- settleMs: 4000,
51
- });
52
- expect(page.evaluate).toHaveBeenCalledTimes(1);
53
- expect(String(page.evaluate.mock.calls[0]?.[0] ?? '')).not.toContain('window.location.href');
54
- });
13
+
14
+ function createDomPage(html, url = 'https://www.facebook.com/search/top?q=ai') {
15
+ const dom = new JSDOM(html, { url });
16
+ return {
17
+ goto: vi.fn().mockResolvedValue(undefined),
18
+ evaluate: vi.fn().mockImplementation((script) => (
19
+ Function('window', 'document', `return ${script};`)(dom.window, dom.window.document)
20
+ )),
21
+ };
22
+ }
23
+
24
+ function searchCommand() {
25
+ return getRegistry().get('facebook/search');
26
+ }
27
+
28
+ describe('facebook search', () => {
29
+ it('registers a function command with the existing row contract', () => {
30
+ const command = searchCommand();
31
+ expect(command).toBeDefined();
32
+ expect(command.columns).toEqual(['index', 'title', 'text', 'url']);
33
+ expect(command.func).toBeTypeOf('function');
34
+ });
35
+
36
+ it('navigates home then to search results before extracting', async () => {
37
+ const page = createPage({ status: 'ok', rows: [{ index: 1, title: 'X', text: 'x', url: 'https://www.facebook.com/x' }] });
38
+ await searchCommand().func(page, { query: 'AI agent', limit: 3 });
39
+
40
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://www.facebook.com');
41
+ expect(page.goto).toHaveBeenNthCalledWith(2, 'https://www.facebook.com/search/top?q=AI%20agent', { settleMs: 4000 });
42
+ expect(String(page.evaluate.mock.calls[0]?.[0] ?? '')).not.toContain('window.location.href');
43
+ });
44
+
45
+ it('extracts feed links while dropping search, chrome, and obfuscated decoys', async () => {
46
+ const page = createDomPage(`
47
+ <div role="feed">
48
+ <div><a role="link" href="https://www.facebook.com/carol.page">Carol's Page</a><span>Public figure · 12K followers</span></div>
49
+ <div><a role="link" href="https://www.facebook.com/groups/1234567/">AI Builders Group</a><span>Group · 3K members</span></div>
50
+ <div><a role="link" href="https://www.facebook.com/dave/posts/9988">Dave's post about AI agents</a></div>
51
+ <a role="link" href="https://www.facebook.com/search/top?q=aaaa">See more results</a>
52
+ <a role="link" href="https://www.facebook.com/search?q=bare">Bare search decoy</a>
53
+ <a role="link" href="https://www.facebook.com/marketplace">Marketplace</a>
54
+ <a role="link" href="https://www.facebook.com/messages/t/123">Messages</a>
55
+ <a role="link" href="https://evil-cdn.com/x">1234567890123456</a>
56
+ <a role="link" href="https://www.facebook.com/a.b.c">a b c d e f</a>
57
+ </div>
58
+ `);
59
+
60
+ const rows = await searchCommand().func(page, { query: 'ai', limit: 10 });
61
+ expect(rows.map((row) => row.url)).toEqual([
62
+ 'https://www.facebook.com/carol.page',
63
+ 'https://www.facebook.com/groups/1234567/',
64
+ 'https://www.facebook.com/dave/posts/9988',
65
+ ]);
66
+ expect(rows[0].title).toBe("Carol's Page");
67
+ });
68
+
69
+ it('deduplicates result URLs and honours the limit', async () => {
70
+ const page = createDomPage(`
71
+ <div role="feed">
72
+ <div><a href="https://www.facebook.com/carol.page">Carol's Page</a></div>
73
+ <div><a href="https://www.facebook.com/carol.page?ref=xyz">Carol's Page again</a></div>
74
+ <div><a href="https://www.facebook.com/erin">Erin</a></div>
75
+ </div>
76
+ `);
77
+
78
+ const rows = await searchCommand().func(page, { query: 'ai', limit: 1 });
79
+ expect(rows).toHaveLength(1);
80
+ expect(rows[0].url).toBe('https://www.facebook.com/carol.page');
81
+ });
82
+
83
+ it('validates query and limit before navigation', async () => {
84
+ const page = createPage({ status: 'ok', rows: [] });
85
+ await expect(searchCommand().func(page, { query: ' ', limit: 3 })).rejects.toBeInstanceOf(ArgumentError);
86
+ await expect(searchCommand().func(page, { query: 'ok', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
87
+ expect(page.goto).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('maps auth, empty, drift, and malformed payloads to typed errors', async () => {
91
+ await expect(searchCommand().func(createPage({ status: 'auth', rows: [] }), { query: 'q', limit: 1 }))
92
+ .rejects.toBeInstanceOf(AuthRequiredError);
93
+ await expect(searchCommand().func(createPage({ status: 'no_rows', rows: [], diagnostics: {} }), { query: 'q', limit: 1 }))
94
+ .rejects.toBeInstanceOf(EmptyResultError);
95
+ await expect(searchCommand().func(createPage({ status: 'no_rows', rows: [], diagnostics: { anchorCount: 40, mainTextLength: 800 } }), { query: 'q', limit: 1 }))
96
+ .rejects.toBeInstanceOf(CommandExecutionError);
97
+ await expect(searchCommand().func(createPage({ rows: null }), { query: 'q', limit: 1 }))
98
+ .rejects.toBeInstanceOf(CommandExecutionError);
99
+ });
55
100
  });
@@ -22,19 +22,37 @@ cli({
22
22
  );
23
23
  if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');
24
24
  const data = await res.json();
25
- const posts = [];
26
- for (const sec of (data?.sectional_items || [])) {
27
- for (const m of (sec?.layout_content?.medias || [])) {
28
- const media = m?.media;
29
- if (media) posts.push({
30
- user: media.user?.username || '',
31
- caption: (media.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),
32
- likes: media.like_count ?? 0,
33
- comments: media.comment_count ?? 0,
34
- type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel',
35
- });
25
+
26
+ const seen = new Set();
27
+ const medias = [];
28
+ const collect = (node, depth) => {
29
+ if (!node || typeof node !== 'object' || depth > 8) return;
30
+ if (Array.isArray(node)) {
31
+ for (const item of node) collect(item, depth + 1);
32
+ return;
36
33
  }
37
- }
34
+ const media = node.media;
35
+ if (media && typeof media === 'object' && !Array.isArray(media)) {
36
+ const key = media.pk ?? media.id ?? media.code;
37
+ if (key != null && !seen.has(key)) {
38
+ seen.add(key);
39
+ medias.push(media);
40
+ }
41
+ }
42
+ for (const [key, value] of Object.entries(node)) {
43
+ if (key === 'media') continue;
44
+ if (value && typeof value === 'object') collect(value, depth + 1);
45
+ }
46
+ };
47
+ for (const section of (data?.sectional_items || [])) collect(section, 0);
48
+
49
+ const posts = medias.map((media) => ({
50
+ user: media.user?.username || '',
51
+ caption: (media.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),
52
+ likes: media.like_count ?? media.play_count ?? 0,
53
+ comments: media.comment_count ?? 0,
54
+ type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel',
55
+ }));
38
56
  return posts.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p }));
39
57
  })()
40
58
  ` },
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@agentrhq/webcmd/registry';
3
+ import './explore.js';
4
+
5
+ async function runExplore(payload, limit = 20) {
6
+ const command = getRegistry().get('instagram/explore');
7
+ const source = command.pipeline.find((step) => step.evaluate).evaluate
8
+ .replace('${{ args.limit }}', String(limit));
9
+ const originalFetch = globalThis.fetch;
10
+ globalThis.fetch = vi.fn().mockResolvedValue({
11
+ ok: true,
12
+ json: vi.fn().mockResolvedValue(payload),
13
+ });
14
+ try {
15
+ return await eval(source);
16
+ } finally {
17
+ globalThis.fetch = originalFetch;
18
+ }
19
+ }
20
+
21
+ describe('instagram explore', () => {
22
+ it('collects distinct identified media from nested Explore layouts', async () => {
23
+ const photo = {
24
+ pk: 'photo-1',
25
+ user: { username: 'photographer' },
26
+ caption: { text: 'First\nphoto' },
27
+ like_count: 12,
28
+ comment_count: 3,
29
+ media_type: 1,
30
+ };
31
+ const reel = {
32
+ id: 'reel-2',
33
+ user: { username: 'filmmaker' },
34
+ caption: { text: 'Nested reel' },
35
+ play_count: 99,
36
+ comment_count: 4,
37
+ media_type: 2,
38
+ };
39
+ const payload = {
40
+ sectional_items: [
41
+ { layout_content: { medias: [{ media: photo }] } },
42
+ { layout_content: { one_by_two_item: { clips: { items: [{ media: reel }] } } } },
43
+ { layout_content: { fill_items: [{ media: photo }] } },
44
+ {
45
+ layout_content: {
46
+ fill_items: [{
47
+ media: {
48
+ user: { username: 'missing-identity' },
49
+ like_count: 1,
50
+ media_type: 1,
51
+ },
52
+ }],
53
+ },
54
+ },
55
+ ],
56
+ };
57
+
58
+ await expect(runExplore(payload)).resolves.toEqual([
59
+ {
60
+ rank: 1,
61
+ user: 'photographer',
62
+ caption: 'First photo',
63
+ likes: 12,
64
+ comments: 3,
65
+ type: 'photo',
66
+ },
67
+ {
68
+ rank: 2,
69
+ user: 'filmmaker',
70
+ caption: 'Nested reel',
71
+ likes: 99,
72
+ comments: 4,
73
+ type: 'video',
74
+ },
75
+ ]);
76
+ });
77
+ });
@@ -1403,7 +1403,20 @@ cli({
1403
1403
  strategy: Strategy.UI,
1404
1404
  browser: true,
1405
1405
  args: [
1406
- { name: 'media', required: false, valueRequired: true, help: `Comma-separated media paths (images/videos, up to ${MAX_MEDIA_ITEMS})` },
1406
+ {
1407
+ name: 'media',
1408
+ required: false,
1409
+ valueRequired: true,
1410
+ help: `Comma-separated media paths (images/videos, up to ${MAX_MEDIA_ITEMS})`,
1411
+ file: {
1412
+ direction: 'input',
1413
+ pathKind: 'file',
1414
+ multiple: true,
1415
+ separator: ',',
1416
+ contentTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'],
1417
+ maxBytes: 262_144_000,
1418
+ },
1419
+ },
1407
1420
  { name: 'content', positional: true, required: false, help: 'Caption text' },
1408
1421
  { name: 'timeout', type: 'int', required: false, default: 300, help: 'Max seconds for the overall command (default: 300)' },
1409
1422
  ],
@@ -738,7 +738,19 @@ cli({
738
738
  strategy: Strategy.UI,
739
739
  browser: true,
740
740
  args: [
741
- { name: 'video', required: false, valueRequired: true, help: 'Path to a single .mp4 video file' },
741
+ {
742
+ name: 'video',
743
+ required: false,
744
+ valueRequired: true,
745
+ help: 'Path to a single .mp4 video file',
746
+ file: {
747
+ direction: 'input',
748
+ pathKind: 'file',
749
+ multiple: false,
750
+ contentTypes: ['video/mp4'],
751
+ maxBytes: 262_144_000,
752
+ },
753
+ },
742
754
  { name: 'content', positional: true, required: false, help: 'Caption text' },
743
755
  { name: 'timeout', type: 'int', required: false, default: INSTAGRAM_REEL_TIMEOUT_SECONDS, help: `Max seconds for the overall command (default: ${INSTAGRAM_REEL_TIMEOUT_SECONDS})` },
744
756
  ],