@jackwener/opencli 0.5.1 → 0.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 (76) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +4 -3
  3. package/SKILL.md +7 -4
  4. package/dist/browser.d.ts +7 -3
  5. package/dist/browser.js +25 -92
  6. package/dist/browser.test.js +18 -1
  7. package/dist/cascade.d.ts +1 -1
  8. package/dist/cascade.js +42 -75
  9. package/dist/cli-manifest.json +80 -0
  10. package/dist/clis/coupang/add-to-cart.d.ts +1 -0
  11. package/dist/clis/coupang/add-to-cart.js +141 -0
  12. package/dist/clis/coupang/search.d.ts +1 -0
  13. package/dist/clis/coupang/search.js +453 -0
  14. package/dist/constants.d.ts +13 -0
  15. package/dist/constants.js +30 -0
  16. package/dist/coupang.d.ts +24 -0
  17. package/dist/coupang.js +262 -0
  18. package/dist/coupang.test.d.ts +1 -0
  19. package/dist/coupang.test.js +62 -0
  20. package/dist/doctor.d.ts +15 -0
  21. package/dist/doctor.js +226 -25
  22. package/dist/doctor.test.js +13 -6
  23. package/dist/engine.js +3 -3
  24. package/dist/engine.test.d.ts +4 -0
  25. package/dist/engine.test.js +67 -0
  26. package/dist/explore.js +1 -15
  27. package/dist/interceptor.d.ts +42 -0
  28. package/dist/interceptor.js +138 -0
  29. package/dist/main.js +8 -4
  30. package/dist/output.js +0 -5
  31. package/dist/pipeline/steps/intercept.js +4 -54
  32. package/dist/pipeline/steps/tap.js +11 -51
  33. package/dist/registry.d.ts +3 -1
  34. package/dist/registry.test.d.ts +4 -0
  35. package/dist/registry.test.js +90 -0
  36. package/dist/runtime.d.ts +15 -1
  37. package/dist/runtime.js +11 -6
  38. package/dist/setup.d.ts +4 -0
  39. package/dist/setup.js +145 -0
  40. package/dist/synthesize.js +5 -5
  41. package/dist/tui.d.ts +22 -0
  42. package/dist/tui.js +139 -0
  43. package/dist/validate.js +21 -0
  44. package/dist/verify.d.ts +7 -0
  45. package/dist/verify.js +7 -1
  46. package/dist/version.d.ts +4 -0
  47. package/dist/version.js +16 -0
  48. package/package.json +1 -1
  49. package/src/browser.test.ts +20 -1
  50. package/src/browser.ts +25 -87
  51. package/src/cascade.ts +47 -75
  52. package/src/clis/coupang/add-to-cart.ts +149 -0
  53. package/src/clis/coupang/search.ts +466 -0
  54. package/src/constants.ts +35 -0
  55. package/src/coupang.test.ts +78 -0
  56. package/src/coupang.ts +302 -0
  57. package/src/doctor.test.ts +15 -6
  58. package/src/doctor.ts +221 -25
  59. package/src/engine.test.ts +77 -0
  60. package/src/engine.ts +5 -5
  61. package/src/explore.ts +2 -15
  62. package/src/interceptor.ts +153 -0
  63. package/src/main.ts +9 -5
  64. package/src/output.ts +0 -4
  65. package/src/pipeline/executor.ts +15 -15
  66. package/src/pipeline/steps/intercept.ts +4 -55
  67. package/src/pipeline/steps/tap.ts +12 -51
  68. package/src/registry.test.ts +106 -0
  69. package/src/registry.ts +4 -1
  70. package/src/runtime.ts +22 -8
  71. package/src/setup.ts +169 -0
  72. package/src/synthesize.ts +5 -5
  73. package/src/tui.ts +171 -0
  74. package/src/validate.ts +22 -0
  75. package/src/verify.ts +10 -1
  76. package/src/version.ts +18 -0
@@ -0,0 +1,149 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { canonicalizeProductUrl, normalizeProductId } from '../../coupang.js';
3
+
4
+ function escapeJsString(value: string): string {
5
+ return JSON.stringify(value);
6
+ }
7
+
8
+ function buildAddToCartEvaluate(expectedProductId: string): string {
9
+ return `
10
+ (async () => {
11
+ const expectedProductId = ${escapeJsString(expectedProductId)};
12
+ const text = document.body.innerText || '';
13
+ const loginHints = {
14
+ hasLoginLink: Boolean(document.querySelector('a[href*="login"], a[title*="로그인"]')),
15
+ hasMyCoupang: /마이쿠팡/.test(text),
16
+ };
17
+
18
+ const pathMatch = location.pathname.match(/\\/vp\\/products\\/(\\d+)/);
19
+ const currentProductId = pathMatch?.[1] || '';
20
+ if (expectedProductId && currentProductId && expectedProductId !== currentProductId) {
21
+ return { ok: false, reason: 'PRODUCT_MISMATCH', currentProductId, loginHints };
22
+ }
23
+
24
+ const optionSelectors = [
25
+ 'select',
26
+ '[role="listbox"]',
27
+ '.prod-option, .product-option, .option-select, .option-dropdown',
28
+ ];
29
+ const hasRequiredOption = optionSelectors.some((selector) => {
30
+ try {
31
+ const nodes = Array.from(document.querySelectorAll(selector));
32
+ return nodes.some((node) => {
33
+ const label = (node.textContent || '') + ' ' + (node.getAttribute?.('aria-label') || '');
34
+ return /옵션|색상|사이즈|용량|선택/i.test(label);
35
+ });
36
+ } catch {
37
+ return false;
38
+ }
39
+ });
40
+ if (hasRequiredOption) {
41
+ return { ok: false, reason: 'OPTION_REQUIRED', currentProductId, loginHints };
42
+ }
43
+
44
+ const clickCandidate = (elements) => {
45
+ for (const element of elements) {
46
+ if (!(element instanceof HTMLElement)) continue;
47
+ const label = ((element.innerText || '') + ' ' + (element.getAttribute('aria-label') || '')).trim();
48
+ if (/장바구니|카트|cart/i.test(label) && !/sold out|품절/i.test(label)) {
49
+ element.click();
50
+ return true;
51
+ }
52
+ }
53
+ return false;
54
+ };
55
+
56
+ const beforeCount = (() => {
57
+ const node = document.querySelector('[class*="cart"] .count, #headerCartCount, .cart-count');
58
+ const text = node?.textContent || '';
59
+ const num = Number(text.replace(/[^\\d]/g, ''));
60
+ return Number.isFinite(num) ? num : null;
61
+ })();
62
+
63
+ const buttons = Array.from(document.querySelectorAll('button, a[role="button"], input[type="button"]'));
64
+ const clicked = clickCandidate(buttons);
65
+ if (!clicked) {
66
+ return { ok: false, reason: 'ADD_TO_CART_BUTTON_NOT_FOUND', currentProductId, loginHints };
67
+ }
68
+
69
+ await new Promise((resolve) => setTimeout(resolve, 2500));
70
+
71
+ const afterText = document.body.innerText || '';
72
+ const successMessage = /장바구니에 담|장바구니 담기 완료|added to cart/i.test(afterText);
73
+ const afterCount = (() => {
74
+ const node = document.querySelector('[class*="cart"] .count, #headerCartCount, .cart-count');
75
+ const text = node?.textContent || '';
76
+ const num = Number(text.replace(/[^\\d]/g, ''));
77
+ return Number.isFinite(num) ? num : null;
78
+ })();
79
+ const countIncreased =
80
+ beforeCount != null &&
81
+ afterCount != null &&
82
+ afterCount >= beforeCount &&
83
+ (afterCount > beforeCount || beforeCount === 0);
84
+
85
+ return {
86
+ ok: successMessage || countIncreased,
87
+ reason: successMessage || countIncreased ? 'SUCCESS' : 'UNKNOWN',
88
+ currentProductId,
89
+ beforeCount,
90
+ afterCount,
91
+ loginHints,
92
+ };
93
+ })()
94
+ `;
95
+ }
96
+
97
+ cli({
98
+ site: 'coupang',
99
+ name: 'add-to-cart',
100
+ description: 'Add a Coupang product to cart using logged-in browser session',
101
+ domain: 'www.coupang.com',
102
+ strategy: Strategy.COOKIE,
103
+ browser: true,
104
+ args: [
105
+ { name: 'productId', required: false, help: 'Coupang product ID' },
106
+ { name: 'url', required: false, help: 'Canonical product URL' },
107
+ ],
108
+ columns: ['ok', 'product_id', 'url', 'message'],
109
+ func: async (page, kwargs) => {
110
+ const rawProductId = kwargs.productId ?? kwargs.product_id;
111
+ const productId = normalizeProductId(rawProductId);
112
+ const targetUrl = canonicalizeProductUrl(kwargs.url, productId);
113
+
114
+ if (!productId && !targetUrl) {
115
+ throw new Error('Either --product-id or --url is required');
116
+ }
117
+
118
+ const finalUrl = targetUrl || canonicalizeProductUrl('', productId);
119
+ await page.goto(finalUrl);
120
+ await page.wait(3);
121
+
122
+ const result = await page.evaluate(buildAddToCartEvaluate(productId));
123
+ const loginHints = result?.loginHints ?? {};
124
+ if (loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
125
+ throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.');
126
+ }
127
+
128
+ const actualProductId = normalizeProductId(result?.currentProductId || productId);
129
+ if (result?.reason === 'PRODUCT_MISMATCH') {
130
+ throw new Error(`Product mismatch: expected ${productId}, got ${actualProductId || 'unknown'}`);
131
+ }
132
+ if (result?.reason === 'OPTION_REQUIRED') {
133
+ throw new Error('This product requires option selection and is not supported in v1.');
134
+ }
135
+ if (result?.reason === 'ADD_TO_CART_BUTTON_NOT_FOUND') {
136
+ throw new Error('Could not find an add-to-cart button on the product page.');
137
+ }
138
+ if (!result?.ok) {
139
+ throw new Error('Failed to confirm add-to-cart success.');
140
+ }
141
+
142
+ return [{
143
+ ok: true,
144
+ product_id: actualProductId || productId,
145
+ url: finalUrl,
146
+ message: 'Added to cart',
147
+ }];
148
+ },
149
+ });
@@ -0,0 +1,466 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { mergeSearchItems, normalizeSearchItem, sanitizeSearchItems } from '../../coupang.js';
3
+
4
+ function escapeJsString(value: string): string {
5
+ return JSON.stringify(value);
6
+ }
7
+
8
+ function buildApplyFilterEvaluate(filter: string): string {
9
+ return `
10
+ () => {
11
+ const filter = ${escapeJsString(filter)};
12
+ const labels = Array.from(document.querySelectorAll('label'));
13
+ const normalize = (value) => (value == null ? '' : String(value).trim().toLowerCase());
14
+ const target = labels.find((label) => {
15
+ const component = normalize(label.getAttribute('data-component-name'));
16
+ const imgAlt = normalize(label.querySelector('img')?.getAttribute('alt'));
17
+ const text = normalize(label.textContent);
18
+
19
+ if (filter === 'rocket') {
20
+ return (
21
+ component.includes('deliveryfilteroption-rocket_luxury,rocket_wow,coupang_global') ||
22
+ imgAlt.includes('rocket_luxury,rocket_wow,coupang_global') ||
23
+ imgAlt.includes('rocket-all') ||
24
+ text.includes('로켓')
25
+ );
26
+ }
27
+
28
+ return component.includes(filter) || imgAlt.includes(filter) || text.includes(filter);
29
+ });
30
+
31
+ if (!target) {
32
+ return { ok: false, reason: 'FILTER_NOT_FOUND' };
33
+ }
34
+
35
+ target.click();
36
+
37
+ return {
38
+ ok: true,
39
+ reason: 'FILTER_CLICKED',
40
+ component: target.getAttribute('data-component-name') || '',
41
+ text: (target.textContent || '').trim(),
42
+ alt: target.querySelector('img')?.getAttribute('alt') || '',
43
+ };
44
+ }
45
+ `;
46
+ }
47
+
48
+ function buildCurrentLocationEvaluate(): string {
49
+ return `
50
+ () => ({
51
+ href: location.href
52
+ })
53
+ `;
54
+ }
55
+
56
+ function buildSearchEvaluate(query: string, limit: number, pageNumber: number): string {
57
+ return `
58
+ (async () => {
59
+ const query = ${escapeJsString(query)};
60
+ const limit = ${limit};
61
+ const pageNumber = ${pageNumber};
62
+
63
+ const normalizeText = (value) => (value == null ? '' : String(value).trim());
64
+ const parseNum = (value) => {
65
+ const text = normalizeText(value).replace(/[^\\d.]/g, '');
66
+ if (!text) return null;
67
+ const num = Number(text);
68
+ return Number.isFinite(num) ? num : null;
69
+ };
70
+ const extractPriceFromText = (text) => {
71
+ const matches = normalizeText(text).match(/\\d{1,3}(?:,\\d{3})*원/g) || [];
72
+ if (!matches.length) return '';
73
+ if (matches.length >= 2) return matches[matches.length - 2];
74
+ return matches[0];
75
+ };
76
+ const extractPriceInfo = (root) => {
77
+ const priceArea =
78
+ root.querySelector('.PriceArea_priceArea__NntJz, [class*="PriceArea_priceArea"], [class*="priceArea"]') ||
79
+ root;
80
+ const priceAreaText = normalizeText(priceArea.textContent || '');
81
+ const originalPrice = normalizeText(
82
+ priceArea.querySelector(
83
+ 'del, .base-price, .origin-price, .original-price, .strike-price, [class*="base-price"], [class*="origin-price"], [class*="line-through"]'
84
+ )?.textContent || ''
85
+ );
86
+ const originalPriceNum = parseNum(originalPrice);
87
+ const unitPrice =
88
+ normalizeText(
89
+ priceArea.querySelector('.unit-price, [class*="unit-price"], [class*="unitPrice"]')?.textContent || ''
90
+ ) ||
91
+ priceAreaText.match(/\\([^)]*당\\s*[^)]*원[^)]*\\)/)?.[0] ||
92
+ '';
93
+
94
+ const candidates = Array.from(priceArea.querySelectorAll('span, strong, div'))
95
+ .map((node) => {
96
+ const text = normalizeText(node.textContent || '');
97
+ if (!text || !/\\d/.test(text)) return null;
98
+ if (/\\d{1,2}:\\d{2}:\\d{2}/.test(text)) return null;
99
+ if (/당\\s*\\d/.test(text)) return null;
100
+ if (/^\\d+%$/.test(text)) return null;
101
+
102
+ const num = parseNum(text);
103
+ if (num == null) return null;
104
+
105
+ const className = normalizeText(node.getAttribute('class') || '').toLowerCase();
106
+ let score = 0;
107
+ if (/price|sale|selling|final/.test(className)) score += 6;
108
+ if (/red/.test(className)) score += 5;
109
+ if (/font-bold|bold/.test(className)) score += 3;
110
+ if (/line-through/.test(className)) score -= 12;
111
+ if (text.includes('원')) score += 2;
112
+ if (originalPriceNum != null && num === originalPriceNum) score -= 10;
113
+ if (num < 100) score -= 10;
114
+
115
+ return { text, num, score };
116
+ })
117
+ .filter(Boolean)
118
+ .sort((a, b) => {
119
+ if (b.score !== a.score) return b.score - a.score;
120
+ if (originalPriceNum != null) {
121
+ const aPrefer = a.num !== originalPriceNum ? 1 : 0;
122
+ const bPrefer = b.num !== originalPriceNum ? 1 : 0;
123
+ if (bPrefer !== aPrefer) return bPrefer - aPrefer;
124
+ }
125
+ return b.num - a.num;
126
+ });
127
+
128
+ const currentPrice =
129
+ normalizeText(candidates.find((candidate) => candidate.num !== originalPriceNum)?.text || '') ||
130
+ normalizeText(candidates[0]?.text || '') ||
131
+ extractPriceFromText(priceAreaText) ||
132
+ '';
133
+
134
+ return {
135
+ price: currentPrice,
136
+ originalPrice,
137
+ unitPrice,
138
+ };
139
+ };
140
+ const canonicalUrl = (url, productId) => {
141
+ if (url) {
142
+ try {
143
+ const parsed = new URL(url, 'https://www.coupang.com');
144
+ const match = parsed.pathname.match(/\\/vp\\/products\\/(\\d+)/);
145
+ return 'https://www.coupang.com/vp/products/' + (match?.[1] || productId || '');
146
+ } catch {}
147
+ }
148
+ return productId ? 'https://www.coupang.com/vp/products/' + productId : '';
149
+ };
150
+ const normalize = (raw) => {
151
+ const rawText = normalizeText(raw.text || raw.badgeText || raw.deliveryText || raw.summary);
152
+ const productId = normalizeText(
153
+ raw.productId || raw.product_id || raw.id || raw.productNo ||
154
+ raw?.product?.productId || raw?.item?.id
155
+ ).match(/(\\d{6,})/)?.[1] || '';
156
+ const title = normalizeText(
157
+ raw.title || raw.name || raw.productName || raw.productTitle || raw.itemName
158
+ );
159
+ const price = parseNum(raw.price || raw.salePrice || raw.finalPrice || raw.sellingPrice);
160
+ const originalPrice = parseNum(raw.originalPrice || raw.basePrice || raw.listPrice || raw.originPrice);
161
+ const unitPrice = normalizeText(raw.unitPrice || raw.unit_price || raw.unitPriceText);
162
+ const rating = parseNum(raw.rating || raw.star || raw.reviewRating);
163
+ const reviewCount = parseNum(raw.reviewCount || raw.ratingCount || raw.reviewCnt || raw.reviews);
164
+ const badge = Array.isArray(raw.badges) ? raw.badges.map(normalizeText).filter(Boolean).join(', ') : normalizeText(raw.badge || raw.labels);
165
+ const seller = normalizeText(raw.seller || raw.sellerName || raw.vendorName || raw.merchantName);
166
+ const category = normalizeText(raw.category || raw.categoryName || raw.categoryPath);
167
+ const discountRate = parseNum(raw.discountRate || raw.discount || raw.discountPercent);
168
+ const url = canonicalUrl(raw.url || raw.productUrl || raw.link, productId);
169
+ return {
170
+ productId,
171
+ title,
172
+ price,
173
+ originalPrice,
174
+ unitPrice,
175
+ discountRate,
176
+ rating,
177
+ reviewCount,
178
+ rocket: normalizeText(raw.rocket || raw.rocketType),
179
+ deliveryType: normalizeText(raw.deliveryType || raw.deliveryBadge || raw.shippingType || raw.shippingBadge),
180
+ deliveryPromise: normalizeText(raw.deliveryPromise || raw.promise || raw.arrivalText || raw.arrivalBadge),
181
+ seller,
182
+ badge,
183
+ category,
184
+ url,
185
+ };
186
+ };
187
+
188
+ const byApi = async () => {
189
+ const candidates = [
190
+ '/np/search?q=' + encodeURIComponent(query) + '&component=&channel=user&page=' + pageNumber,
191
+ '/np/search?component=&q=' + encodeURIComponent(query) + '&channel=user&page=' + pageNumber,
192
+ ];
193
+
194
+ for (const path of candidates) {
195
+ try {
196
+ const resp = await fetch(path, { credentials: 'include' });
197
+ if (!resp.ok) continue;
198
+ const text = await resp.text();
199
+ const data = text.trim().startsWith('<') ? null : JSON.parse(text);
200
+ const maybeItems =
201
+ data?.data?.products ||
202
+ data?.data?.productList ||
203
+ data?.products ||
204
+ data?.productList ||
205
+ data?.items;
206
+ if (Array.isArray(maybeItems) && maybeItems.length) {
207
+ return maybeItems.slice(0, limit).map(normalize);
208
+ }
209
+ } catch {}
210
+ }
211
+ return [];
212
+ };
213
+
214
+ const byBootstrap = () => {
215
+ const isProductLike = (item) => {
216
+ if (!item || typeof item !== 'object') return false;
217
+ const values = [item.productId, item.product_id, item.id, item.productNo, item.url, item.productUrl, item.link, item.title, item.productName];
218
+ return values.some((value) => /\\/vp\\/products\\/|\\d{6,}/.test(normalizeText(value)));
219
+ };
220
+
221
+ const collectProducts = (node) => {
222
+ const queue = [node];
223
+ while (queue.length) {
224
+ const current = queue.shift();
225
+ if (!current || typeof current !== 'object') continue;
226
+ if (Array.isArray(current)) {
227
+ const productish = current.filter(isProductLike);
228
+ if (productish.length >= 3) return productish.slice(0, limit).map(normalize);
229
+ queue.push(...current.slice(0, 50));
230
+ continue;
231
+ }
232
+ for (const value of Object.values(current)) queue.push(value);
233
+ }
234
+ return [];
235
+ };
236
+
237
+ const scriptNodes = Array.from(document.scripts);
238
+ for (const script of scriptNodes) {
239
+ const text = script.textContent || '';
240
+ if (!text || !/product|search/i.test(text)) continue;
241
+ const arrayMatches = [
242
+ ...text.matchAll(/"products?"\\s*:\\s*(\\[[\\s\\S]{100,}?\\])/g),
243
+ ...text.matchAll(/"itemList"\\s*:\\s*(\\[[\\s\\S]{100,}?\\])/g),
244
+ ];
245
+ for (const match of arrayMatches) {
246
+ try {
247
+ const products = JSON.parse(match[1]);
248
+ if (Array.isArray(products) && products.length) {
249
+ return products.slice(0, limit).map(normalize);
250
+ }
251
+ } catch {}
252
+ }
253
+ }
254
+
255
+ const globals = [
256
+ window.__NEXT_DATA__,
257
+ window.__APOLLO_STATE__,
258
+ window.__INITIAL_STATE__,
259
+ window.__STATE__,
260
+ window.__PRELOADED_STATE__,
261
+ ];
262
+ for (const candidate of globals) {
263
+ if (!candidate || typeof candidate !== 'object') continue;
264
+ const found = collectProducts(candidate);
265
+ if (found.length) return found;
266
+ }
267
+ return [];
268
+ };
269
+
270
+ const byJsonLd = () => {
271
+ const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
272
+ for (const script of scripts) {
273
+ const text = script.textContent || '';
274
+ if (!text) continue;
275
+ try {
276
+ const payload = JSON.parse(text);
277
+ const docs = Array.isArray(payload) ? payload : [payload];
278
+ for (const doc of docs) {
279
+ const items =
280
+ doc?.itemListElement ||
281
+ doc?.about?.itemListElement ||
282
+ doc?.mainEntity?.itemListElement ||
283
+ [];
284
+ if (!Array.isArray(items) || !items.length) continue;
285
+ const mapped = items.map((entry) => {
286
+ const item = entry?.item || entry;
287
+ return normalize({
288
+ productId: item?.url || item?.sku || item?.productID,
289
+ title: item?.name,
290
+ price: item?.offers?.price,
291
+ originalPrice: item?.offers?.highPrice,
292
+ rating: item?.aggregateRating?.ratingValue,
293
+ reviewCount: item?.aggregateRating?.reviewCount,
294
+ seller: item?.offers?.seller?.name,
295
+ badge: item?.offers?.availability,
296
+ category: item?.category,
297
+ url: item?.url,
298
+ });
299
+ }).filter((item) => item.productId || item.url || item.title);
300
+ if (mapped.length) return mapped.slice(0, limit);
301
+ }
302
+ } catch {}
303
+ }
304
+ return [];
305
+ };
306
+
307
+ const byDom = () => {
308
+ const domScanLimit = Math.max(limit * 6, 60);
309
+ const cards = Array.from(new Set([
310
+ ...document.querySelectorAll('li.search-product'),
311
+ ...document.querySelectorAll('li[class*="search-product"], div[class*="search-product"], article[class*="search-product"]'),
312
+ ...document.querySelectorAll('li[class*="ProductUnit_productUnit"], [class*="ProductUnit_productUnit"]'),
313
+ ...document.querySelectorAll('.impression-logged, [class*="promotion-item"], [class*="product-item"]'),
314
+ ...document.querySelectorAll('[data-product-id]'),
315
+ ...document.querySelectorAll('[data-id]'),
316
+ ...document.querySelectorAll('a[href*="/vp/products/"]'),
317
+ ])).slice(0, domScanLimit);
318
+ const items = [];
319
+ for (const el of cards) {
320
+ const root = el.closest('li, div, article, section') || el;
321
+ const html = root.innerHTML || '';
322
+ const priceInfo = extractPriceInfo(root);
323
+ const badgeImages = Array.from(root.querySelectorAll('img[data-badge-id]'));
324
+ const badgeIds = badgeImages
325
+ .map((node) => node.getAttribute('data-badge-id') || '')
326
+ .filter(Boolean);
327
+ const badgeSrcText = badgeImages
328
+ .map((node) => (node.getAttribute('data-badge-id') || '') + ' ' + (node.getAttribute('src') || ''))
329
+ .join(' ');
330
+ const productId =
331
+ root.getAttribute('data-product-id') ||
332
+ el.getAttribute('data-product-id') ||
333
+ root.querySelector('a[href*="/vp/products/"]')?.getAttribute('data-product-id') ||
334
+ root.querySelector('a[href*="/vp/products/"]')?.getAttribute('href')?.match(/\\/vp\\/products\\/(\\d+)/)?.[1] ||
335
+ html.match(/\\/vp\\/products\\/(\\d+)/)?.[1] ||
336
+ (el.getAttribute('href') || '').match(/\\/vp\\/products\\/(\\d+)/)?.[1] ||
337
+ '';
338
+ const title =
339
+ root.querySelector('.name, .title, .product-name, .search-product-title, .item-title, .ProductUnit_productNameV2__cV9cw, [class*="ProductUnit_productName"], [class*="productName"], [class*="product-name"], [class*="title"]')?.textContent ||
340
+ root.querySelector('img[alt]')?.getAttribute('alt') ||
341
+ html.match(/alt="([^"]+)"/)?.[1] ||
342
+ (root.textContent || '').replace(/\\s+/g, ' ').trim().match(/^(.+?)(\\d{1,3},\\d{3}원|무료배송|내일\\(|오늘\\(|새벽)/)?.[1] ||
343
+ el.getAttribute('title') ||
344
+ '';
345
+ const price = priceInfo.price || '';
346
+ const originalPrice = priceInfo.originalPrice || '';
347
+ const unitPrice = priceInfo.unitPrice || '';
348
+ const rating =
349
+ root.querySelector('.rating, .star em, [class*="rating"], [class*="star"], [class*="ProductRating"] [aria-label], [aria-label][class*="ProductRating"]')?.getAttribute?.('aria-label') ||
350
+ root.querySelector('.rating, .star em, [class*="rating"], [class*="star"], [class*="ProductRating"]')?.textContent ||
351
+ '';
352
+ const reviewCount =
353
+ root.querySelector('.rating-total-count, .count, .review-count, .promotion-item-review-count, [class*="review"], [class*="count"], [class*="ProductRating"] span, [class*="ProductRating"] [class*="fw-text"]')?.textContent ||
354
+ '';
355
+ const seller =
356
+ root.querySelector('.seller, .vendor, .search-product-wrap .vendor-name, [class*="vendor"], [class*="seller"]')?.textContent ||
357
+ '';
358
+ const category =
359
+ root.getAttribute('data-category') ||
360
+ root.querySelector('[class*="category"]')?.textContent ||
361
+ '';
362
+ const text = (root.textContent || '').replace(/\\s+/g, ' ').trim();
363
+ const badgeNodes = Array.from(root.querySelectorAll('.badge, .delivery, .tag, .icon-service, .pdd-text, .delivery-text, [class*="badge"], [class*="delivery"]'));
364
+ const hrefNode = root.querySelector('a[href*="/vp/products/"]');
365
+ items.push(normalize({
366
+ productId,
367
+ title,
368
+ price,
369
+ originalPrice,
370
+ unitPrice,
371
+ rating,
372
+ reviewCount,
373
+ seller,
374
+ badges: [...badgeIds, ...badgeNodes.map((node) => node.textContent || '').filter(Boolean)],
375
+ rocket: badgeSrcText + ' ' + badgeNodes.map((node) => node.textContent || '').join(' '),
376
+ deliveryType: badgeNodes.map((node) => node.textContent || '').join(' ') + ' ' + text,
377
+ deliveryPromise: badgeNodes.map((node) => node.textContent || '').join(' ') + ' ' + text,
378
+ category,
379
+ text,
380
+ url: hrefNode?.getAttribute('href') || '',
381
+ }));
382
+ }
383
+ return items.slice(0, domScanLimit);
384
+ };
385
+
386
+ let items = await byApi();
387
+ if (!items.length) items = byJsonLd();
388
+ if (!items.length) items = byBootstrap();
389
+ const domItems = byDom();
390
+ if (!items.length) items = domItems;
391
+
392
+ return {
393
+ loginHints: {
394
+ hasLoginLink: Boolean(document.querySelector('a[href*="login"], a[title*="로그인"]')),
395
+ hasMyCoupang: /마이쿠팡/.test(document.body.innerText),
396
+ },
397
+ items,
398
+ domItems,
399
+ };
400
+ })()
401
+ `;
402
+ }
403
+
404
+ cli({
405
+ site: 'coupang',
406
+ name: 'search',
407
+ description: 'Search Coupang products with logged-in browser session',
408
+ domain: 'www.coupang.com',
409
+ strategy: Strategy.COOKIE,
410
+ browser: true,
411
+ args: [
412
+ { name: 'query', required: true, help: 'Search keyword' },
413
+ { name: 'page', type: 'int', default: 1, help: 'Search result page number' },
414
+ { name: 'limit', type: 'int', default: 20, help: 'Max results (max 50)' },
415
+ { name: 'filter', required: false, help: 'Optional search filter (currently supports: rocket)' },
416
+ ],
417
+ columns: ['rank', 'title', 'price', 'unit_price', 'rating', 'review_count', 'rocket', 'delivery_type', 'delivery_promise', 'url'],
418
+ func: async (page, kwargs) => {
419
+ const query = String(kwargs.query || '').trim();
420
+ const pageNumber = Math.max(Number(kwargs.page || 1), 1);
421
+ const limit = Math.min(Math.max(Number(kwargs.limit || 20), 1), 50);
422
+ const filter = String(kwargs.filter || '').trim().toLowerCase();
423
+ if (!query) throw new Error('Query is required');
424
+
425
+ const initialPage = filter ? 1 : pageNumber;
426
+ const url = `https://www.coupang.com/np/search?q=${encodeURIComponent(query)}&channel=user&page=${initialPage}`;
427
+ await page.goto(url);
428
+ await page.wait(3);
429
+ if (filter) {
430
+ const filterResult = await page.evaluate(buildApplyFilterEvaluate(filter));
431
+ if (!filterResult?.ok) {
432
+ throw new Error(`Unsupported or unavailable filter: ${filter}`);
433
+ }
434
+ await page.wait(3);
435
+ if (pageNumber > 1) {
436
+ const locationInfo = await page.evaluate(buildCurrentLocationEvaluate());
437
+ const filteredUrl = new URL(locationInfo?.href || url);
438
+ filteredUrl.searchParams.set('page', String(pageNumber));
439
+ await page.goto(filteredUrl.toString());
440
+ await page.wait(3);
441
+ }
442
+ }
443
+ await page.autoScroll({ times: filter ? 3 : 2, delayMs: 1500 });
444
+
445
+ const raw = await page.evaluate(buildSearchEvaluate(query, limit, pageNumber));
446
+ const loginHints = raw?.loginHints ?? {};
447
+ const items = Array.isArray(raw?.items) ? raw.items : [];
448
+ const domItems = Array.isArray(raw?.domItems) ? raw.domItems : [];
449
+ const normalizedBase = sanitizeSearchItems(
450
+ items.map((item: Record<string, unknown>, index: number) => normalizeSearchItem(item, index)),
451
+ limit
452
+ );
453
+ const normalizedDom = sanitizeSearchItems(
454
+ domItems.map((item: Record<string, unknown>, index: number) => normalizeSearchItem(item, index)),
455
+ Math.max(limit * 6, 60)
456
+ );
457
+ const normalized = filter
458
+ ? sanitizeSearchItems(normalizedDom, limit)
459
+ : mergeSearchItems(normalizedBase, normalizedDom, limit);
460
+
461
+ if (!normalized.length && loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
462
+ throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.');
463
+ }
464
+ return normalized;
465
+ },
466
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared constants used across explore, synthesize, and pipeline modules.
3
+ */
4
+
5
+ /** URL query params that are volatile/ephemeral and should be stripped from patterns */
6
+ export const VOLATILE_PARAMS = new Set([
7
+ 'w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign',
8
+ ]);
9
+
10
+ /** Search-related query parameter names */
11
+ export const SEARCH_PARAMS = new Set([
12
+ 'q', 'query', 'keyword', 'search', 'wd', 'kw', 'search_query', 'w',
13
+ ]);
14
+
15
+ /** Pagination-related query parameter names */
16
+ export const PAGINATION_PARAMS = new Set([
17
+ 'page', 'pn', 'offset', 'cursor', 'next', 'page_num',
18
+ ]);
19
+
20
+ /** Limit/page-size query parameter names */
21
+ export const LIMIT_PARAMS = new Set([
22
+ 'limit', 'count', 'size', 'per_page', 'page_size', 'ps', 'num',
23
+ ]);
24
+
25
+ /** Field role → common API field names mapping */
26
+ export const FIELD_ROLES: Record<string, string[]> = {
27
+ title: ['title', 'name', 'text', 'content', 'desc', 'description', 'headline', 'subject'],
28
+ url: ['url', 'uri', 'link', 'href', 'permalink', 'jump_url', 'web_url', 'share_url'],
29
+ author: ['author', 'username', 'user_name', 'nickname', 'nick', 'owner', 'creator', 'up_name', 'uname'],
30
+ score: ['score', 'hot', 'heat', 'likes', 'like_count', 'view_count', 'views', 'play', 'favorite_count', 'reply_count'],
31
+ time: ['time', 'created_at', 'publish_time', 'pub_time', 'date', 'ctime', 'mtime', 'pubdate', 'created'],
32
+ id: ['id', 'aid', 'bvid', 'mid', 'uid', 'oid', 'note_id', 'item_id'],
33
+ cover: ['cover', 'pic', 'image', 'thumbnail', 'poster', 'avatar'],
34
+ category: ['category', 'tag', 'type', 'tname', 'channel', 'section'],
35
+ };