@jackwener/opencli 1.5.7 → 1.5.9

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 (199) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +17 -1
  3. package/README.zh-CN.md +17 -1
  4. package/dist/browser/base-page.d.ts +48 -0
  5. package/dist/browser/base-page.js +160 -0
  6. package/dist/browser/cdp.js +4 -106
  7. package/dist/browser/daemon-client.d.ts +1 -7
  8. package/dist/browser/daemon-client.js +2 -9
  9. package/dist/browser/discover.d.ts +1 -4
  10. package/dist/browser/discover.js +1 -4
  11. package/dist/browser/errors.d.ts +4 -0
  12. package/dist/browser/errors.js +20 -0
  13. package/dist/browser/index.d.ts +1 -1
  14. package/dist/browser/index.js +1 -1
  15. package/dist/browser/page.d.ts +6 -35
  16. package/dist/browser/page.js +10 -189
  17. package/dist/browser/tabs.js +5 -5
  18. package/dist/browser.test.js +15 -15
  19. package/dist/cli-manifest.json +294 -22
  20. package/dist/clis/amazon/bestsellers.d.ts +21 -0
  21. package/dist/clis/amazon/bestsellers.js +130 -0
  22. package/dist/clis/amazon/bestsellers.test.js +20 -0
  23. package/dist/clis/amazon/discussion.d.ts +20 -0
  24. package/dist/clis/amazon/discussion.js +91 -0
  25. package/dist/clis/amazon/discussion.test.js +36 -0
  26. package/dist/clis/amazon/offer.d.ts +23 -0
  27. package/dist/clis/amazon/offer.js +140 -0
  28. package/dist/clis/amazon/offer.test.d.ts +1 -0
  29. package/dist/clis/amazon/offer.test.js +29 -0
  30. package/dist/clis/amazon/product.d.ts +18 -0
  31. package/dist/clis/amazon/product.js +92 -0
  32. package/dist/clis/amazon/product.test.d.ts +1 -0
  33. package/dist/clis/amazon/product.test.js +24 -0
  34. package/dist/clis/amazon/search.d.ts +18 -0
  35. package/dist/clis/amazon/search.js +87 -0
  36. package/dist/clis/amazon/search.test.d.ts +1 -0
  37. package/dist/clis/amazon/search.test.js +22 -0
  38. package/dist/clis/amazon/shared.d.ts +64 -0
  39. package/dist/clis/amazon/shared.js +255 -0
  40. package/dist/clis/amazon/shared.test.d.ts +1 -0
  41. package/dist/clis/amazon/shared.test.js +33 -0
  42. package/dist/clis/gemini/ask.d.ts +1 -0
  43. package/dist/clis/gemini/ask.js +40 -0
  44. package/dist/clis/gemini/image.d.ts +1 -0
  45. package/dist/clis/gemini/image.js +105 -0
  46. package/dist/clis/gemini/new.d.ts +1 -0
  47. package/dist/clis/gemini/new.js +20 -0
  48. package/dist/clis/gemini/utils.d.ts +34 -0
  49. package/dist/clis/gemini/utils.js +463 -0
  50. package/dist/clis/gemini/utils.test.d.ts +1 -0
  51. package/dist/clis/gemini/utils.test.js +31 -0
  52. package/dist/clis/notebooklm/compat.test.d.ts +1 -1
  53. package/dist/clis/notebooklm/compat.test.js +3 -3
  54. package/dist/clis/notebooklm/current.js +2 -3
  55. package/dist/clis/notebooklm/get.js +2 -3
  56. package/dist/clis/notebooklm/history.js +2 -3
  57. package/dist/clis/notebooklm/note-list.js +2 -3
  58. package/dist/clis/notebooklm/notes-get.js +2 -3
  59. package/dist/clis/notebooklm/open.d.ts +1 -0
  60. package/dist/clis/notebooklm/open.js +41 -0
  61. package/dist/clis/notebooklm/open.test.d.ts +1 -0
  62. package/dist/clis/notebooklm/open.test.js +63 -0
  63. package/dist/clis/notebooklm/source-fulltext.js +2 -3
  64. package/dist/clis/notebooklm/source-get.js +2 -3
  65. package/dist/clis/notebooklm/source-guide.js +2 -3
  66. package/dist/clis/notebooklm/source-list.js +2 -3
  67. package/dist/clis/notebooklm/status.js +1 -2
  68. package/dist/clis/notebooklm/summary.js +2 -3
  69. package/dist/clis/notebooklm/utils.d.ts +2 -1
  70. package/dist/clis/notebooklm/utils.js +20 -21
  71. package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
  72. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
  73. package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
  74. package/dist/commanderAdapter.js +6 -3
  75. package/dist/commanderAdapter.test.js +33 -0
  76. package/dist/commands/daemon.js +1 -1
  77. package/dist/commands/daemon.test.js +1 -1
  78. package/dist/doctor.d.ts +1 -2
  79. package/dist/doctor.js +7 -8
  80. package/dist/explore.js +1 -1
  81. package/dist/extension-manifest-regression.test.js +1 -0
  82. package/dist/output.js +28 -0
  83. package/dist/output.test.js +15 -0
  84. package/dist/pipeline/executor.js +2 -7
  85. package/dist/pipeline/steps/browser.js +1 -1
  86. package/dist/pipeline/template.js +25 -3
  87. package/dist/record.d.ts +50 -0
  88. package/dist/record.js +298 -57
  89. package/dist/record.test.d.ts +1 -0
  90. package/dist/record.test.js +293 -0
  91. package/dist/registry.d.ts +2 -0
  92. package/dist/registry.js +1 -0
  93. package/dist/registry.test.js +10 -0
  94. package/dist/runtime.js +3 -3
  95. package/dist/snapshotFormatter.d.ts +1 -1
  96. package/dist/snapshotFormatter.js +4 -4
  97. package/dist/snapshotFormatter.test.d.ts +1 -1
  98. package/dist/snapshotFormatter.test.js +2 -2
  99. package/dist/types.d.ts +3 -1
  100. package/dist/types.js +1 -1
  101. package/docs/.vitepress/config.mts +2 -0
  102. package/docs/adapters/browser/amazon.md +53 -0
  103. package/docs/adapters/browser/gemini.md +72 -0
  104. package/docs/adapters/browser/notebooklm.md +5 -5
  105. package/docs/adapters/index.md +3 -1
  106. package/extension/dist/background.js +614 -794
  107. package/extension/manifest.json +2 -1
  108. package/extension/src/background.test.ts +7 -163
  109. package/extension/src/background.ts +7 -156
  110. package/extension/src/cdp.test.ts +75 -0
  111. package/extension/src/cdp.ts +77 -3
  112. package/extension/src/protocol.ts +1 -5
  113. package/package.json +1 -1
  114. package/skills/opencli-explorer/SKILL.md +847 -0
  115. package/skills/opencli-oneshot/SKILL.md +216 -0
  116. package/skills/opencli-usage/SKILL.md +71 -0
  117. package/skills/opencli-usage/browser.md +429 -0
  118. package/skills/opencli-usage/desktop.md +118 -0
  119. package/skills/opencli-usage/plugins.md +82 -0
  120. package/skills/opencli-usage/public-api.md +149 -0
  121. package/src/browser/base-page.ts +197 -0
  122. package/src/browser/cdp.ts +7 -131
  123. package/src/browser/daemon-client.ts +3 -14
  124. package/src/browser/discover.ts +1 -4
  125. package/src/browser/errors.ts +22 -0
  126. package/src/browser/index.ts +1 -1
  127. package/src/browser/page.ts +13 -212
  128. package/src/browser/tabs.ts +5 -5
  129. package/src/browser.test.ts +15 -15
  130. package/src/clis/amazon/bestsellers.test.ts +22 -0
  131. package/src/clis/amazon/bestsellers.ts +180 -0
  132. package/src/clis/amazon/discussion.test.ts +38 -0
  133. package/src/clis/amazon/discussion.ts +131 -0
  134. package/src/clis/amazon/offer.test.ts +35 -0
  135. package/src/clis/amazon/offer.ts +185 -0
  136. package/src/clis/amazon/product.test.ts +26 -0
  137. package/src/clis/amazon/product.ts +131 -0
  138. package/src/clis/amazon/search.test.ts +24 -0
  139. package/src/clis/amazon/search.ts +128 -0
  140. package/src/clis/amazon/shared.test.ts +37 -0
  141. package/src/clis/amazon/shared.ts +316 -0
  142. package/src/clis/gemini/ask.ts +46 -0
  143. package/src/clis/gemini/image.ts +115 -0
  144. package/src/clis/gemini/new.ts +22 -0
  145. package/src/clis/gemini/utils.test.ts +36 -0
  146. package/src/clis/gemini/utils.ts +523 -0
  147. package/src/clis/notebooklm/compat.test.ts +3 -3
  148. package/src/clis/notebooklm/current.ts +2 -3
  149. package/src/clis/notebooklm/get.ts +1 -3
  150. package/src/clis/notebooklm/history.ts +1 -3
  151. package/src/clis/notebooklm/note-list.ts +1 -3
  152. package/src/clis/notebooklm/notes-get.ts +1 -3
  153. package/src/clis/notebooklm/open.test.ts +78 -0
  154. package/src/clis/notebooklm/open.ts +61 -0
  155. package/src/clis/notebooklm/source-fulltext.ts +1 -3
  156. package/src/clis/notebooklm/source-get.ts +1 -3
  157. package/src/clis/notebooklm/source-guide.ts +1 -3
  158. package/src/clis/notebooklm/source-list.ts +1 -3
  159. package/src/clis/notebooklm/status.ts +1 -2
  160. package/src/clis/notebooklm/summary.ts +1 -3
  161. package/src/clis/notebooklm/utils.ts +29 -20
  162. package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
  163. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
  164. package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
  165. package/src/commanderAdapter.test.ts +47 -0
  166. package/src/commanderAdapter.ts +7 -3
  167. package/src/commands/daemon.test.ts +1 -1
  168. package/src/commands/daemon.ts +1 -1
  169. package/src/doctor.ts +7 -8
  170. package/src/explore.ts +1 -1
  171. package/src/extension-manifest-regression.test.ts +1 -0
  172. package/src/output.test.ts +17 -0
  173. package/src/output.ts +27 -0
  174. package/src/pipeline/executor.ts +2 -7
  175. package/src/pipeline/steps/browser.ts +1 -1
  176. package/src/pipeline/template.ts +27 -4
  177. package/src/record.test.ts +362 -0
  178. package/src/record.ts +341 -62
  179. package/src/registry.test.ts +12 -0
  180. package/src/registry.ts +3 -0
  181. package/src/runtime.ts +3 -3
  182. package/src/snapshotFormatter.test.ts +2 -2
  183. package/src/snapshotFormatter.ts +4 -4
  184. package/src/types.ts +3 -1
  185. package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
  186. package/.agents/workflows/cross-project-adapter-migration.md +0 -54
  187. package/SKILL.md +0 -879
  188. package/dist/clis/notebooklm/bind-current.js +0 -29
  189. package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
  190. package/dist/clis/notebooklm/bind-current.test.js +0 -35
  191. package/dist/clis/notebooklm/binding.test.js +0 -44
  192. package/src/clis/notebooklm/bind-current.test.ts +0 -43
  193. package/src/clis/notebooklm/bind-current.ts +0 -36
  194. package/src/clis/notebooklm/binding.test.ts +0 -53
  195. /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
  196. /package/dist/browser/{mcp.js → bridge.js} +0 -0
  197. /package/dist/clis/{notebooklm/bind-current.d.ts → amazon/bestsellers.test.d.ts} +0 -0
  198. /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/discussion.test.d.ts} +0 -0
  199. /package/src/browser/{mcp.ts → bridge.ts} +0 -0
@@ -10,25 +10,13 @@
10
10
  * chrome-extension:// tab that can't be debugged.
11
11
  */
12
12
 
13
- import { formatSnapshot } from '../snapshotFormatter.js';
14
- import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
13
+ import type { BrowserCookie, ScreenshotOptions } from '../types.js';
15
14
  import { sendCommand } from './daemon-client.js';
16
15
  import { wrapForEval } from './utils.js';
17
16
  import { saveBase64ToFile } from '../utils.js';
18
- import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
19
17
  import { generateStealthJs } from './stealth.js';
20
- import {
21
- clickJs,
22
- typeTextJs,
23
- pressKeyJs,
24
- waitForTextJs,
25
- waitForCaptureJs,
26
- waitForSelectorJs,
27
- scrollJs,
28
- autoScrollJs,
29
- networkRequestsJs,
30
- waitForDomStableJs,
31
- } from './dom-helpers.js';
18
+ import { waitForDomStableJs } from './dom-helpers.js';
19
+ import { BasePage } from './base-page.js';
32
20
 
33
21
  export function isRetryableSettleError(err: unknown): boolean {
34
22
  const message = err instanceof Error ? err.message : String(err);
@@ -39,13 +27,13 @@ export function isRetryableSettleError(err: unknown): boolean {
39
27
  /**
40
28
  * Page — implements IPage by talking to the daemon via HTTP.
41
29
  */
42
- export class Page implements IPage {
43
- constructor(private readonly workspace: string = 'default') {}
30
+ export class Page extends BasePage {
31
+ constructor(private readonly workspace: string = 'default') {
32
+ super();
33
+ }
44
34
 
45
35
  /** Active tab ID, set after navigate and used in all subsequent commands */
46
36
  private _tabId: number | undefined;
47
- /** Last navigated URL, tracked in-memory to avoid extra round-trips */
48
- private _lastUrl: string | null = null;
49
37
 
50
38
  /** Helper: spread workspace into command params */
51
39
  private _wsOpt(): { workspace: string } {
@@ -107,27 +95,8 @@ export class Page implements IPage {
107
95
  }
108
96
  }
109
97
 
110
- async getCurrentUrl(): Promise<string | null> {
111
- if (this._lastUrl) return this._lastUrl;
112
- try {
113
- const current = await this.evaluate('window.location.href');
114
- if (typeof current === 'string' && current) {
115
- this._lastUrl = current;
116
- return current;
117
- }
118
- } catch {
119
- // Best-effort: some commands may run before a debuggable tab is ready.
120
- }
121
- return null;
122
- }
123
-
124
- /** Close the automation window in the extension */
125
- async closeWindow(): Promise<void> {
126
- try {
127
- await sendCommand('close-window', { ...this._wsOpt() });
128
- } catch {
129
- // Window may already be closed or daemon may be down
130
- }
98
+ getActiveTabId(): number | undefined {
99
+ return this._tabId;
131
100
  }
132
101
 
133
102
  async evaluate(js: string): Promise<unknown> {
@@ -146,124 +115,12 @@ export class Page implements IPage {
146
115
  return Array.isArray(result) ? result : [];
147
116
  }
148
117
 
149
- async snapshot(opts: SnapshotOptions = {}): Promise<unknown> {
150
- // Primary: use the advanced DOM snapshot engine with multi-layer pruning
151
- const snapshotJs = generateSnapshotJs({
152
- viewportExpand: opts.viewportExpand ?? 800,
153
- maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)),
154
- interactiveOnly: opts.interactive ?? false,
155
- maxTextLength: opts.maxTextLength ?? 120,
156
- includeScrollInfo: true,
157
- bboxDedup: true,
158
- });
159
-
118
+ /** Close the automation window in the extension */
119
+ async closeWindow(): Promise<void> {
160
120
  try {
161
- const result = await sendCommand('exec', { code: snapshotJs, ...this._cmdOpts() });
162
- // The advanced engine already produces a clean, pruned, LLM-friendly output.
163
- // Do NOT pass through formatSnapshot — its format is incompatible.
164
- return result;
121
+ await sendCommand('close-window', { ...this._wsOpt() });
165
122
  } catch {
166
- // Fallback: basic DOM snapshot (original implementation)
167
- return this._basicSnapshot(opts);
168
- }
169
- }
170
-
171
- /** Fallback basic snapshot — original buildTree approach */
172
- private async _basicSnapshot(opts: Pick<SnapshotOptions, 'interactive' | 'compact' | 'maxDepth' | 'raw'> = {}): Promise<unknown> {
173
- const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
174
- const code = `
175
- (async () => {
176
- function buildTree(node, depth) {
177
- if (depth > ${maxDepth}) return '';
178
- const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
179
- const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
180
- const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
181
-
182
- ${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
183
-
184
- let indent = ' '.repeat(depth);
185
- let line = indent + role;
186
- if (name) line += ' "' + name.replace(/"/g, '\\\\\\"') + '"';
187
- if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
188
- if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
189
-
190
- let result = line + '\\n';
191
- if (node.children) {
192
- for (const child of node.children) {
193
- result += buildTree(child, depth + 1);
194
- }
195
- }
196
- return result;
197
- }
198
- return buildTree(document.body, 0);
199
- })()
200
- `;
201
- const raw = await sendCommand('exec', { code, ...this._cmdOpts() });
202
- if (opts.raw) return raw;
203
- if (typeof raw === 'string') return formatSnapshot(raw, opts);
204
- return raw;
205
- }
206
-
207
- async click(ref: string): Promise<void> {
208
- const code = clickJs(ref);
209
- await sendCommand('exec', { code, ...this._cmdOpts() });
210
- }
211
-
212
- async typeText(ref: string, text: string): Promise<void> {
213
- const code = typeTextJs(ref, text);
214
- await sendCommand('exec', { code, ...this._cmdOpts() });
215
- }
216
-
217
- async pressKey(key: string): Promise<void> {
218
- const code = pressKeyJs(key);
219
- await sendCommand('exec', { code, ...this._cmdOpts() });
220
- }
221
-
222
- async scrollTo(ref: string): Promise<unknown> {
223
- const code = scrollToRefJs(ref);
224
- return sendCommand('exec', { code, ...this._cmdOpts() });
225
- }
226
-
227
- async getFormState(): Promise<Record<string, unknown>> {
228
- const code = getFormStateJs();
229
- return (await sendCommand('exec', { code, ...this._cmdOpts() })) as Record<string, unknown>;
230
- }
231
-
232
- async wait(options: number | WaitOptions): Promise<void> {
233
- if (typeof options === 'number') {
234
- if (options >= 1) {
235
- // For waits >= 1s, use DOM-stable check: return early when the page
236
- // stops mutating, with the original wait time as the hard cap.
237
- // This turns e.g. `page.wait(5)` from a fixed 5s sleep into
238
- // "wait until DOM is stable, max 5s" — often completing in <1s.
239
- try {
240
- const maxMs = options * 1000;
241
- await sendCommand('exec', {
242
- code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
243
- ...this._cmdOpts(),
244
- });
245
- return;
246
- } catch {
247
- // Fallback: fixed sleep (e.g. if page has no DOM yet)
248
- }
249
- }
250
- await new Promise(resolve => setTimeout(resolve, options * 1000));
251
- return;
252
- }
253
- if (typeof options.time === 'number') {
254
- await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
255
- return;
256
- }
257
- if (options.selector) {
258
- const timeout = (options.timeout ?? 10) * 1000;
259
- const code = waitForSelectorJs(options.selector, timeout);
260
- await sendCommand('exec', { code, ...this._cmdOpts() });
261
- return;
262
- }
263
- if (options.text) {
264
- const timeout = (options.timeout ?? 30) * 1000;
265
- const code = waitForTextJs(options.text, timeout);
266
- await sendCommand('exec', { code, ...this._cmdOpts() });
123
+ // Window may already be closed or daemon may be down
267
124
  }
268
125
  }
269
126
 
@@ -289,27 +146,8 @@ export class Page implements IPage {
289
146
  if (result?.selected) this._tabId = result.selected;
290
147
  }
291
148
 
292
- async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
293
- const code = networkRequestsJs(includeStatic);
294
- const result = await sendCommand('exec', { code, ...this._cmdOpts() });
295
- return Array.isArray(result) ? result : [];
296
- }
297
-
298
- /**
299
- * Console messages are not available in lightweight daemon mode.
300
- * Would require CDP Runtime.consoleAPICalled event listener.
301
- * @returns Always returns empty array.
302
- */
303
- async consoleMessages(_level: string = 'info'): Promise<unknown[]> {
304
- return [];
305
- }
306
-
307
149
  /**
308
150
  * Capture a screenshot via CDP Page.captureScreenshot.
309
- * @param options.format - 'png' (default) or 'jpeg'
310
- * @param options.quality - JPEG quality 0-100
311
- * @param options.fullPage - capture full scrollable page
312
- * @param options.path - save to file path (returns base64 if omitted)
313
151
  */
314
152
  async screenshot(options: ScreenshotOptions = {}): Promise<string> {
315
153
  const base64 = await sendCommand('screenshot', {
@@ -326,35 +164,6 @@ export class Page implements IPage {
326
164
  return base64;
327
165
  }
328
166
 
329
- async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
330
- const code = scrollJs(direction, amount);
331
- await sendCommand('exec', { code, ...this._cmdOpts() });
332
- }
333
-
334
- async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
335
- const times = options.times ?? 3;
336
- const delayMs = options.delayMs ?? 2000;
337
- const code = autoScrollJs(times, delayMs);
338
- await sendCommand('exec', { code, ...this._cmdOpts() });
339
- }
340
-
341
- async installInterceptor(pattern: string): Promise<void> {
342
- const { generateInterceptorJs } = await import('../interceptor.js');
343
- // Must use evaluate() so wrapForEval() converts the arrow function into an IIFE;
344
- // sendCommand('exec') sends the code as-is, and CDP never executes a bare arrow.
345
- await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
346
- arrayName: '__opencli_xhr',
347
- patchGuard: '__opencli_interceptor_patched',
348
- }));
349
- }
350
-
351
- async getInterceptedRequests(): Promise<unknown[]> {
352
- const { generateReadInterceptedJs } = await import('../interceptor.js');
353
- // Same as installInterceptor: must go through evaluate() for IIFE wrapping
354
- const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
355
- return Array.isArray(result) ? result : [];
356
- }
357
-
358
167
  /**
359
168
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
360
169
  * Chrome reads the files directly from the local filesystem, avoiding the
@@ -370,14 +179,6 @@ export class Page implements IPage {
370
179
  throw new Error('setFileInput returned no count — command may not be supported by the extension');
371
180
  }
372
181
  }
373
-
374
- async waitForCapture(timeout: number = 10): Promise<void> {
375
- const maxMs = timeout * 1000;
376
- await sendCommand('exec', {
377
- code: waitForCaptureJs(maxMs),
378
- ...this._cmdOpts(),
379
- });
380
- }
381
182
  }
382
183
 
383
184
  // (End of file)
@@ -21,12 +21,12 @@ export function extractTabEntries(raw: unknown): Array<{ index: number; identity
21
21
  .map(line => line.trim())
22
22
  .filter(Boolean)
23
23
  .map(line => {
24
- // Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
25
- const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
26
- if (mcpMatch) {
24
+ // Match tab list format: "- 0: (current) [title](url)" or "- 1: [title](url)"
25
+ const tabMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
26
+ if (tabMatch) {
27
27
  return {
28
- index: parseInt(mcpMatch[1], 10),
29
- identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
28
+ index: parseInt(tabMatch[1], 10),
29
+ identity: tabMatch[2].trim() || `tab-${tabMatch[1]}`,
30
30
  };
31
31
  }
32
32
  // Legacy format: "Tab 0 ..."
@@ -104,43 +104,43 @@ describe('browser helpers', () => {
104
104
 
105
105
  describe('BrowserBridge state', () => {
106
106
  it('transitions to closed after close()', async () => {
107
- const mcp = new BrowserBridge();
107
+ const bridge = new BrowserBridge();
108
108
 
109
- expect(mcp.state).toBe('idle');
109
+ expect(bridge.state).toBe('idle');
110
110
 
111
- await mcp.close();
111
+ await bridge.close();
112
112
 
113
- expect(mcp.state).toBe('closed');
113
+ expect(bridge.state).toBe('closed');
114
114
  });
115
115
 
116
116
  it('rejects connect() after the session has been closed', async () => {
117
- const mcp = new BrowserBridge();
118
- await mcp.close();
117
+ const bridge = new BrowserBridge();
118
+ await bridge.close();
119
119
 
120
- await expect(mcp.connect()).rejects.toThrow('Session is closed');
120
+ await expect(bridge.connect()).rejects.toThrow('Session is closed');
121
121
  });
122
122
 
123
123
  it('rejects connect() while already connecting', async () => {
124
- const mcp = new BrowserBridge();
125
- (mcp as any)._state = 'connecting';
124
+ const bridge = new BrowserBridge();
125
+ (bridge as any)._state = 'connecting';
126
126
 
127
- await expect(mcp.connect()).rejects.toThrow('Already connecting');
127
+ await expect(bridge.connect()).rejects.toThrow('Already connecting');
128
128
  });
129
129
 
130
130
  it('rejects connect() while closing', async () => {
131
- const mcp = new BrowserBridge();
132
- (mcp as any)._state = 'closing';
131
+ const bridge = new BrowserBridge();
132
+ (bridge as any)._state = 'closing';
133
133
 
134
- await expect(mcp.connect()).rejects.toThrow('Session is closing');
134
+ await expect(bridge.connect()).rejects.toThrow('Session is closing');
135
135
  });
136
136
 
137
137
  it('fails fast when daemon is running but extension is disconnected', async () => {
138
138
  vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
139
139
  vi.spyOn(daemonClient, 'isDaemonRunning').mockResolvedValue(true);
140
140
 
141
- const mcp = new BrowserBridge();
141
+ const bridge = new BrowserBridge();
142
142
 
143
- await expect(mcp.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected');
143
+ await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected');
144
144
  });
145
145
  });
146
146
 
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './bestsellers.js';
3
+
4
+ describe('amazon bestsellers normalization', () => {
5
+ it('normalizes bestseller cards and infers review counts from card text', () => {
6
+ const result = __test__.normalizeBestsellerCandidate({
7
+ asin: 'B0DR31GC3D',
8
+ title: '',
9
+ href: 'https://www.amazon.com/NUTIKAS-Shelves-Desktop-Orgnizer-Shlef/dp/B0DR31GC3D/ref=zg_bs',
10
+ price_text: '$25.92',
11
+ rating_text: '4.3 out of 5 stars',
12
+ review_count_text: '',
13
+ card_text: 'Desk Shelves Desktop Organizer Shlef\n4.3 out of 5 stars\n435\n$25.92',
14
+ }, 2, 'Amazon Best Sellers: Best Desktop & Off-Surface Shelves', 'https://www.amazon.com/example');
15
+
16
+ expect(result.rank).toBe(2);
17
+ expect(result.asin).toBe('B0DR31GC3D');
18
+ expect(result.title).toBe('Desk Shelves Desktop Organizer Shlef');
19
+ expect(result.review_count).toBe(435);
20
+ expect(result.list_title).toBe('Amazon Best Sellers: Best Desktop & Off-Surface Shelves');
21
+ });
22
+ });
@@ -0,0 +1,180 @@
1
+ import { CommandExecutionError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import type { IPage } from '../../types.js';
4
+ import {
5
+ buildProvenance,
6
+ cleanText,
7
+ extractAsin,
8
+ extractReviewCountFromCardText,
9
+ firstMeaningfulLine,
10
+ normalizeProductUrl,
11
+ parsePriceText,
12
+ parseRatingValue,
13
+ parseReviewCount,
14
+ resolveBestsellersUrl,
15
+ uniqueNonEmpty,
16
+ assertUsableState,
17
+ gotoAndReadState,
18
+ } from './shared.js';
19
+
20
+ interface BestsellersPagePayload {
21
+ href?: string;
22
+ title?: string;
23
+ list_title?: string;
24
+ cards?: Array<{
25
+ rank_text?: string | null;
26
+ asin?: string | null;
27
+ title?: string | null;
28
+ href?: string | null;
29
+ price_text?: string | null;
30
+ rating_text?: string | null;
31
+ review_count_text?: string | null;
32
+ card_text?: string | null;
33
+ }>;
34
+ page_links?: string[];
35
+ }
36
+
37
+ function normalizeBestsellerCandidate(
38
+ candidate: NonNullable<BestsellersPagePayload['cards']>[number],
39
+ rank: number,
40
+ listTitle: string | null,
41
+ sourceUrl: string,
42
+ ): Record<string, unknown> {
43
+ const productUrl = normalizeProductUrl(candidate.href);
44
+ const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null;
45
+ const title = cleanText(candidate.title) || firstMeaningfulLine(candidate.card_text);
46
+ const price = parsePriceText(cleanText(candidate.price_text) || candidate.card_text);
47
+ const ratingText = cleanText(candidate.rating_text) || null;
48
+ const reviewCountText = cleanText(candidate.review_count_text)
49
+ || extractReviewCountFromCardText(candidate.card_text)
50
+ || null;
51
+ const provenance = buildProvenance(sourceUrl);
52
+
53
+ return {
54
+ rank,
55
+ asin,
56
+ title: title || null,
57
+ product_url: productUrl,
58
+ list_title: listTitle,
59
+ ...provenance,
60
+ price_text: price.price_text,
61
+ price_value: price.price_value,
62
+ currency: price.currency,
63
+ rating_text: ratingText,
64
+ rating_value: parseRatingValue(ratingText),
65
+ review_count_text: reviewCountText,
66
+ review_count: parseReviewCount(reviewCountText),
67
+ };
68
+ }
69
+
70
+ async function readBestsellersPage(page: IPage, url: string): Promise<BestsellersPagePayload> {
71
+ const state = await gotoAndReadState(page, url, 2500, 'bestsellers');
72
+ assertUsableState(state, 'bestsellers');
73
+
74
+ return await page.evaluate(`
75
+ (() => ({
76
+ href: window.location.href,
77
+ title: document.title || '',
78
+ list_title:
79
+ document.querySelector('#zg_banner_text')?.textContent
80
+ || document.querySelector('h1')?.textContent
81
+ || '',
82
+ cards: Array.from(document.querySelectorAll('.p13n-sc-uncoverable-faceout'))
83
+ .map((card) => ({
84
+ rank_text:
85
+ card.querySelector('.zg-bdg-text')?.textContent
86
+ || card.querySelector('[class*="rank"]')?.textContent
87
+ || '',
88
+ asin: card.id || '',
89
+ title:
90
+ card.querySelector('[class*="line-clamp"]')?.textContent
91
+ || card.querySelector('img')?.getAttribute('alt')
92
+ || '',
93
+ href: card.querySelector('a[href*="/dp/"]')?.href || '',
94
+ price_text: card.querySelector('.a-price .a-offscreen')?.textContent || '',
95
+ rating_text: card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label') || '',
96
+ review_count_text:
97
+ card.querySelector('a[href*="#customerReviews"]')?.textContent
98
+ || card.querySelector('.a-size-small')?.textContent
99
+ || '',
100
+ card_text: card.innerText || '',
101
+ })),
102
+ page_links: Array.from(document.querySelectorAll('li.a-normal a, li.a-selected a'))
103
+ .map((anchor) => anchor.href || '')
104
+ .filter((href) => /\\/zgbs\\//.test(href) && /(?:[?&]pg=|ref=zg_bs_pg_)/.test(href)),
105
+ }))()
106
+ `) as BestsellersPagePayload;
107
+ }
108
+
109
+ cli({
110
+ site: 'amazon',
111
+ name: 'bestsellers',
112
+ description: 'Amazon Best Sellers pages for category candidate discovery',
113
+ domain: 'amazon.com',
114
+ strategy: Strategy.COOKIE,
115
+ navigateBefore: false,
116
+ args: [
117
+ {
118
+ name: 'input',
119
+ positional: true,
120
+ help: 'Best sellers URL or /zgbs path. Omit to use the root Best Sellers page.',
121
+ },
122
+ {
123
+ name: 'limit',
124
+ type: 'int',
125
+ default: 100,
126
+ help: 'Maximum number of ranked items to return (default 100)',
127
+ },
128
+ ],
129
+ columns: ['rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'],
130
+ func: async (page, kwargs) => {
131
+ const limit = Math.max(1, Number(kwargs.limit) || 100);
132
+ const initialUrl = resolveBestsellersUrl(typeof kwargs.input === 'string' ? kwargs.input : undefined);
133
+
134
+ const queue = [initialUrl];
135
+ const visited = new Set<string>();
136
+ const seenAsins = new Set<string>();
137
+ const results: Record<string, unknown>[] = [];
138
+ let listTitle: string | null = null;
139
+
140
+ while (queue.length > 0 && results.length < limit) {
141
+ const nextUrl = queue.shift()!;
142
+ if (visited.has(nextUrl)) continue;
143
+ visited.add(nextUrl);
144
+
145
+ const payload = await readBestsellersPage(page, nextUrl);
146
+ const sourceUrl = cleanText(payload.href) || nextUrl;
147
+ listTitle = cleanText(payload.list_title) || cleanText(payload.title) || listTitle;
148
+ const cards = payload.cards ?? [];
149
+
150
+ for (const card of cards) {
151
+ const normalized = normalizeBestsellerCandidate(card, results.length + 1, listTitle, sourceUrl);
152
+ const asin = cleanText(String(normalized.asin ?? ''));
153
+ if (!asin || seenAsins.has(asin)) continue;
154
+ seenAsins.add(asin);
155
+ results.push(normalized);
156
+ if (results.length >= limit) break;
157
+ }
158
+
159
+ const pageLinks = uniqueNonEmpty(payload.page_links ?? []);
160
+ for (const href of pageLinks) {
161
+ if (!visited.has(href) && !queue.includes(href)) {
162
+ queue.push(href);
163
+ }
164
+ }
165
+ }
166
+
167
+ if (results.length === 0) {
168
+ throw new CommandExecutionError(
169
+ 'amazon bestsellers did not expose any ranked items',
170
+ 'Open the same best sellers page in Chrome, verify it is a real Amazon ranking page, and retry.',
171
+ );
172
+ }
173
+
174
+ return results.slice(0, limit);
175
+ },
176
+ });
177
+
178
+ export const __test__ = {
179
+ normalizeBestsellerCandidate,
180
+ };
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './discussion.js';
3
+
4
+ describe('amazon discussion normalization', () => {
5
+ it('normalizes review summary and sample reviews', () => {
6
+ const result = __test__.normalizeDiscussionPayload({
7
+ href: 'https://www.amazon.com/product-reviews/B0FJS72893',
8
+ average_rating_text: '3.9 out of 5',
9
+ total_review_count_text: '27 global ratings',
10
+ qa_links: [],
11
+ review_samples: [
12
+ {
13
+ title: '5.0 out of 5 stars Great value and quality',
14
+ rating_text: '5.0 out of 5 stars',
15
+ author: 'GTreader2',
16
+ date_text: 'Reviewed in the United States on February 21, 2026',
17
+ body: 'Small but mighty.',
18
+ verified: true,
19
+ },
20
+ ],
21
+ });
22
+
23
+ expect(result.asin).toBe('B0FJS72893');
24
+ expect(result.average_rating_value).toBe(3.9);
25
+ expect(result.total_review_count).toBe(27);
26
+ expect(result.review_samples).toEqual([
27
+ {
28
+ title: 'Great value and quality',
29
+ rating_text: '5.0 out of 5 stars',
30
+ rating_value: 5,
31
+ author: 'GTreader2',
32
+ date_text: 'Reviewed in the United States on February 21, 2026',
33
+ body: 'Small but mighty.',
34
+ verified_purchase: true,
35
+ },
36
+ ]);
37
+ });
38
+ });