@jackwener/opencli 1.5.8 → 1.6.0

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 (220) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +35 -1
  3. package/README.zh-CN.md +17 -1
  4. package/SKILL.md +31 -851
  5. package/autoresearch/baseline-browse.txt +1 -0
  6. package/autoresearch/baseline-skill.txt +1 -0
  7. package/autoresearch/browse-tasks.json +688 -0
  8. package/autoresearch/eval-browse.ts +185 -0
  9. package/autoresearch/eval-skill.ts +248 -0
  10. package/autoresearch/run-browse.sh +9 -0
  11. package/autoresearch/run-skill.sh +9 -0
  12. package/dist/browser/base-page.d.ts +48 -0
  13. package/dist/browser/base-page.js +160 -0
  14. package/dist/browser/cdp.js +4 -106
  15. package/dist/browser/daemon-client.d.ts +20 -7
  16. package/dist/browser/daemon-client.js +39 -39
  17. package/dist/browser/daemon-client.test.js +77 -0
  18. package/dist/browser/discover.d.ts +1 -4
  19. package/dist/browser/discover.js +9 -23
  20. package/dist/browser/errors.d.ts +4 -0
  21. package/dist/browser/errors.js +20 -0
  22. package/dist/browser/index.d.ts +1 -1
  23. package/dist/browser/index.js +1 -1
  24. package/dist/browser/page.d.ts +10 -35
  25. package/dist/browser/page.js +55 -187
  26. package/dist/browser/tabs.js +5 -5
  27. package/dist/browser.test.js +15 -15
  28. package/dist/cli-manifest.json +294 -22
  29. package/dist/cli.js +392 -0
  30. package/dist/clis/amazon/bestsellers.d.ts +21 -0
  31. package/dist/clis/amazon/bestsellers.js +130 -0
  32. package/dist/clis/amazon/bestsellers.test.js +20 -0
  33. package/dist/clis/amazon/discussion.d.ts +20 -0
  34. package/dist/clis/amazon/discussion.js +91 -0
  35. package/dist/clis/amazon/discussion.test.d.ts +1 -0
  36. package/dist/clis/amazon/discussion.test.js +36 -0
  37. package/dist/clis/amazon/offer.d.ts +23 -0
  38. package/dist/clis/amazon/offer.js +140 -0
  39. package/dist/clis/amazon/offer.test.d.ts +1 -0
  40. package/dist/clis/amazon/offer.test.js +29 -0
  41. package/dist/clis/amazon/product.d.ts +18 -0
  42. package/dist/clis/amazon/product.js +92 -0
  43. package/dist/clis/amazon/product.test.d.ts +1 -0
  44. package/dist/clis/amazon/product.test.js +24 -0
  45. package/dist/clis/amazon/search.d.ts +18 -0
  46. package/dist/clis/amazon/search.js +87 -0
  47. package/dist/clis/amazon/search.test.d.ts +1 -0
  48. package/dist/clis/amazon/search.test.js +22 -0
  49. package/dist/clis/amazon/shared.d.ts +64 -0
  50. package/dist/clis/amazon/shared.js +255 -0
  51. package/dist/clis/amazon/shared.test.d.ts +1 -0
  52. package/dist/clis/amazon/shared.test.js +33 -0
  53. package/dist/clis/gemini/ask.d.ts +1 -0
  54. package/dist/clis/gemini/ask.js +40 -0
  55. package/dist/clis/gemini/image.d.ts +1 -0
  56. package/dist/clis/gemini/image.js +105 -0
  57. package/dist/clis/gemini/new.d.ts +1 -0
  58. package/dist/clis/gemini/new.js +20 -0
  59. package/dist/clis/gemini/utils.d.ts +34 -0
  60. package/dist/clis/gemini/utils.js +463 -0
  61. package/dist/clis/gemini/utils.test.d.ts +1 -0
  62. package/dist/clis/gemini/utils.test.js +31 -0
  63. package/dist/clis/notebooklm/compat.test.d.ts +1 -1
  64. package/dist/clis/notebooklm/compat.test.js +3 -3
  65. package/dist/clis/notebooklm/current.js +2 -3
  66. package/dist/clis/notebooklm/get.js +2 -3
  67. package/dist/clis/notebooklm/history.js +2 -3
  68. package/dist/clis/notebooklm/note-list.js +2 -3
  69. package/dist/clis/notebooklm/notes-get.js +2 -3
  70. package/dist/clis/notebooklm/open.d.ts +1 -0
  71. package/dist/clis/notebooklm/open.js +41 -0
  72. package/dist/clis/notebooklm/open.test.d.ts +1 -0
  73. package/dist/clis/notebooklm/open.test.js +63 -0
  74. package/dist/clis/notebooklm/source-fulltext.js +2 -3
  75. package/dist/clis/notebooklm/source-get.js +2 -3
  76. package/dist/clis/notebooklm/source-guide.js +2 -3
  77. package/dist/clis/notebooklm/source-list.js +2 -3
  78. package/dist/clis/notebooklm/status.js +1 -2
  79. package/dist/clis/notebooklm/summary.js +2 -3
  80. package/dist/clis/notebooklm/utils.d.ts +2 -1
  81. package/dist/clis/notebooklm/utils.js +20 -21
  82. package/dist/clis/twitter/article.js +28 -1
  83. package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
  84. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
  85. package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
  86. package/dist/clis/xiaohongshu/note.js +11 -0
  87. package/dist/clis/xiaohongshu/note.test.js +49 -0
  88. package/dist/commanderAdapter.js +7 -4
  89. package/dist/commanderAdapter.test.js +76 -0
  90. package/dist/commands/daemon.js +8 -47
  91. package/dist/commands/daemon.test.js +45 -70
  92. package/dist/discovery.js +27 -0
  93. package/dist/doctor.d.ts +1 -2
  94. package/dist/doctor.js +7 -8
  95. package/dist/explore.js +1 -1
  96. package/dist/output.js +28 -0
  97. package/dist/output.test.js +15 -0
  98. package/dist/pipeline/executor.js +2 -7
  99. package/dist/pipeline/steps/browser.js +1 -1
  100. package/dist/pipeline/template.js +25 -3
  101. package/dist/record.d.ts +50 -0
  102. package/dist/record.js +298 -57
  103. package/dist/record.test.d.ts +1 -0
  104. package/dist/record.test.js +293 -0
  105. package/dist/registry.d.ts +2 -0
  106. package/dist/registry.js +1 -0
  107. package/dist/registry.test.js +10 -0
  108. package/dist/runtime.js +3 -3
  109. package/dist/snapshotFormatter.d.ts +1 -1
  110. package/dist/snapshotFormatter.js +4 -4
  111. package/dist/snapshotFormatter.test.d.ts +1 -1
  112. package/dist/snapshotFormatter.test.js +2 -2
  113. package/dist/types.d.ts +11 -1
  114. package/dist/types.js +1 -1
  115. package/docs/.vitepress/config.mts +2 -0
  116. package/docs/adapters/browser/amazon.md +53 -0
  117. package/docs/adapters/browser/gemini.md +72 -0
  118. package/docs/adapters/browser/notebooklm.md +5 -5
  119. package/docs/adapters/index.md +3 -1
  120. package/docs/guide/getting-started.md +21 -0
  121. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  122. package/docs/zh/guide/getting-started.md +21 -0
  123. package/extension/package-lock.json +2 -2
  124. package/extension/src/background.test.ts +7 -163
  125. package/extension/src/background.ts +58 -161
  126. package/extension/src/cdp.ts +77 -124
  127. package/extension/src/protocol.ts +5 -5
  128. package/package.json +1 -1
  129. package/skills/opencli-explorer/SKILL.md +853 -0
  130. package/skills/opencli-oneshot/SKILL.md +222 -0
  131. package/skills/opencli-operate/SKILL.md +213 -0
  132. package/skills/opencli-usage/SKILL.md +152 -0
  133. package/skills/opencli-usage/browser.md +429 -0
  134. package/skills/opencli-usage/desktop.md +118 -0
  135. package/skills/opencli-usage/plugins.md +82 -0
  136. package/skills/opencli-usage/public-api.md +149 -0
  137. package/src/browser/base-page.ts +197 -0
  138. package/src/browser/cdp.ts +7 -131
  139. package/src/browser/daemon-client.test.ts +103 -0
  140. package/src/browser/daemon-client.ts +55 -43
  141. package/src/browser/discover.ts +9 -21
  142. package/src/browser/errors.ts +22 -0
  143. package/src/browser/index.ts +1 -1
  144. package/src/browser/page.ts +57 -209
  145. package/src/browser/tabs.ts +5 -5
  146. package/src/browser.test.ts +15 -15
  147. package/src/cli.ts +392 -0
  148. package/src/clis/amazon/bestsellers.test.ts +22 -0
  149. package/src/clis/amazon/bestsellers.ts +180 -0
  150. package/src/clis/amazon/discussion.test.ts +38 -0
  151. package/src/clis/amazon/discussion.ts +131 -0
  152. package/src/clis/amazon/offer.test.ts +35 -0
  153. package/src/clis/amazon/offer.ts +185 -0
  154. package/src/clis/amazon/product.test.ts +26 -0
  155. package/src/clis/amazon/product.ts +131 -0
  156. package/src/clis/amazon/search.test.ts +24 -0
  157. package/src/clis/amazon/search.ts +128 -0
  158. package/src/clis/amazon/shared.test.ts +37 -0
  159. package/src/clis/amazon/shared.ts +316 -0
  160. package/src/clis/gemini/ask.ts +46 -0
  161. package/src/clis/gemini/image.ts +115 -0
  162. package/src/clis/gemini/new.ts +22 -0
  163. package/src/clis/gemini/utils.test.ts +36 -0
  164. package/src/clis/gemini/utils.ts +523 -0
  165. package/src/clis/notebooklm/compat.test.ts +3 -3
  166. package/src/clis/notebooklm/current.ts +2 -3
  167. package/src/clis/notebooklm/get.ts +1 -3
  168. package/src/clis/notebooklm/history.ts +1 -3
  169. package/src/clis/notebooklm/note-list.ts +1 -3
  170. package/src/clis/notebooklm/notes-get.ts +1 -3
  171. package/src/clis/notebooklm/open.test.ts +78 -0
  172. package/src/clis/notebooklm/open.ts +61 -0
  173. package/src/clis/notebooklm/source-fulltext.ts +1 -3
  174. package/src/clis/notebooklm/source-get.ts +1 -3
  175. package/src/clis/notebooklm/source-guide.ts +1 -3
  176. package/src/clis/notebooklm/source-list.ts +1 -3
  177. package/src/clis/notebooklm/status.ts +1 -2
  178. package/src/clis/notebooklm/summary.ts +1 -3
  179. package/src/clis/notebooklm/utils.ts +29 -20
  180. package/src/clis/twitter/article.ts +31 -1
  181. package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
  182. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
  183. package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
  184. package/src/clis/xiaohongshu/note.test.ts +51 -0
  185. package/src/clis/xiaohongshu/note.ts +18 -0
  186. package/src/commanderAdapter.test.ts +109 -0
  187. package/src/commanderAdapter.ts +8 -4
  188. package/src/commands/daemon.test.ts +50 -84
  189. package/src/commands/daemon.ts +8 -56
  190. package/src/discovery.ts +22 -0
  191. package/src/doctor.ts +8 -9
  192. package/src/explore.ts +1 -1
  193. package/src/output.test.ts +17 -0
  194. package/src/output.ts +27 -0
  195. package/src/pipeline/executor.ts +2 -7
  196. package/src/pipeline/steps/browser.ts +1 -1
  197. package/src/pipeline/template.ts +27 -4
  198. package/src/record.test.ts +362 -0
  199. package/src/record.ts +341 -62
  200. package/src/registry.test.ts +12 -0
  201. package/src/registry.ts +3 -0
  202. package/src/runtime.ts +3 -3
  203. package/src/snapshotFormatter.test.ts +2 -2
  204. package/src/snapshotFormatter.ts +4 -4
  205. package/src/types.ts +11 -1
  206. package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
  207. package/.agents/workflows/cross-project-adapter-migration.md +0 -54
  208. package/dist/clis/notebooklm/bind-current.js +0 -29
  209. package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
  210. package/dist/clis/notebooklm/bind-current.test.js +0 -35
  211. package/dist/clis/notebooklm/binding.test.js +0 -44
  212. package/extension/dist/background.js +0 -819
  213. package/src/clis/notebooklm/bind-current.test.ts +0 -43
  214. package/src/clis/notebooklm/bind-current.ts +0 -36
  215. package/src/clis/notebooklm/binding.test.ts +0 -53
  216. /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
  217. /package/dist/browser/{mcp.js → bridge.js} +0 -0
  218. /package/dist/{clis/notebooklm/bind-current.d.ts → browser/daemon-client.test.d.ts} +0 -0
  219. /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/bestsellers.test.d.ts} +0 -0
  220. /package/src/browser/{mcp.ts → bridge.ts} +0 -0
@@ -0,0 +1,91 @@
1
+ import { CommandExecutionError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
4
+ function normalizeDiscussionPayload(payload) {
5
+ const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
6
+ const asin = extractAsin(payload.href ?? '') ?? null;
7
+ const averageRatingText = cleanText(payload.average_rating_text) || null;
8
+ const totalReviewCountText = cleanText(payload.total_review_count_text) || null;
9
+ const provenance = buildProvenance(sourceUrl);
10
+ return {
11
+ asin,
12
+ product_url: asin ? normalizeProductUrl(asin) : null,
13
+ discussion_url: sourceUrl,
14
+ ...provenance,
15
+ average_rating_text: averageRatingText,
16
+ average_rating_value: parseRatingValue(averageRatingText),
17
+ total_review_count_text: totalReviewCountText,
18
+ total_review_count: parseReviewCount(totalReviewCountText),
19
+ qa_urls: uniqueNonEmpty(payload.qa_links ?? []),
20
+ review_samples: (payload.review_samples ?? []).map((sample) => ({
21
+ title: trimRatingPrefix(sample.title) || null,
22
+ rating_text: cleanText(sample.rating_text) || null,
23
+ rating_value: parseRatingValue(sample.rating_text),
24
+ author: cleanText(sample.author) || null,
25
+ date_text: cleanText(sample.date_text) || null,
26
+ body: cleanText(sample.body) || null,
27
+ verified_purchase: sample.verified === true,
28
+ })),
29
+ };
30
+ }
31
+ async function readDiscussionPayload(page, input, limit) {
32
+ const url = buildDiscussionUrl(input);
33
+ const state = await gotoAndReadState(page, url, 2500, 'discussion');
34
+ assertUsableState(state, 'discussion');
35
+ return await page.evaluate(`
36
+ (() => ({
37
+ href: window.location.href,
38
+ title: document.title || '',
39
+ average_rating_text: document.querySelector('[data-hook="rating-out-of-text"]')?.textContent || '',
40
+ total_review_count_text: document.querySelector('[data-hook="total-review-count"]')?.textContent || '',
41
+ qa_links: Array.from(document.querySelectorAll('a[href*="ask/questions"]')).map((anchor) => anchor.href || ''),
42
+ review_samples: Array.from(document.querySelectorAll('[data-hook="review"]')).slice(0, ${limit}).map((card) => ({
43
+ title: card.querySelector('[data-hook="review-title"]')?.textContent || '',
44
+ rating_text:
45
+ card.querySelector('[data-hook="review-star-rating"]')?.textContent
46
+ || card.querySelector('[data-hook="cmps-review-star-rating"]')?.textContent
47
+ || '',
48
+ author: card.querySelector('.a-profile-name')?.textContent || '',
49
+ date_text: card.querySelector('[data-hook="review-date"]')?.textContent || '',
50
+ body: card.querySelector('[data-hook="review-body"]')?.textContent || '',
51
+ verified: !!card.querySelector('[data-hook="avp-badge"]'),
52
+ })),
53
+ }))()
54
+ `);
55
+ }
56
+ cli({
57
+ site: 'amazon',
58
+ name: 'discussion',
59
+ description: 'Amazon review summary and sample customer discussion from product review pages',
60
+ domain: 'amazon.com',
61
+ strategy: Strategy.COOKIE,
62
+ navigateBefore: false,
63
+ args: [
64
+ {
65
+ name: 'input',
66
+ required: true,
67
+ positional: true,
68
+ help: 'ASIN or product URL, for example B0FJS72893',
69
+ },
70
+ {
71
+ name: 'limit',
72
+ type: 'int',
73
+ default: 10,
74
+ help: 'Maximum number of review samples to return (default 10)',
75
+ },
76
+ ],
77
+ columns: ['asin', 'average_rating_value', 'total_review_count'],
78
+ func: async (page, kwargs) => {
79
+ const input = String(kwargs.input ?? '');
80
+ const limit = Math.max(1, Number(kwargs.limit) || 10);
81
+ const payload = await readDiscussionPayload(page, input, limit);
82
+ const normalized = normalizeDiscussionPayload(payload);
83
+ if (!normalized.average_rating_text && !normalized.total_review_count_text) {
84
+ throw new CommandExecutionError('amazon discussion page did not expose review summary', 'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.');
85
+ }
86
+ return [normalized];
87
+ },
88
+ });
89
+ export const __test__ = {
90
+ normalizeDiscussionPayload,
91
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './discussion.js';
3
+ describe('amazon discussion normalization', () => {
4
+ it('normalizes review summary and sample reviews', () => {
5
+ const result = __test__.normalizeDiscussionPayload({
6
+ href: 'https://www.amazon.com/product-reviews/B0FJS72893',
7
+ average_rating_text: '3.9 out of 5',
8
+ total_review_count_text: '27 global ratings',
9
+ qa_links: [],
10
+ review_samples: [
11
+ {
12
+ title: '5.0 out of 5 stars Great value and quality',
13
+ rating_text: '5.0 out of 5 stars',
14
+ author: 'GTreader2',
15
+ date_text: 'Reviewed in the United States on February 21, 2026',
16
+ body: 'Small but mighty.',
17
+ verified: true,
18
+ },
19
+ ],
20
+ });
21
+ expect(result.asin).toBe('B0FJS72893');
22
+ expect(result.average_rating_value).toBe(3.9);
23
+ expect(result.total_review_count).toBe(27);
24
+ expect(result.review_samples).toEqual([
25
+ {
26
+ title: 'Great value and quality',
27
+ rating_text: '5.0 out of 5 stars',
28
+ rating_value: 5,
29
+ author: 'GTreader2',
30
+ date_text: 'Reviewed in the United States on February 21, 2026',
31
+ body: 'Small but mighty.',
32
+ verified_purchase: true,
33
+ },
34
+ ]);
35
+ });
36
+ });
@@ -0,0 +1,23 @@
1
+ interface OfferPayload {
2
+ href?: string;
3
+ title?: string;
4
+ price_text?: string | null;
5
+ merchant_info?: string | null;
6
+ sold_by?: string | null;
7
+ ships_from_text?: string | null;
8
+ offer_link?: string | null;
9
+ review_url?: string | null;
10
+ qa_url?: string | null;
11
+ buybox_text?: string | null;
12
+ }
13
+ declare function extractShipsFrom(text: string): string | null;
14
+ declare function extractSoldBy(text: string): string | null;
15
+ declare function isDeliveryLocationBlocked(text: string | null | undefined): boolean;
16
+ declare function normalizeOfferPayload(payload: OfferPayload): Record<string, unknown>;
17
+ export declare const __test__: {
18
+ extractShipsFrom: typeof extractShipsFrom;
19
+ extractSoldBy: typeof extractSoldBy;
20
+ isDeliveryLocationBlocked: typeof isDeliveryLocationBlocked;
21
+ normalizeOfferPayload: typeof normalizeOfferPayload;
22
+ };
23
+ export {};
@@ -0,0 +1,140 @@
1
+ import { CommandExecutionError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { buildProductUrl, buildProvenance, cleanText, extractAsin, isAmazonEntity, normalizeProductUrl, PRIMARY_PRICE_SELECTORS, parsePriceText, assertUsableState, gotoAndReadState, } from './shared.js';
4
+ const OFFER_FACT_SELECTOR = [
5
+ '#sellerProfileTriggerId',
6
+ '#shipsFromSoldByInsideBuyBox_feature_div',
7
+ '#fulfillerInfoFeature_feature_div',
8
+ '#merchantInfoFeature_feature_div',
9
+ '#tabular-buybox-container',
10
+ '#merchant-info',
11
+ ].join(', ');
12
+ function collapseAdjacentWords(text) {
13
+ const parts = cleanText(text).split(' ').filter(Boolean);
14
+ const deduped = [];
15
+ for (const part of parts) {
16
+ if (deduped[deduped.length - 1] === part)
17
+ continue;
18
+ deduped.push(part);
19
+ }
20
+ return deduped.join(' ');
21
+ }
22
+ function extractShipsFrom(text) {
23
+ const normalized = cleanText(text);
24
+ const match = normalized.match(/Ships from\s+(.+?)(?=Sold by|and Fulfilled by|$)/i);
25
+ return match ? collapseAdjacentWords(match[1].replace(/Ships from/ig, '')) : null;
26
+ }
27
+ function extractSoldBy(text) {
28
+ const normalized = cleanText(text);
29
+ const match = normalized.match(/Sold by\s+(.+?)(?=and Fulfilled by|Ships from|$)/i);
30
+ return match ? collapseAdjacentWords(match[1]) : null;
31
+ }
32
+ function isDeliveryLocationBlocked(text) {
33
+ const normalized = cleanText(text).toLowerCase();
34
+ return normalized.includes('cannot be shipped to your selected delivery location')
35
+ || normalized.includes('similar items shipping to')
36
+ || normalized.includes('deliver to hong kong');
37
+ }
38
+ function normalizeOfferPayload(payload) {
39
+ const asin = extractAsin(payload.href ?? '') ?? null;
40
+ const sourceUrl = cleanText(payload.href) || buildProductUrl(payload.href ?? '');
41
+ const price = parsePriceText(payload.price_text);
42
+ const merchantInfo = cleanText(payload.merchant_info) || null;
43
+ const soldBy = cleanText(payload.sold_by)
44
+ || extractSoldBy(payload.ships_from_text ?? '')
45
+ || extractSoldBy(merchantInfo ?? '')
46
+ || null;
47
+ const shipsFrom = extractShipsFrom(payload.ships_from_text ?? '')
48
+ || extractShipsFrom(merchantInfo ?? '')
49
+ || cleanText(payload.ships_from_text)
50
+ || null;
51
+ const provenance = buildProvenance(sourceUrl);
52
+ return {
53
+ asin,
54
+ product_url: normalizeProductUrl(payload.href),
55
+ ...provenance,
56
+ price_text: price.price_text,
57
+ price_value: price.price_value,
58
+ currency: price.currency,
59
+ merchant_info_text: merchantInfo,
60
+ sold_by: soldBy,
61
+ ships_from: shipsFrom,
62
+ offer_listing_url: cleanText(payload.offer_link) || null,
63
+ review_url: cleanText(payload.review_url) || null,
64
+ qa_url: cleanText(payload.qa_url) || null,
65
+ is_amazon_sold: isAmazonEntity(soldBy),
66
+ is_amazon_fulfilled: isAmazonEntity(shipsFrom) || /fulfilled by amazon/i.test(merchantInfo ?? ''),
67
+ };
68
+ }
69
+ async function readOfferPayload(page, input) {
70
+ const url = buildProductUrl(input);
71
+ const state = await gotoAndReadState(page, url, 2500, 'offer');
72
+ assertUsableState(state, 'offer');
73
+ // Reconnecting to an existing Amazon target can surface the product page
74
+ // before the buy-box / merchant blocks are reattached to the DOM.
75
+ await page.wait({ selector: OFFER_FACT_SELECTOR, timeout: 6 }).catch(() => { });
76
+ return await page.evaluate(`
77
+ (() => ({
78
+ href: window.location.href,
79
+ title: document.title || '',
80
+ price_text: (() => {
81
+ const selectors = ${JSON.stringify(PRIMARY_PRICE_SELECTORS)};
82
+ for (const selector of selectors) {
83
+ const text = document.querySelector(selector)?.textContent || '';
84
+ if (text.trim()) return text;
85
+ }
86
+ return '';
87
+ })(),
88
+ merchant_info: document.querySelector('#merchant-info')?.textContent || '',
89
+ sold_by: document.querySelector('#sellerProfileTriggerId')?.textContent || '',
90
+ ships_from_text:
91
+ document.querySelector('#shipsFromSoldByInsideBuyBox_feature_div')?.textContent
92
+ || document.querySelector('#fulfillerInfoFeature_feature_div')?.textContent
93
+ || document.querySelector('#merchantInfoFeature_feature_div')?.textContent
94
+ || document.querySelector('#tabular-buybox-container')?.textContent
95
+ || '',
96
+ offer_link: document.querySelector('a[href*="/gp/offer-listing/"]')?.href || '',
97
+ review_url: document.querySelector('a[href*="#customerReviews"]')?.href || '',
98
+ qa_url: document.querySelector('a[href*="ask/questions"]')?.href || '',
99
+ buybox_text:
100
+ document.querySelector('#desktop_qualifiedBuyBox')?.textContent
101
+ || document.querySelector('#buybox')?.textContent
102
+ || '',
103
+ }))()
104
+ `);
105
+ }
106
+ cli({
107
+ site: 'amazon',
108
+ name: 'offer',
109
+ description: 'Amazon seller, buy box, and fulfillment facts from the product page',
110
+ domain: 'amazon.com',
111
+ strategy: Strategy.COOKIE,
112
+ navigateBefore: false,
113
+ args: [
114
+ {
115
+ name: 'input',
116
+ required: true,
117
+ positional: true,
118
+ help: 'ASIN or product URL, for example B0FJS72893',
119
+ },
120
+ ],
121
+ columns: ['asin', 'price_text', 'sold_by', 'ships_from', 'is_amazon_sold', 'is_amazon_fulfilled'],
122
+ func: async (page, kwargs) => {
123
+ const input = String(kwargs.input ?? '');
124
+ const payload = await readOfferPayload(page, input);
125
+ const normalized = normalizeOfferPayload(payload);
126
+ if (!normalized.sold_by && !normalized.ships_from && !normalized.merchant_info_text) {
127
+ if (isDeliveryLocationBlocked(payload.buybox_text)) {
128
+ throw new CommandExecutionError('amazon offer buy box is blocked by the current delivery location', 'The shared Chrome profile is not set to the target US delivery address. Switch Amazon delivery location to the requested US destination, reopen the product page, and retry.');
129
+ }
130
+ throw new CommandExecutionError('amazon offer surface did not expose seller or fulfillment facts', 'The product page may have changed. Open the product page in Chrome, make sure the buy box is visible, and retry.');
131
+ }
132
+ return [normalized];
133
+ },
134
+ });
135
+ export const __test__ = {
136
+ extractShipsFrom,
137
+ extractSoldBy,
138
+ isDeliveryLocationBlocked,
139
+ normalizeOfferPayload,
140
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './offer.js';
3
+ describe('amazon offer normalization', () => {
4
+ it('extracts sold-by and fulfillment facts from product offer text', () => {
5
+ const result = __test__.normalizeOfferPayload({
6
+ href: 'https://www.amazon.com/dp/B0FJS72893',
7
+ price_text: '$15.99',
8
+ merchant_info: '',
9
+ sold_by: 'KUATUDIRECT',
10
+ ships_from_text: 'Ships from Amazon',
11
+ offer_link: null,
12
+ review_url: 'https://www.amazon.com/dp/B0FJS72893#customerReviews',
13
+ qa_url: null,
14
+ });
15
+ expect(result.asin).toBe('B0FJS72893');
16
+ expect(result.sold_by).toBe('KUATUDIRECT');
17
+ expect(result.ships_from).toBe('Amazon');
18
+ expect(result.is_amazon_sold).toBe(false);
19
+ expect(result.is_amazon_fulfilled).toBe(true);
20
+ });
21
+ it('parses merchant info fallback text', () => {
22
+ expect(__test__.extractSoldBy('Sold by Example Seller and Fulfilled by Amazon.')).toBe('Example Seller');
23
+ expect(__test__.extractShipsFrom('Ships from Amazon')).toBe('Amazon');
24
+ });
25
+ it('detects delivery-location blocking in the buy box text', () => {
26
+ expect(__test__.isDeliveryLocationBlocked('This item cannot be shipped to your selected delivery location. Similar items shipping to Hong Kong')).toBe(true);
27
+ expect(__test__.isDeliveryLocationBlocked('Ships from Amazon')).toBe(false);
28
+ });
29
+ });
@@ -0,0 +1,18 @@
1
+ interface ProductPayload {
2
+ href?: string;
3
+ title?: string;
4
+ product_title?: string | null;
5
+ byline?: string | null;
6
+ price_text?: string | null;
7
+ rating_text?: string | null;
8
+ review_count_text?: string | null;
9
+ review_url?: string | null;
10
+ qa_url?: string | null;
11
+ bullets?: string[];
12
+ breadcrumbs?: string[];
13
+ }
14
+ declare function normalizeProductPayload(payload: ProductPayload): Record<string, unknown>;
15
+ export declare const __test__: {
16
+ normalizeProductPayload: typeof normalizeProductPayload;
17
+ };
18
+ export {};
@@ -0,0 +1,92 @@
1
+ import { CommandExecutionError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { buildProductUrl, buildProvenance, cleanText, extractAsin, PRIMARY_PRICE_SELECTORS, parsePriceText, parseRatingValue, parseReviewCount, normalizeProductUrl, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
4
+ const PRODUCT_TITLE_SELECTOR = '#productTitle, #title span, [data-feature-name="title"] h1 span';
5
+ const BYLINE_SELECTOR = '#bylineInfo, [data-feature-name="bylineInfo"] #bylineInfo';
6
+ function normalizeProductPayload(payload) {
7
+ const sourceUrl = cleanText(payload.href) || buildProductUrl(cleanText(payload.product_title) || cleanText(payload.href));
8
+ const asin = extractAsin(payload.href ?? '') ?? null;
9
+ const price = parsePriceText(payload.price_text);
10
+ const ratingText = cleanText(payload.rating_text) || null;
11
+ const reviewCountText = cleanText(payload.review_count_text) || null;
12
+ const provenance = buildProvenance(sourceUrl);
13
+ return {
14
+ asin,
15
+ title: cleanText(payload.product_title) || cleanText(payload.title) || null,
16
+ product_url: normalizeProductUrl(payload.href),
17
+ ...provenance,
18
+ brand_text: cleanText(payload.byline) || null,
19
+ price_text: price.price_text,
20
+ price_value: price.price_value,
21
+ currency: price.currency,
22
+ rating_text: ratingText,
23
+ rating_value: parseRatingValue(ratingText),
24
+ review_count_text: reviewCountText,
25
+ review_count: parseReviewCount(reviewCountText),
26
+ review_url: cleanText(payload.review_url) || null,
27
+ qa_url: cleanText(payload.qa_url) || null,
28
+ breadcrumbs: uniqueNonEmpty(payload.breadcrumbs ?? []),
29
+ bullet_points: uniqueNonEmpty(payload.bullets ?? []),
30
+ };
31
+ }
32
+ async function readProductPayload(page, input) {
33
+ const url = buildProductUrl(input);
34
+ const state = await gotoAndReadState(page, url, 2500, 'product');
35
+ assertUsableState(state, 'product');
36
+ // Amazon can report a "stable" DOM before the product title block hydrates,
37
+ // especially when reconnecting to an existing shared CDP target.
38
+ await page.wait({ selector: PRODUCT_TITLE_SELECTOR, timeout: 6 }).catch(() => { });
39
+ return await page.evaluate(`
40
+ (() => ({
41
+ href: window.location.href,
42
+ title: document.title || '',
43
+ product_title: document.querySelector(${JSON.stringify(PRODUCT_TITLE_SELECTOR)})?.textContent || '',
44
+ byline: document.querySelector(${JSON.stringify(BYLINE_SELECTOR)})?.textContent || '',
45
+ price_text: (() => {
46
+ const selectors = ${JSON.stringify(PRIMARY_PRICE_SELECTORS)};
47
+ for (const selector of selectors) {
48
+ const text = document.querySelector(selector)?.textContent || '';
49
+ if (text.trim()) return text;
50
+ }
51
+ return '';
52
+ })(),
53
+ rating_text:
54
+ document.querySelector('#acrPopover')?.getAttribute('title')
55
+ || document.querySelector('#acrPopover')?.textContent
56
+ || '',
57
+ review_count_text: document.querySelector('#acrCustomerReviewText')?.textContent || '',
58
+ review_url: document.querySelector('a[href*="#customerReviews"]')?.href || '',
59
+ qa_url: document.querySelector('a[href*="ask/questions"]')?.href || '',
60
+ bullets: Array.from(document.querySelectorAll('#feature-bullets li .a-list-item')).map((node) => node.textContent || ''),
61
+ breadcrumbs: Array.from(document.querySelectorAll('#wayfinding-breadcrumbs_feature_div a')).map((node) => node.textContent || ''),
62
+ }))()
63
+ `);
64
+ }
65
+ cli({
66
+ site: 'amazon',
67
+ name: 'product',
68
+ description: 'Amazon product page facts for candidate validation',
69
+ domain: 'amazon.com',
70
+ strategy: Strategy.COOKIE,
71
+ navigateBefore: false,
72
+ args: [
73
+ {
74
+ name: 'input',
75
+ required: true,
76
+ positional: true,
77
+ help: 'ASIN or product URL, for example B0FJS72893',
78
+ },
79
+ ],
80
+ columns: ['asin', 'title', 'price_text', 'rating_value', 'review_count'],
81
+ func: async (page, kwargs) => {
82
+ const input = String(kwargs.input ?? '');
83
+ const payload = await readProductPayload(page, input);
84
+ if (!cleanText(payload.product_title)) {
85
+ throw new CommandExecutionError('amazon product page did not expose product content', 'The product page may have changed or hit a robot check. Open the product page in Chrome and retry.');
86
+ }
87
+ return [normalizeProductPayload(payload)];
88
+ },
89
+ });
90
+ export const __test__ = {
91
+ normalizeProductPayload,
92
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './product.js';
3
+ describe('amazon product normalization', () => {
4
+ it('normalizes product facts from the product page', () => {
5
+ const result = __test__.normalizeProductPayload({
6
+ href: 'https://www.amazon.com/dp/B0FJS72893',
7
+ title: 'Amazon.com: KVTUKIAIT Desktop Shelf Organizer',
8
+ product_title: 'White Desktop Shelf Organizer for Top of Desk',
9
+ byline: 'Visit the KVTUKIAIT Store',
10
+ price_text: '$15.99',
11
+ rating_text: '3.9 out of 5 stars',
12
+ review_count_text: '27 ratings',
13
+ review_url: 'https://www.amazon.com/dp/B0FJS72893#customerReviews',
14
+ qa_url: null,
15
+ bullets: ['SPACE-SAVING DESK SHELF ORGANIZER', 'SMALL AND STYLISH AESTHETIC DECOR'],
16
+ breadcrumbs: ['Office Products', 'Desktop & Off-Surface Shelves'],
17
+ });
18
+ expect(result.asin).toBe('B0FJS72893');
19
+ expect(result.price_value).toBe(15.99);
20
+ expect(result.rating_value).toBe(3.9);
21
+ expect(result.review_count).toBe(27);
22
+ expect(result.breadcrumbs).toEqual(['Office Products', 'Desktop & Off-Surface Shelves']);
23
+ });
24
+ });
@@ -0,0 +1,18 @@
1
+ interface SearchPayload {
2
+ href?: string;
3
+ cards?: Array<{
4
+ asin?: string;
5
+ title?: string;
6
+ href?: string;
7
+ price_text?: string | null;
8
+ rating_text?: string | null;
9
+ review_count_text?: string | null;
10
+ sponsored?: boolean;
11
+ badge_texts?: string[];
12
+ }>;
13
+ }
14
+ declare function normalizeSearchCandidate(candidate: NonNullable<SearchPayload['cards']>[number], rank: number, sourceUrl: string): Record<string, unknown>;
15
+ export declare const __test__: {
16
+ normalizeSearchCandidate: typeof normalizeSearchCandidate;
17
+ };
18
+ export {};
@@ -0,0 +1,87 @@
1
+ import { CommandExecutionError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { buildProvenance, buildSearchUrl, cleanText, extractAsin, normalizeProductUrl, parsePriceText, parseRatingValue, parseReviewCount, assertUsableState, gotoAndReadState, } from './shared.js';
4
+ function normalizeSearchCandidate(candidate, rank, sourceUrl) {
5
+ const productUrl = normalizeProductUrl(candidate.href);
6
+ const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null;
7
+ const price = parsePriceText(candidate.price_text);
8
+ const ratingText = cleanText(candidate.rating_text) || null;
9
+ const reviewCountText = cleanText(candidate.review_count_text) || null;
10
+ const provenance = buildProvenance(sourceUrl);
11
+ return {
12
+ rank,
13
+ asin,
14
+ title: cleanText(candidate.title) || null,
15
+ product_url: productUrl,
16
+ ...provenance,
17
+ price_text: price.price_text,
18
+ price_value: price.price_value,
19
+ currency: price.currency,
20
+ rating_text: ratingText,
21
+ rating_value: parseRatingValue(ratingText),
22
+ review_count_text: reviewCountText,
23
+ review_count: parseReviewCount(reviewCountText),
24
+ is_sponsored: candidate.sponsored === true,
25
+ badges: (candidate.badge_texts ?? []).map((value) => cleanText(value)).filter(Boolean),
26
+ };
27
+ }
28
+ async function readSearchPayload(page, query) {
29
+ const url = buildSearchUrl(query);
30
+ const state = await gotoAndReadState(page, url, 2500, 'search');
31
+ assertUsableState(state, 'search');
32
+ return await page.evaluate(`
33
+ (() => ({
34
+ href: window.location.href,
35
+ cards: Array.from(document.querySelectorAll('[data-component-type="s-search-result"]'))
36
+ .map((card) => ({
37
+ asin: card.getAttribute('data-asin') || '',
38
+ title: card.querySelector('h2')?.textContent || '',
39
+ href: card.querySelector('a.a-link-normal[href*="/dp/"]')?.href || '',
40
+ price_text: card.querySelector('.a-price .a-offscreen')?.textContent || '',
41
+ rating_text: card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label') || '',
42
+ review_count_text: card.querySelector('a[href*="#customerReviews"]')?.textContent || '',
43
+ sponsored: /sponsored/i.test(card.innerText || ''),
44
+ badge_texts: Array.from(card.querySelectorAll('.a-badge-text')).map((node) => node.textContent || ''),
45
+ })),
46
+ }))()
47
+ `);
48
+ }
49
+ cli({
50
+ site: 'amazon',
51
+ name: 'search',
52
+ description: 'Amazon search results for product discovery and coarse filtering',
53
+ domain: 'amazon.com',
54
+ strategy: Strategy.COOKIE,
55
+ navigateBefore: false,
56
+ args: [
57
+ {
58
+ name: 'query',
59
+ required: true,
60
+ positional: true,
61
+ help: 'Search query, for example "desk shelf organizer"',
62
+ },
63
+ {
64
+ name: 'limit',
65
+ type: 'int',
66
+ default: 20,
67
+ help: 'Maximum number of results to return (default 20)',
68
+ },
69
+ ],
70
+ columns: ['rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'],
71
+ func: async (page, kwargs) => {
72
+ const query = String(kwargs.query ?? '');
73
+ const limit = Math.max(1, Number(kwargs.limit) || 20);
74
+ const payload = await readSearchPayload(page, query);
75
+ const sourceUrl = cleanText(payload.href) || buildSearchUrl(query);
76
+ const cards = (payload.cards ?? [])
77
+ .filter((card) => cleanText(card.asin) && cleanText(card.title))
78
+ .slice(0, limit);
79
+ if (cards.length === 0) {
80
+ throw new CommandExecutionError('amazon search did not expose any product cards', 'The search page may have changed or hit a robot check. Open the same query in Chrome, verify the page is visible, and retry.');
81
+ }
82
+ return cards.map((card, index) => normalizeSearchCandidate(card, index + 1, sourceUrl));
83
+ },
84
+ });
85
+ export const __test__ = {
86
+ normalizeSearchCandidate,
87
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './search.js';
3
+ describe('amazon search normalization', () => {
4
+ it('normalizes search cards into research-friendly fields', () => {
5
+ const result = __test__.normalizeSearchCandidate({
6
+ asin: 'B0FJS72893',
7
+ title: 'White Desktop Shelf Organizer for Top of Desk',
8
+ href: 'https://www.amazon.com/KVTUKIAIT-White-Desktop-Shelf-Organizer/dp/B0FJS72893/ref=sr_1_1',
9
+ price_text: '$15.99',
10
+ rating_text: '3.9 out of 5 stars, rating details',
11
+ review_count_text: '(27)',
12
+ sponsored: false,
13
+ badge_texts: ['Limited time deal'],
14
+ }, 1, 'https://www.amazon.com/s?k=desk+shelf+organizer');
15
+ expect(result.asin).toBe('B0FJS72893');
16
+ expect(result.product_url).toBe('https://www.amazon.com/dp/B0FJS72893');
17
+ expect(result.price_value).toBe(15.99);
18
+ expect(result.rating_value).toBe(3.9);
19
+ expect(result.review_count).toBe(27);
20
+ expect(result.badges).toEqual(['Limited time deal']);
21
+ });
22
+ });