@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
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,13 +126,14 @@ 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 |
132
133
  | **smzdm** | `search` | 🔐 Browser |
133
134
  | **ctrip** | `search` | 🔐 Browser |
134
135
  | **github** | `search` | 🌐 Public |
135
- | **v2ex** | `hot` `latest` `topic` | 🌐 Public |
136
+ | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 Public / 🔐 Browser |
136
137
  | **hackernews** | `top` | 🌐 Public |
137
138
  | **bbc** | `news` | 🌐 Public |
138
139
 
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,13 +126,14 @@ 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` | 🔐 浏览器 |
132
133
  | **smzdm** | `search` | 🔐 浏览器 |
133
134
  | **ctrip** | `search` | 🔐 浏览器 |
134
135
  | **github** | `search` | 🌐 公共 API |
135
- | **v2ex** | `hot` `latest` `topic` | 🌐 公共 API |
136
+ | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 公共 API / 🔐 浏览器 |
136
137
  | **hackernews** | `top` | 🌐 公共 API |
137
138
  | **bbc** | `news` | 🌐 公共 API |
138
139
 
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: opencli
3
3
  description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login."
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  author: jackwener
6
6
  tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, AI, agent]
7
7
  ---
@@ -95,10 +95,13 @@ opencli reddit frontpage --limit 10 # 首页
95
95
  opencli reddit search --keyword "AI" # 搜索
96
96
  opencli reddit subreddit --name rust # 子版块浏览
97
97
 
98
- # V2EX (public)
98
+ # V2EX (public + browser)
99
99
  opencli v2ex hot --limit 10 # 热门话题
100
100
  opencli v2ex latest --limit 10 # 最新话题
101
101
  opencli v2ex topic --id 1024 # 主题详情
102
+ opencli v2ex daily # 每日签到 (browser)
103
+ opencli v2ex me # 我的信息 (browser)
104
+ opencli v2ex notifications --limit 10 # 通知 (browser)
102
105
 
103
106
  # Hacker News (public)
104
107
  opencli hackernews top --limit 10 # Top stories
@@ -156,8 +159,8 @@ opencli cascade <api-url>
156
159
  # Explore with interactive fuzzing (click buttons to trigger lazy APIs)
157
160
  opencli explore <url> --auto --click "字幕,CC,评论"
158
161
 
159
- # Verify: smoke-test a generated adapter
160
- opencli verify <site/name> --smoke
162
+ # Verify: validate adapter definitions
163
+ opencli verify
161
164
  ```
162
165
 
163
166
  ## Output Formats
package/dist/browser.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Browser interaction via Playwright MCP Bridge extension.
3
3
  * Connects to an existing Chrome browser through the extension.
4
4
  */
5
+ import { withTimeoutMs } from './runtime.js';
5
6
  type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
6
7
  type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
7
8
  type ConnectFailureInput = {
@@ -29,7 +30,6 @@ export declare class Page implements IPage {
29
30
  call(method: string, params?: Record<string, any>): Promise<any>;
30
31
  goto(url: string): Promise<void>;
31
32
  evaluate(js: string): Promise<any>;
32
- private normalizeEval;
33
33
  snapshot(opts?: {
34
34
  interactive?: boolean;
35
35
  compact?: boolean;
@@ -90,12 +90,16 @@ declare function diffTabIndexes(initialIdentities: string[], currentTabs: Array<
90
90
  identity: string;
91
91
  }>): number[];
92
92
  declare function appendLimited(current: string, chunk: string, limit: number): string;
93
- declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
93
+ declare function buildMcpArgs(input: {
94
+ mcpPath: string;
95
+ executablePath?: string | null;
96
+ }): string[];
94
97
  export declare const __test__: {
95
98
  createJsonRpcRequest: typeof createJsonRpcRequest;
96
99
  extractTabEntries: typeof extractTabEntries;
97
100
  diffTabIndexes: typeof diffTabIndexes;
98
101
  appendLimited: typeof appendLimited;
99
- withTimeout: typeof withTimeout;
102
+ buildMcpArgs: typeof buildMcpArgs;
103
+ withTimeoutMs: typeof withTimeoutMs;
100
104
  };
101
105
  export {};
package/dist/browser.js CHANGED
@@ -9,14 +9,10 @@ import * as fs from 'node:fs';
9
9
  import * as os from 'node:os';
10
10
  import * as path from 'node:path';
11
11
  import { formatSnapshot } from './snapshotFormatter.js';
12
- // Read version from package.json (single source of truth)
13
- const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
14
- const PKG_VERSION = (() => { try {
15
- return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version;
16
- }
17
- catch {
18
- return '0.0.0';
19
- } })();
12
+ import { PKG_VERSION } from './version.js';
13
+ import { normalizeEvaluateSource } from './pipeline/template.js';
14
+ import { generateInterceptorJs, generateReadInterceptedJs } from './interceptor.js';
15
+ import { withTimeoutMs } from './runtime.js';
20
16
  const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
21
17
  const STDERR_BUFFER_LIMIT = 16 * 1024;
22
18
  const INITIAL_TABS_TIMEOUT_MS = 1500;
@@ -126,26 +122,9 @@ export class Page {
126
122
  }
127
123
  async evaluate(js) {
128
124
  // Normalize IIFE format to function format expected by MCP browser_evaluate
129
- const normalized = this.normalizeEval(js);
125
+ const normalized = normalizeEvaluateSource(js);
130
126
  return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
131
127
  }
132
- normalizeEval(source) {
133
- const s = source.trim();
134
- if (!s)
135
- return '() => undefined';
136
- // IIFE: (async () => {...})() → wrap as () => (...)
137
- if (s.startsWith('(') && s.endsWith(')()'))
138
- return `() => (${s})`;
139
- // Already a function/arrow
140
- if (/^(async\s+)?\([^)]*\)\s*=>/.test(s))
141
- return s;
142
- if (/^(async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=>/.test(s))
143
- return s;
144
- if (s.startsWith('function ') || s.startsWith('async function '))
145
- return s;
146
- // Raw expression → wrap
147
- return `() => (${s})`;
148
- }
149
128
  async snapshot(opts = {}) {
150
129
  const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
151
130
  if (opts.raw)
@@ -224,56 +203,14 @@ export class Page {
224
203
  await this.evaluate(js);
225
204
  }
226
205
  async installInterceptor(pattern) {
227
- const js = `
228
- () => {
229
- window.__opencli_xhr = window.__opencli_xhr || [];
230
- window.__opencli_patterns = window.__opencli_patterns || [];
231
- if (!window.__opencli_patterns.includes('${pattern}')) {
232
- window.__opencli_patterns.push('${pattern}');
233
- }
234
-
235
- if (!window.__patched_xhr) {
236
- const checkMatch = (url) => window.__opencli_patterns.some(p => url.includes(p));
237
-
238
- const XHR = XMLHttpRequest.prototype;
239
- const open = XHR.open;
240
- const send = XHR.send;
241
- XHR.open = function(method, url) {
242
- this._url = url;
243
- return open.call(this, method, url, ...Array.prototype.slice.call(arguments, 2));
244
- };
245
- XHR.send = function() {
246
- this.addEventListener('load', function() {
247
- if (checkMatch(this._url)) {
248
- try { window.__opencli_xhr.push({url: this._url, data: JSON.parse(this.responseText)}); } catch(e){}
249
- }
250
- });
251
- return send.apply(this, arguments);
252
- };
253
-
254
- const origFetch = window.fetch;
255
- window.fetch = async function(...args) {
256
- let u = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
257
- const res = await origFetch.apply(this, args);
258
- setTimeout(async () => {
259
- try {
260
- if (checkMatch(u)) {
261
- const clone = res.clone();
262
- const j = await clone.json();
263
- window.__opencli_xhr.push({url: u, data: j});
264
- }
265
- } catch(e) {}
266
- }, 0);
267
- return res;
268
- };
269
- window.__patched_xhr = true;
270
- }
271
- }
272
- `;
273
- await this.evaluate(js);
206
+ await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
207
+ arrayName: '__opencli_xhr',
208
+ patchGuard: '__opencli_interceptor_patched',
209
+ }));
274
210
  }
275
211
  async getInterceptedRequests() {
276
- return (await this.evaluate('() => window.__opencli_xhr')) || [];
212
+ const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
213
+ return result || [];
277
214
  }
278
215
  }
279
216
  /**
@@ -402,13 +339,13 @@ export class PlaywrightMCP {
402
339
  stderr: stderrBuffer,
403
340
  }));
404
341
  }, timeout * 1000);
405
- const mcpArgs = [mcpPath, '--extension'];
342
+ const mcpArgs = buildMcpArgs({
343
+ mcpPath,
344
+ executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
345
+ });
406
346
  if (process.env.OPENCLI_VERBOSE) {
407
347
  console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
408
348
  }
409
- if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
410
- mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
411
- }
412
349
  debugLog(`Spawning node ${mcpArgs.join(' ')}`);
413
350
  this._proc = spawn('node', mcpArgs, {
414
351
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -485,7 +422,7 @@ export class PlaywrightMCP {
485
422
  this._proc?.stdin?.write(initializedMsg);
486
423
  // Use tabs as a readiness probe and for tab cleanup bookkeeping.
487
424
  debugLog('Fetching initial tabs count...');
488
- withTimeout(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs) => {
425
+ withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs) => {
489
426
  debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
490
427
  this._initialTabIdentities = extractTabIdentities(tabs);
491
428
  settleSuccess(page);
@@ -510,7 +447,7 @@ export class PlaywrightMCP {
510
447
  // Extension mode opens bridge/session tabs that we can clean up best-effort.
511
448
  if (this._page && this._proc && !this._proc.killed) {
512
449
  try {
513
- const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
450
+ const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
514
451
  const tabEntries = extractTabEntries(tabs);
515
452
  const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
516
453
  for (const index of tabsToClose) {
@@ -621,24 +558,20 @@ function appendLimited(current, chunk, limit) {
621
558
  return next;
622
559
  return next.slice(-limit);
623
560
  }
624
- function withTimeout(promise, timeoutMs, message) {
625
- return new Promise((resolve, reject) => {
626
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
627
- promise.then((value) => {
628
- clearTimeout(timer);
629
- resolve(value);
630
- }, (error) => {
631
- clearTimeout(timer);
632
- reject(error);
633
- });
634
- });
561
+ function buildMcpArgs(input) {
562
+ const args = [input.mcpPath, '--extension'];
563
+ if (input.executablePath) {
564
+ args.push('--executable-path', input.executablePath);
565
+ }
566
+ return args;
635
567
  }
636
568
  export const __test__ = {
637
569
  createJsonRpcRequest,
638
570
  extractTabEntries,
639
571
  diffTabIndexes,
640
572
  appendLimited,
641
- withTimeout,
573
+ buildMcpArgs,
574
+ withTimeoutMs,
642
575
  };
643
576
  function findMcpServerPath() {
644
577
  if (_cachedMcpServerPath !== undefined)
@@ -34,8 +34,25 @@ describe('browser helpers', () => {
34
34
  it('keeps only the tail of stderr buffers', () => {
35
35
  expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
36
36
  });
37
+ it('builds Playwright MCP args with kebab-case executable path', () => {
38
+ expect(__test__.buildMcpArgs({
39
+ mcpPath: '/tmp/cli.js',
40
+ executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
41
+ })).toEqual([
42
+ '/tmp/cli.js',
43
+ '--extension',
44
+ '--executable-path',
45
+ '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
46
+ ]);
47
+ expect(__test__.buildMcpArgs({
48
+ mcpPath: '/tmp/cli.js',
49
+ })).toEqual([
50
+ '/tmp/cli.js',
51
+ '--extension',
52
+ ]);
53
+ });
37
54
  it('times out slow promises', async () => {
38
- await expect(__test__.withTimeout(new Promise(() => { }), 10, 'timeout')).rejects.toThrow('timeout');
55
+ await expect(__test__.withTimeoutMs(new Promise(() => { }), 10, 'timeout')).rejects.toThrow('timeout');
39
56
  });
40
57
  });
41
58
  describe('PlaywrightMCP state', () => {
package/dist/cascade.d.ts CHANGED
@@ -28,7 +28,7 @@ interface CascadeResult {
28
28
  * Probe an endpoint with a specific strategy.
29
29
  * Returns whether the probe succeeded and basic response info.
30
30
  */
31
- export declare function probeEndpoint(page: IPage, url: string, strategy: Strategy, opts?: {
31
+ export declare function probeEndpoint(page: IPage, url: string, strategy: Strategy, _opts?: {
32
32
  timeout?: number;
33
33
  }): Promise<ProbeResult>;
34
34
  /**
package/dist/cascade.js CHANGED
@@ -18,34 +18,54 @@ const CASCADE_ORDER = [
18
18
  Strategy.INTERCEPT,
19
19
  Strategy.UI,
20
20
  ];
21
+ /**
22
+ * Build the JavaScript source for a fetch probe.
23
+ * Shared logic for PUBLIC, COOKIE, and HEADER strategies.
24
+ */
25
+ function buildFetchProbeJs(url, opts) {
26
+ const credentialsLine = opts.credentials ? `credentials: 'include',` : '';
27
+ const headerSetup = opts.extractCsrf
28
+ ? `
29
+ const cookies = document.cookie.split(';').map(c => c.trim());
30
+ const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
31
+ const headers = {};
32
+ if (csrf) { headers['X-Csrf-Token'] = csrf; headers['X-XSRF-Token'] = csrf; }
33
+ `
34
+ : 'const headers = {};';
35
+ return `
36
+ async () => {
37
+ try {
38
+ ${headerSetup}
39
+ const resp = await fetch(${JSON.stringify(url)}, {
40
+ ${credentialsLine}
41
+ headers
42
+ });
43
+ const status = resp.status;
44
+ if (!resp.ok) return { status, ok: false };
45
+ const text = await resp.text();
46
+ let hasData = false;
47
+ try {
48
+ const json = JSON.parse(text);
49
+ hasData = !!json && (Array.isArray(json) ? json.length > 0 :
50
+ typeof json === 'object' && Object.keys(json).length > 0);
51
+ // Check for API-level error codes (common in Chinese sites)
52
+ if (json.code !== undefined && json.code !== 0) hasData = false;
53
+ } catch {}
54
+ return { status, ok: true, hasData, preview: text.slice(0, 200) };
55
+ } catch (e) { return { ok: false, error: e.message }; }
56
+ }
57
+ `;
58
+ }
21
59
  /**
22
60
  * Probe an endpoint with a specific strategy.
23
61
  * Returns whether the probe succeeded and basic response info.
24
62
  */
25
- export async function probeEndpoint(page, url, strategy, opts = {}) {
63
+ export async function probeEndpoint(page, url, strategy, _opts = {}) {
26
64
  const result = { strategy, success: false };
27
65
  try {
28
66
  switch (strategy) {
29
67
  case Strategy.PUBLIC: {
30
- // Try direct fetch without browser (no credentials)
31
- const js = `
32
- async () => {
33
- try {
34
- const resp = await fetch(${JSON.stringify(url)});
35
- const status = resp.status;
36
- if (!resp.ok) return { status, ok: false };
37
- const text = await resp.text();
38
- let hasData = false;
39
- try {
40
- const json = JSON.parse(text);
41
- hasData = !!json && (Array.isArray(json) ? json.length > 0 :
42
- typeof json === 'object' && Object.keys(json).length > 0);
43
- } catch {}
44
- return { status, ok: true, hasData, preview: text.slice(0, 200) };
45
- } catch (e) { return { ok: false, error: e.message }; }
46
- }
47
- `;
48
- const resp = await page.evaluate(js);
68
+ const resp = await page.evaluate(buildFetchProbeJs(url, {}));
49
69
  result.statusCode = resp?.status;
50
70
  result.success = resp?.ok && resp?.hasData;
51
71
  result.hasData = resp?.hasData;
@@ -53,27 +73,7 @@ export async function probeEndpoint(page, url, strategy, opts = {}) {
53
73
  break;
54
74
  }
55
75
  case Strategy.COOKIE: {
56
- // Fetch with credentials: 'include' (uses browser cookies)
57
- const js = `
58
- async () => {
59
- try {
60
- const resp = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
61
- const status = resp.status;
62
- if (!resp.ok) return { status, ok: false };
63
- const text = await resp.text();
64
- let hasData = false;
65
- try {
66
- const json = JSON.parse(text);
67
- hasData = !!json && (Array.isArray(json) ? json.length > 0 :
68
- typeof json === 'object' && Object.keys(json).length > 0);
69
- // Check for API-level error codes (common in Chinese sites)
70
- if (json.code !== undefined && json.code !== 0) hasData = false;
71
- } catch {}
72
- return { status, ok: true, hasData, preview: text.slice(0, 200) };
73
- } catch (e) { return { ok: false, error: e.message }; }
74
- }
75
- `;
76
- const resp = await page.evaluate(js);
76
+ const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true }));
77
77
  result.statusCode = resp?.status;
78
78
  result.success = resp?.ok && resp?.hasData;
79
79
  result.hasData = resp?.hasData;
@@ -81,39 +81,7 @@ export async function probeEndpoint(page, url, strategy, opts = {}) {
81
81
  break;
82
82
  }
83
83
  case Strategy.HEADER: {
84
- // Fetch with credentials + try to extract common auth headers
85
- const js = `
86
- async () => {
87
- try {
88
- // Try to extract CSRF tokens from cookies
89
- const cookies = document.cookie.split(';').map(c => c.trim());
90
- const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
91
-
92
- const headers = {};
93
- if (csrf) {
94
- headers['X-Csrf-Token'] = csrf;
95
- headers['X-XSRF-Token'] = csrf;
96
- }
97
-
98
- const resp = await fetch(${JSON.stringify(url)}, {
99
- credentials: 'include',
100
- headers
101
- });
102
- const status = resp.status;
103
- if (!resp.ok) return { status, ok: false };
104
- const text = await resp.text();
105
- let hasData = false;
106
- try {
107
- const json = JSON.parse(text);
108
- hasData = !!json && (Array.isArray(json) ? json.length > 0 :
109
- typeof json === 'object' && Object.keys(json).length > 0);
110
- if (json.code !== undefined && json.code !== 0) hasData = false;
111
- } catch {}
112
- return { status, ok: true, hasData, preview: text.slice(0, 200) };
113
- } catch (e) { return { ok: false, error: e.message }; }
114
- }
115
- `;
116
- const resp = await page.evaluate(js);
84
+ const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true, extractCsrf: true }));
117
85
  result.statusCode = resp?.status;
118
86
  result.success = resp?.ok && resp?.hasData;
119
87
  result.hasData = resp?.hasData;
@@ -123,7 +91,6 @@ export async function probeEndpoint(page, url, strategy, opts = {}) {
123
91
  case Strategy.INTERCEPT:
124
92
  case Strategy.UI:
125
93
  // These require specific implementation per-site
126
- // Mark as needing manual implementation
127
94
  result.success = false;
128
95
  result.error = `Strategy ${strategy} requires site-specific implementation`;
129
96
  break;
@@ -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 {};