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