@jackwener/opencli 0.5.1 → 0.5.2

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 (52) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  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/constants.d.ts +13 -0
  10. package/dist/constants.js +30 -0
  11. package/dist/engine.js +3 -3
  12. package/dist/engine.test.d.ts +4 -0
  13. package/dist/engine.test.js +67 -0
  14. package/dist/explore.js +1 -15
  15. package/dist/interceptor.d.ts +42 -0
  16. package/dist/interceptor.js +138 -0
  17. package/dist/main.js +1 -4
  18. package/dist/output.js +0 -5
  19. package/dist/pipeline/steps/intercept.js +4 -54
  20. package/dist/pipeline/steps/tap.js +11 -51
  21. package/dist/registry.d.ts +3 -1
  22. package/dist/registry.test.d.ts +4 -0
  23. package/dist/registry.test.js +90 -0
  24. package/dist/runtime.d.ts +15 -1
  25. package/dist/runtime.js +11 -6
  26. package/dist/synthesize.js +5 -5
  27. package/dist/validate.js +21 -0
  28. package/dist/verify.d.ts +7 -0
  29. package/dist/verify.js +7 -1
  30. package/dist/version.d.ts +4 -0
  31. package/dist/version.js +16 -0
  32. package/package.json +1 -1
  33. package/src/browser.test.ts +20 -1
  34. package/src/browser.ts +25 -87
  35. package/src/cascade.ts +47 -75
  36. package/src/constants.ts +35 -0
  37. package/src/engine.test.ts +77 -0
  38. package/src/engine.ts +5 -5
  39. package/src/explore.ts +2 -15
  40. package/src/interceptor.ts +153 -0
  41. package/src/main.ts +1 -5
  42. package/src/output.ts +0 -4
  43. package/src/pipeline/executor.ts +15 -15
  44. package/src/pipeline/steps/intercept.ts +4 -55
  45. package/src/pipeline/steps/tap.ts +12 -51
  46. package/src/registry.test.ts +106 -0
  47. package/src/registry.ts +4 -1
  48. package/src/runtime.ts +22 -8
  49. package/src/synthesize.ts +5 -5
  50. package/src/validate.ts +22 -0
  51. package/src/verify.ts +10 -1
  52. package/src/version.ts +18 -0
@@ -49,8 +49,27 @@ describe('browser helpers', () => {
49
49
  expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
50
50
  });
51
51
 
52
+ it('builds Playwright MCP args with kebab-case executable path', () => {
53
+ expect(__test__.buildMcpArgs({
54
+ mcpPath: '/tmp/cli.js',
55
+ executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
56
+ })).toEqual([
57
+ '/tmp/cli.js',
58
+ '--extension',
59
+ '--executable-path',
60
+ '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
61
+ ]);
62
+
63
+ expect(__test__.buildMcpArgs({
64
+ mcpPath: '/tmp/cli.js',
65
+ })).toEqual([
66
+ '/tmp/cli.js',
67
+ '--extension',
68
+ ]);
69
+ });
70
+
52
71
  it('times out slow promises', async () => {
53
- await expect(__test__.withTimeout(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
72
+ await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
54
73
  });
55
74
  });
56
75
 
package/src/browser.ts CHANGED
@@ -10,10 +10,10 @@ import * as fs from 'node:fs';
10
10
  import * as os from 'node:os';
11
11
  import * as path from 'node:path';
12
12
  import { formatSnapshot } from './snapshotFormatter.js';
13
-
14
- // Read version from package.json (single source of truth)
15
- const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
16
- const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version; } catch { return '0.0.0'; } })();
13
+ import { PKG_VERSION } from './version.js';
14
+ import { normalizeEvaluateSource } from './pipeline/template.js';
15
+ import { generateInterceptorJs, generateReadInterceptedJs } from './interceptor.js';
16
+ import { withTimeoutMs } from './runtime.js';
17
17
 
18
18
  const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
19
19
  const STDERR_BUFFER_LIMIT = 16 * 1024;
@@ -158,23 +158,10 @@ export class Page implements IPage {
158
158
 
159
159
  async evaluate(js: string): Promise<any> {
160
160
  // Normalize IIFE format to function format expected by MCP browser_evaluate
161
- const normalized = this.normalizeEval(js);
161
+ const normalized = normalizeEvaluateSource(js);
162
162
  return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
163
163
  }
164
164
 
165
- private normalizeEval(source: string): string {
166
- const s = source.trim();
167
- if (!s) return '() => undefined';
168
- // IIFE: (async () => {...})() → wrap as () => (...)
169
- if (s.startsWith('(') && s.endsWith(')()')) return `() => (${s})`;
170
- // Already a function/arrow
171
- if (/^(async\s+)?\([^)]*\)\s*=>/.test(s)) return s;
172
- if (/^(async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=>/.test(s)) return s;
173
- if (s.startsWith('function ') || s.startsWith('async function ')) return s;
174
- // Raw expression → wrap
175
- return `() => (${s})`;
176
- }
177
-
178
165
  async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
179
166
  const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
180
167
  if (opts.raw) return raw;
@@ -263,57 +250,15 @@ export class Page implements IPage {
263
250
  }
264
251
 
265
252
  async installInterceptor(pattern: string): Promise<void> {
266
- const js = `
267
- () => {
268
- window.__opencli_xhr = window.__opencli_xhr || [];
269
- window.__opencli_patterns = window.__opencli_patterns || [];
270
- if (!window.__opencli_patterns.includes('${pattern}')) {
271
- window.__opencli_patterns.push('${pattern}');
272
- }
273
-
274
- if (!window.__patched_xhr) {
275
- const checkMatch = (url) => window.__opencli_patterns.some(p => url.includes(p));
276
-
277
- const XHR = XMLHttpRequest.prototype;
278
- const open = XHR.open;
279
- const send = XHR.send;
280
- XHR.open = function(method, url) {
281
- this._url = url;
282
- return open.call(this, method, url, ...Array.prototype.slice.call(arguments, 2));
283
- };
284
- XHR.send = function() {
285
- this.addEventListener('load', function() {
286
- if (checkMatch(this._url)) {
287
- try { window.__opencli_xhr.push({url: this._url, data: JSON.parse(this.responseText)}); } catch(e){}
288
- }
289
- });
290
- return send.apply(this, arguments);
291
- };
292
-
293
- const origFetch = window.fetch;
294
- window.fetch = async function(...args) {
295
- let u = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
296
- const res = await origFetch.apply(this, args);
297
- setTimeout(async () => {
298
- try {
299
- if (checkMatch(u)) {
300
- const clone = res.clone();
301
- const j = await clone.json();
302
- window.__opencli_xhr.push({url: u, data: j});
303
- }
304
- } catch(e) {}
305
- }, 0);
306
- return res;
307
- };
308
- window.__patched_xhr = true;
309
- }
310
- }
311
- `;
312
- await this.evaluate(js);
253
+ await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
254
+ arrayName: '__opencli_xhr',
255
+ patchGuard: '__opencli_interceptor_patched',
256
+ }));
313
257
  }
314
258
 
315
259
  async getInterceptedRequests(): Promise<any[]> {
316
- return (await this.evaluate('() => window.__opencli_xhr')) || [];
260
+ const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
261
+ return result || [];
317
262
  }
318
263
  }
319
264
 
@@ -442,13 +387,13 @@ export class PlaywrightMCP {
442
387
  }));
443
388
  }, timeout * 1000);
444
389
 
445
- const mcpArgs: string[] = [mcpPath, '--extension'];
390
+ const mcpArgs = buildMcpArgs({
391
+ mcpPath,
392
+ executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
393
+ });
446
394
  if (process.env.OPENCLI_VERBOSE) {
447
395
  console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
448
396
  }
449
- if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
450
- mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
451
- }
452
397
  debugLog(`Spawning node ${mcpArgs.join(' ')}`);
453
398
 
454
399
  this._proc = spawn('node', mcpArgs, {
@@ -530,7 +475,7 @@ export class PlaywrightMCP {
530
475
 
531
476
  // Use tabs as a readiness probe and for tab cleanup bookkeeping.
532
477
  debugLog('Fetching initial tabs count...');
533
- withTimeout(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
478
+ withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
534
479
  debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
535
480
  this._initialTabIdentities = extractTabIdentities(tabs);
536
481
  settleSuccess(page);
@@ -555,7 +500,7 @@ export class PlaywrightMCP {
555
500
  // Extension mode opens bridge/session tabs that we can clean up best-effort.
556
501
  if (this._page && this._proc && !this._proc.killed) {
557
502
  try {
558
- const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
503
+ const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
559
504
  const tabEntries = extractTabEntries(tabs);
560
505
  const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
561
506
  for (const index of tabsToClose) {
@@ -664,20 +609,12 @@ function appendLimited(current: string, chunk: string, limit: number): string {
664
609
  return next.slice(-limit);
665
610
  }
666
611
 
667
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
668
- return new Promise<T>((resolve, reject) => {
669
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
670
- promise.then(
671
- (value) => {
672
- clearTimeout(timer);
673
- resolve(value);
674
- },
675
- (error) => {
676
- clearTimeout(timer);
677
- reject(error);
678
- },
679
- );
680
- });
612
+ function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
613
+ const args = [input.mcpPath, '--extension'];
614
+ if (input.executablePath) {
615
+ args.push('--executable-path', input.executablePath);
616
+ }
617
+ return args;
681
618
  }
682
619
 
683
620
  export const __test__ = {
@@ -685,7 +622,8 @@ export const __test__ = {
685
622
  extractTabEntries,
686
623
  diffTabIndexes,
687
624
  appendLimited,
688
- withTimeout,
625
+ buildMcpArgs,
626
+ withTimeoutMs,
689
627
  };
690
628
 
691
629
  function findMcpServerPath(): string | null {
package/src/cascade.ts CHANGED
@@ -37,6 +37,49 @@ interface CascadeResult {
37
37
  confidence: number;
38
38
  }
39
39
 
40
+ /**
41
+ * Build the JavaScript source for a fetch probe.
42
+ * Shared logic for PUBLIC, COOKIE, and HEADER strategies.
43
+ */
44
+ function buildFetchProbeJs(url: string, opts: {
45
+ credentials?: boolean;
46
+ extractCsrf?: boolean;
47
+ }): string {
48
+ const credentialsLine = opts.credentials ? `credentials: 'include',` : '';
49
+ const headerSetup = opts.extractCsrf
50
+ ? `
51
+ const cookies = document.cookie.split(';').map(c => c.trim());
52
+ const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
53
+ const headers = {};
54
+ if (csrf) { headers['X-Csrf-Token'] = csrf; headers['X-XSRF-Token'] = csrf; }
55
+ `
56
+ : 'const headers = {};';
57
+
58
+ return `
59
+ async () => {
60
+ try {
61
+ ${headerSetup}
62
+ const resp = await fetch(${JSON.stringify(url)}, {
63
+ ${credentialsLine}
64
+ headers
65
+ });
66
+ const status = resp.status;
67
+ if (!resp.ok) return { status, ok: false };
68
+ const text = await resp.text();
69
+ let hasData = false;
70
+ try {
71
+ const json = JSON.parse(text);
72
+ hasData = !!json && (Array.isArray(json) ? json.length > 0 :
73
+ typeof json === 'object' && Object.keys(json).length > 0);
74
+ // Check for API-level error codes (common in Chinese sites)
75
+ if (json.code !== undefined && json.code !== 0) hasData = false;
76
+ } catch {}
77
+ return { status, ok: true, hasData, preview: text.slice(0, 200) };
78
+ } catch (e) { return { ok: false, error: e.message }; }
79
+ }
80
+ `;
81
+ }
82
+
40
83
  /**
41
84
  * Probe an endpoint with a specific strategy.
42
85
  * Returns whether the probe succeeded and basic response info.
@@ -45,32 +88,14 @@ export async function probeEndpoint(
45
88
  page: IPage,
46
89
  url: string,
47
90
  strategy: Strategy,
48
- opts: { timeout?: number } = {},
91
+ _opts: { timeout?: number } = {},
49
92
  ): Promise<ProbeResult> {
50
93
  const result: ProbeResult = { strategy, success: false };
51
94
 
52
95
  try {
53
96
  switch (strategy) {
54
97
  case Strategy.PUBLIC: {
55
- // Try direct fetch without browser (no credentials)
56
- const js = `
57
- async () => {
58
- try {
59
- const resp = await fetch(${JSON.stringify(url)});
60
- const status = resp.status;
61
- if (!resp.ok) return { status, ok: false };
62
- const text = await resp.text();
63
- let hasData = false;
64
- try {
65
- const json = JSON.parse(text);
66
- hasData = !!json && (Array.isArray(json) ? json.length > 0 :
67
- typeof json === 'object' && Object.keys(json).length > 0);
68
- } catch {}
69
- return { status, ok: true, hasData, preview: text.slice(0, 200) };
70
- } catch (e) { return { ok: false, error: e.message }; }
71
- }
72
- `;
73
- const resp = await page.evaluate(js);
98
+ const resp = await page.evaluate(buildFetchProbeJs(url, {}));
74
99
  result.statusCode = resp?.status;
75
100
  result.success = resp?.ok && resp?.hasData;
76
101
  result.hasData = resp?.hasData;
@@ -79,27 +104,7 @@ export async function probeEndpoint(
79
104
  }
80
105
 
81
106
  case Strategy.COOKIE: {
82
- // Fetch with credentials: 'include' (uses browser cookies)
83
- const js = `
84
- async () => {
85
- try {
86
- const resp = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
87
- const status = resp.status;
88
- if (!resp.ok) return { status, ok: false };
89
- const text = await resp.text();
90
- let hasData = false;
91
- try {
92
- const json = JSON.parse(text);
93
- hasData = !!json && (Array.isArray(json) ? json.length > 0 :
94
- typeof json === 'object' && Object.keys(json).length > 0);
95
- // Check for API-level error codes (common in Chinese sites)
96
- if (json.code !== undefined && json.code !== 0) hasData = false;
97
- } catch {}
98
- return { status, ok: true, hasData, preview: text.slice(0, 200) };
99
- } catch (e) { return { ok: false, error: e.message }; }
100
- }
101
- `;
102
- const resp = await page.evaluate(js);
107
+ const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true }));
103
108
  result.statusCode = resp?.status;
104
109
  result.success = resp?.ok && resp?.hasData;
105
110
  result.hasData = resp?.hasData;
@@ -108,39 +113,7 @@ export async function probeEndpoint(
108
113
  }
109
114
 
110
115
  case Strategy.HEADER: {
111
- // Fetch with credentials + try to extract common auth headers
112
- const js = `
113
- async () => {
114
- try {
115
- // Try to extract CSRF tokens from cookies
116
- const cookies = document.cookie.split(';').map(c => c.trim());
117
- const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
118
-
119
- const headers = {};
120
- if (csrf) {
121
- headers['X-Csrf-Token'] = csrf;
122
- headers['X-XSRF-Token'] = csrf;
123
- }
124
-
125
- const resp = await fetch(${JSON.stringify(url)}, {
126
- credentials: 'include',
127
- headers
128
- });
129
- const status = resp.status;
130
- if (!resp.ok) return { status, ok: false };
131
- const text = await resp.text();
132
- let hasData = false;
133
- try {
134
- const json = JSON.parse(text);
135
- hasData = !!json && (Array.isArray(json) ? json.length > 0 :
136
- typeof json === 'object' && Object.keys(json).length > 0);
137
- if (json.code !== undefined && json.code !== 0) hasData = false;
138
- } catch {}
139
- return { status, ok: true, hasData, preview: text.slice(0, 200) };
140
- } catch (e) { return { ok: false, error: e.message }; }
141
- }
142
- `;
143
- const resp = await page.evaluate(js);
116
+ const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true, extractCsrf: true }));
144
117
  result.statusCode = resp?.status;
145
118
  result.success = resp?.ok && resp?.hasData;
146
119
  result.hasData = resp?.hasData;
@@ -151,7 +124,6 @@ export async function probeEndpoint(
151
124
  case Strategy.INTERCEPT:
152
125
  case Strategy.UI:
153
126
  // These require specific implementation per-site
154
- // Mark as needing manual implementation
155
127
  result.success = false;
156
128
  result.error = `Strategy ${strategy} requires site-specific implementation`;
157
129
  break;
@@ -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
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Tests for engine.ts: CLI discovery and command execution.
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { discoverClis, executeCommand } from './engine.js';
7
+ import { getRegistry, cli, Strategy } from './registry.js';
8
+
9
+ describe('discoverClis', () => {
10
+ it('handles non-existent directories gracefully', async () => {
11
+ // Should not throw for missing directories
12
+ await expect(discoverClis('/tmp/nonexistent-opencli-test-dir')).resolves.not.toThrow();
13
+ });
14
+ });
15
+
16
+ describe('executeCommand', () => {
17
+ it('executes a command with func', async () => {
18
+ const cmd = cli({
19
+ site: 'test-engine',
20
+ name: 'func-test',
21
+ description: 'test command with func',
22
+ browser: false,
23
+ strategy: Strategy.PUBLIC,
24
+ func: async (_page, kwargs) => {
25
+ return [{ title: kwargs.query ?? 'default' }];
26
+ },
27
+ });
28
+
29
+ const result = await executeCommand(cmd, null, { query: 'hello' });
30
+ expect(result).toEqual([{ title: 'hello' }]);
31
+ });
32
+
33
+ it('executes a command with pipeline', async () => {
34
+ const cmd = cli({
35
+ site: 'test-engine',
36
+ name: 'pipe-test',
37
+ description: 'test command with pipeline',
38
+ browser: false,
39
+ strategy: Strategy.PUBLIC,
40
+ pipeline: [
41
+ { evaluate: '() => [{ n: 1 }, { n: 2 }, { n: 3 }]' },
42
+ { limit: '2' },
43
+ ],
44
+ });
45
+
46
+ // Pipeline commands require page for evaluate step, so we'll test the error path
47
+ await expect(executeCommand(cmd, null, {})).rejects.toThrow();
48
+ });
49
+
50
+ it('throws for command with no func or pipeline', async () => {
51
+ const cmd = cli({
52
+ site: 'test-engine',
53
+ name: 'empty-test',
54
+ description: 'empty command',
55
+ browser: false,
56
+ });
57
+
58
+ await expect(executeCommand(cmd, null, {})).rejects.toThrow('has no func or pipeline');
59
+ });
60
+
61
+ it('passes debug flag to func', async () => {
62
+ let receivedDebug = false;
63
+ const cmd = cli({
64
+ site: 'test-engine',
65
+ name: 'debug-test',
66
+ description: 'debug test',
67
+ browser: false,
68
+ func: async (_page, _kwargs, debug) => {
69
+ receivedDebug = debug ?? false;
70
+ return [];
71
+ },
72
+ });
73
+
74
+ await executeCommand(cmd, null, {}, true);
75
+ expect(receivedDebug).toBe(true);
76
+ });
77
+ });
package/src/engine.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
  import yaml from 'js-yaml';
14
- import { type CliCommand, type Arg, Strategy, registerCommand } from './registry.js';
14
+ import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
15
15
  import type { IPage } from './types.js';
16
16
  import { executePipeline } from './pipeline.js';
17
17
 
@@ -66,7 +66,7 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
66
66
  // The actual module is loaded lazily on first executeCommand().
67
67
  const strategy = (Strategy as any)[(entry.strategy ?? 'cookie').toUpperCase()] ?? Strategy.COOKIE;
68
68
  const modulePath = path.resolve(clisDir, entry.modulePath);
69
- const cmd: CliCommand = {
69
+ const cmd: InternalCliCommand = {
70
70
  site: entry.site,
71
71
  name: entry.name,
72
72
  description: entry.description ?? '',
@@ -77,7 +77,6 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
77
77
  columns: entry.columns,
78
78
  timeoutSeconds: entry.timeout,
79
79
  source: modulePath,
80
- // Mark as lazy — executeCommand will load the module before running
81
80
  _lazy: true,
82
81
  _modulePath: modulePath,
83
82
  };
@@ -170,8 +169,9 @@ export async function executeCommand(
170
169
  debug: boolean = false,
171
170
  ): Promise<any> {
172
171
  // Lazy-load TS module on first execution
173
- if ((cmd as any)._lazy && (cmd as any)._modulePath) {
174
- const modulePath = (cmd as any)._modulePath;
172
+ const internal = cmd as InternalCliCommand;
173
+ if (internal._lazy && internal._modulePath) {
174
+ const modulePath = internal._modulePath;
175
175
  if (!_loadedModules.has(modulePath)) {
176
176
  try {
177
177
  await import(`file://${modulePath}`);
package/src/explore.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import { DEFAULT_BROWSER_EXPLORE_TIMEOUT, browserSession, runWithTimeout } from './runtime.js';
12
+ import { VOLATILE_PARAMS, SEARCH_PARAMS, PAGINATION_PARAMS, LIMIT_PARAMS, FIELD_ROLES } from './constants.js';
12
13
 
13
14
  // ── Site name detection ────────────────────────────────────────────────────
14
15
 
@@ -43,21 +44,7 @@ export function slugify(value: string): string {
43
44
 
44
45
  // ── Field & capability inference ───────────────────────────────────────────
45
46
 
46
- const FIELD_ROLES: Record<string, string[]> = {
47
- title: ['title', 'name', 'text', 'content', 'desc', 'description', 'headline', 'subject'],
48
- url: ['url', 'uri', 'link', 'href', 'permalink', 'jump_url', 'web_url', 'share_url'],
49
- author: ['author', 'username', 'user_name', 'nickname', 'nick', 'owner', 'creator', 'up_name', 'uname'],
50
- score: ['score', 'hot', 'heat', 'likes', 'like_count', 'view_count', 'views', 'play', 'favorite_count', 'reply_count'],
51
- time: ['time', 'created_at', 'publish_time', 'pub_time', 'date', 'ctime', 'mtime', 'pubdate', 'created'],
52
- id: ['id', 'aid', 'bvid', 'mid', 'uid', 'oid', 'note_id', 'item_id'],
53
- cover: ['cover', 'pic', 'image', 'thumbnail', 'poster', 'avatar'],
54
- category: ['category', 'tag', 'type', 'tname', 'channel', 'section'],
55
- };
56
-
57
- const SEARCH_PARAMS = new Set(['q', 'query', 'keyword', 'search', 'wd', 'kw', 'search_query', 'w']);
58
- const PAGINATION_PARAMS = new Set(['page', 'pn', 'offset', 'cursor', 'next', 'page_num']);
59
- const LIMIT_PARAMS = new Set(['limit', 'count', 'size', 'per_page', 'page_size', 'ps', 'num']);
60
- const VOLATILE_PARAMS = new Set(['w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign']);
47
+ // (constants now imported from constants.ts)
61
48
 
62
49
  // ── Network analysis ───────────────────────────────────────────────────────
63
50