@jackwener/opencli 0.5.2 → 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.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
10
10
  [![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
11
11
 
12
- A CLI tool that turns **any website** into a command-line interface. **57 commands** across **17 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery.
12
+ A CLI tool that turns **any website** into a command-line interface. **59 commands** across **18 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube, coupang — powered by browser session reuse and AI-native discovery.
13
13
 
14
14
  ---
15
15
 
@@ -126,6 +126,7 @@ npm install -g @jackwener/opencli@latest
126
126
  | **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 Browser |
127
127
  | **weibo** | `hot` | 🔐 Browser |
128
128
  | **boss** | `search` | 🔐 Browser |
129
+ | **coupang** | `search` `add-to-cart` | 🔐 Browser |
129
130
  | **youtube** | `search` | 🔐 Browser |
130
131
  | **yahoo-finance** | `quote` | 🔐 Browser |
131
132
  | **reuters** | `search` | 🔐 Browser |
package/README.zh-CN.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
10
10
  [![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
11
11
 
12
- OpenCLI 将任何网站变成命令行工具。**57 个命令**覆盖 **17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube — 复用浏览器登录态,AI 驱动探索。
12
+ OpenCLI 将任何网站变成命令行工具。**59 个命令**覆盖 **18 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube、Coupang — 复用浏览器登录态,AI 驱动探索。
13
13
 
14
14
  ---
15
15
 
@@ -29,7 +29,7 @@ OpenCLI 将任何网站变成命令行工具。**57 个命令**覆盖 **17 个
29
29
 
30
30
  ## 亮点
31
31
 
32
- - **57 个命令,17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube
32
+ - **59 个命令,18 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube、Coupang
33
33
  - **零风控** — 复用 Chrome 登录态,无需存储任何凭证
34
34
  - **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
35
35
  - **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
@@ -126,6 +126,7 @@ npm install -g @jackwener/opencli@latest
126
126
  | **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 浏览器 |
127
127
  | **weibo** | `hot` | 🔐 浏览器 |
128
128
  | **boss** | `search` | 🔐 浏览器 |
129
+ | **coupang** | `search` `add-to-cart` | 🔐 浏览器 |
129
130
  | **youtube** | `search` | 🔐 浏览器 |
130
131
  | **yahoo-finance** | `quote` | 🔐 浏览器 |
131
132
  | **reuters** | `search` | 🔐 浏览器 |
@@ -470,6 +470,86 @@
470
470
  "url"
471
471
  ]
472
472
  },
473
+ {
474
+ "site": "coupang",
475
+ "name": "add-to-cart",
476
+ "description": "Add a Coupang product to cart using logged-in browser session",
477
+ "strategy": "cookie",
478
+ "browser": true,
479
+ "args": [
480
+ {
481
+ "name": "productId",
482
+ "type": "str",
483
+ "required": false,
484
+ "help": "Coupang product ID"
485
+ },
486
+ {
487
+ "name": "url",
488
+ "type": "str",
489
+ "required": false,
490
+ "help": "Canonical product URL"
491
+ }
492
+ ],
493
+ "type": "ts",
494
+ "modulePath": "coupang/add-to-cart.js",
495
+ "domain": "www.coupang.com",
496
+ "columns": [
497
+ "ok",
498
+ "product_id",
499
+ "url",
500
+ "message"
501
+ ]
502
+ },
503
+ {
504
+ "site": "coupang",
505
+ "name": "search",
506
+ "description": "Search Coupang products with logged-in browser session",
507
+ "strategy": "cookie",
508
+ "browser": true,
509
+ "args": [
510
+ {
511
+ "name": "query",
512
+ "type": "str",
513
+ "required": true,
514
+ "help": "Search keyword"
515
+ },
516
+ {
517
+ "name": "page",
518
+ "type": "int",
519
+ "default": 1,
520
+ "required": false,
521
+ "help": "Search result page number"
522
+ },
523
+ {
524
+ "name": "limit",
525
+ "type": "int",
526
+ "default": 20,
527
+ "required": false,
528
+ "help": "Max results (max 50)"
529
+ },
530
+ {
531
+ "name": "filter",
532
+ "type": "str",
533
+ "required": false,
534
+ "help": "Optional search filter (currently supports: rocket)"
535
+ }
536
+ ],
537
+ "type": "ts",
538
+ "modulePath": "coupang/search.js",
539
+ "domain": "www.coupang.com",
540
+ "columns": [
541
+ "rank",
542
+ "title",
543
+ "price",
544
+ "unit_price",
545
+ "rating",
546
+ "review_count",
547
+ "rocket",
548
+ "delivery_type",
549
+ "delivery_promise",
550
+ "url"
551
+ ]
552
+ },
473
553
  {
474
554
  "site": "ctrip",
475
555
  "name": "search",
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,141 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { canonicalizeProductUrl, normalizeProductId } from '../../coupang.js';
3
+ function escapeJsString(value) {
4
+ return JSON.stringify(value);
5
+ }
6
+ function buildAddToCartEvaluate(expectedProductId) {
7
+ return `
8
+ (async () => {
9
+ const expectedProductId = ${escapeJsString(expectedProductId)};
10
+ const text = document.body.innerText || '';
11
+ const loginHints = {
12
+ hasLoginLink: Boolean(document.querySelector('a[href*="login"], a[title*="로그인"]')),
13
+ hasMyCoupang: /마이쿠팡/.test(text),
14
+ };
15
+
16
+ const pathMatch = location.pathname.match(/\\/vp\\/products\\/(\\d+)/);
17
+ const currentProductId = pathMatch?.[1] || '';
18
+ if (expectedProductId && currentProductId && expectedProductId !== currentProductId) {
19
+ return { ok: false, reason: 'PRODUCT_MISMATCH', currentProductId, loginHints };
20
+ }
21
+
22
+ const optionSelectors = [
23
+ 'select',
24
+ '[role="listbox"]',
25
+ '.prod-option, .product-option, .option-select, .option-dropdown',
26
+ ];
27
+ const hasRequiredOption = optionSelectors.some((selector) => {
28
+ try {
29
+ const nodes = Array.from(document.querySelectorAll(selector));
30
+ return nodes.some((node) => {
31
+ const label = (node.textContent || '') + ' ' + (node.getAttribute?.('aria-label') || '');
32
+ return /옵션|색상|사이즈|용량|선택/i.test(label);
33
+ });
34
+ } catch {
35
+ return false;
36
+ }
37
+ });
38
+ if (hasRequiredOption) {
39
+ return { ok: false, reason: 'OPTION_REQUIRED', currentProductId, loginHints };
40
+ }
41
+
42
+ const clickCandidate = (elements) => {
43
+ for (const element of elements) {
44
+ if (!(element instanceof HTMLElement)) continue;
45
+ const label = ((element.innerText || '') + ' ' + (element.getAttribute('aria-label') || '')).trim();
46
+ if (/장바구니|카트|cart/i.test(label) && !/sold out|품절/i.test(label)) {
47
+ element.click();
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ };
53
+
54
+ const beforeCount = (() => {
55
+ const node = document.querySelector('[class*="cart"] .count, #headerCartCount, .cart-count');
56
+ const text = node?.textContent || '';
57
+ const num = Number(text.replace(/[^\\d]/g, ''));
58
+ return Number.isFinite(num) ? num : null;
59
+ })();
60
+
61
+ const buttons = Array.from(document.querySelectorAll('button, a[role="button"], input[type="button"]'));
62
+ const clicked = clickCandidate(buttons);
63
+ if (!clicked) {
64
+ return { ok: false, reason: 'ADD_TO_CART_BUTTON_NOT_FOUND', currentProductId, loginHints };
65
+ }
66
+
67
+ await new Promise((resolve) => setTimeout(resolve, 2500));
68
+
69
+ const afterText = document.body.innerText || '';
70
+ const successMessage = /장바구니에 담|장바구니 담기 완료|added to cart/i.test(afterText);
71
+ const afterCount = (() => {
72
+ const node = document.querySelector('[class*="cart"] .count, #headerCartCount, .cart-count');
73
+ const text = node?.textContent || '';
74
+ const num = Number(text.replace(/[^\\d]/g, ''));
75
+ return Number.isFinite(num) ? num : null;
76
+ })();
77
+ const countIncreased =
78
+ beforeCount != null &&
79
+ afterCount != null &&
80
+ afterCount >= beforeCount &&
81
+ (afterCount > beforeCount || beforeCount === 0);
82
+
83
+ return {
84
+ ok: successMessage || countIncreased,
85
+ reason: successMessage || countIncreased ? 'SUCCESS' : 'UNKNOWN',
86
+ currentProductId,
87
+ beforeCount,
88
+ afterCount,
89
+ loginHints,
90
+ };
91
+ })()
92
+ `;
93
+ }
94
+ cli({
95
+ site: 'coupang',
96
+ name: 'add-to-cart',
97
+ description: 'Add a Coupang product to cart using logged-in browser session',
98
+ domain: 'www.coupang.com',
99
+ strategy: Strategy.COOKIE,
100
+ browser: true,
101
+ args: [
102
+ { name: 'productId', required: false, help: 'Coupang product ID' },
103
+ { name: 'url', required: false, help: 'Canonical product URL' },
104
+ ],
105
+ columns: ['ok', 'product_id', 'url', 'message'],
106
+ func: async (page, kwargs) => {
107
+ const rawProductId = kwargs.productId ?? kwargs.product_id;
108
+ const productId = normalizeProductId(rawProductId);
109
+ const targetUrl = canonicalizeProductUrl(kwargs.url, productId);
110
+ if (!productId && !targetUrl) {
111
+ throw new Error('Either --product-id or --url is required');
112
+ }
113
+ const finalUrl = targetUrl || canonicalizeProductUrl('', productId);
114
+ await page.goto(finalUrl);
115
+ await page.wait(3);
116
+ const result = await page.evaluate(buildAddToCartEvaluate(productId));
117
+ const loginHints = result?.loginHints ?? {};
118
+ if (loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
119
+ throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.');
120
+ }
121
+ const actualProductId = normalizeProductId(result?.currentProductId || productId);
122
+ if (result?.reason === 'PRODUCT_MISMATCH') {
123
+ throw new Error(`Product mismatch: expected ${productId}, got ${actualProductId || 'unknown'}`);
124
+ }
125
+ if (result?.reason === 'OPTION_REQUIRED') {
126
+ throw new Error('This product requires option selection and is not supported in v1.');
127
+ }
128
+ if (result?.reason === 'ADD_TO_CART_BUTTON_NOT_FOUND') {
129
+ throw new Error('Could not find an add-to-cart button on the product page.');
130
+ }
131
+ if (!result?.ok) {
132
+ throw new Error('Failed to confirm add-to-cart success.');
133
+ }
134
+ return [{
135
+ ok: true,
136
+ product_id: actualProductId || productId,
137
+ url: finalUrl,
138
+ message: 'Added to cart',
139
+ }];
140
+ },
141
+ });
@@ -0,0 +1 @@
1
+ export {};