@jackwener/opencli 0.3.0 → 0.4.1

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/src/browser.ts CHANGED
@@ -104,8 +104,13 @@ export class Page implements IPage {
104
104
  await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
105
105
  }
106
106
 
107
- async wait(seconds: number): Promise<void> {
108
- await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: seconds } });
107
+ async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
108
+ if (typeof options === 'number') {
109
+ await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
110
+ } else {
111
+ // Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
112
+ await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
113
+ }
109
114
  }
110
115
 
111
116
  async tabs(): Promise<any> {
@@ -139,10 +144,32 @@ export class Page implements IPage {
139
144
  async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
140
145
  const times = options.times ?? 3;
141
146
  const delayMs = options.delayMs ?? 2000;
142
- for (let i = 0; i < times; i++) {
143
- await this.evaluate('() => window.scrollTo(0, document.body.scrollHeight)');
144
- await this.wait(delayMs / 1000);
145
- }
147
+ const js = `
148
+ async () => {
149
+ const maxTimes = ${times};
150
+ const maxWaitMs = ${delayMs};
151
+ for (let i = 0; i < maxTimes; i++) {
152
+ const lastHeight = document.body.scrollHeight;
153
+ window.scrollTo(0, lastHeight);
154
+ await new Promise(resolve => {
155
+ let timeoutId;
156
+ const observer = new MutationObserver(() => {
157
+ if (document.body.scrollHeight > lastHeight) {
158
+ clearTimeout(timeoutId);
159
+ observer.disconnect();
160
+ setTimeout(resolve, 100); // Small debounce for rendering
161
+ }
162
+ });
163
+ observer.observe(document.body, { childList: true, subtree: true });
164
+ timeoutId = setTimeout(() => {
165
+ observer.disconnect();
166
+ resolve(null);
167
+ }, maxWaitMs);
168
+ });
169
+ }
170
+ }
171
+ `;
172
+ await this.evaluate(js);
146
173
  }
147
174
 
148
175
  async installInterceptor(pattern: string): Promise<void> {
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build-time CLI manifest compiler.
4
+ *
5
+ * Scans all YAML/TS CLI definitions and pre-compiles them into a single
6
+ * manifest.json for instant cold-start registration (no runtime YAML parsing).
7
+ *
8
+ * Usage: npx tsx src/build-manifest.ts
9
+ * Output: dist/cli-manifest.json
10
+ */
11
+
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import yaml from 'js-yaml';
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const CLIS_DIR = path.resolve(__dirname, 'clis');
19
+ const OUTPUT = path.resolve(__dirname, '..', 'dist', 'cli-manifest.json');
20
+
21
+ interface ManifestEntry {
22
+ site: string;
23
+ name: string;
24
+ description: string;
25
+ domain?: string;
26
+ strategy: string;
27
+ browser: boolean;
28
+ args: Array<{
29
+ name: string;
30
+ type?: string;
31
+ default?: any;
32
+ required?: boolean;
33
+ help?: string;
34
+ choices?: string[];
35
+ }>;
36
+ columns?: string[];
37
+ pipeline?: any[];
38
+ timeout?: number;
39
+ /** 'yaml' or 'ts' — determines how executeCommand loads the handler */
40
+ type: 'yaml' | 'ts';
41
+ /** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */
42
+ modulePath?: string;
43
+ }
44
+
45
+ function scanYaml(filePath: string, site: string): ManifestEntry | null {
46
+ try {
47
+ const raw = fs.readFileSync(filePath, 'utf-8');
48
+ const def = yaml.load(raw) as any;
49
+ if (!def || typeof def !== 'object') return null;
50
+
51
+ const strategyStr = def.strategy ?? (def.browser === false ? 'public' : 'cookie');
52
+ const strategy = strategyStr.toUpperCase();
53
+ const browser = def.browser ?? (strategy !== 'PUBLIC');
54
+
55
+ const args: ManifestEntry['args'] = [];
56
+ if (def.args && typeof def.args === 'object') {
57
+ for (const [argName, argDef] of Object.entries(def.args as Record<string, any>)) {
58
+ args.push({
59
+ name: argName,
60
+ type: argDef?.type ?? 'str',
61
+ default: argDef?.default,
62
+ required: argDef?.required ?? false,
63
+ help: argDef?.description ?? argDef?.help ?? '',
64
+ choices: argDef?.choices,
65
+ });
66
+ }
67
+ }
68
+
69
+ return {
70
+ site: def.site ?? site,
71
+ name: def.name ?? path.basename(filePath, path.extname(filePath)),
72
+ description: def.description ?? '',
73
+ domain: def.domain,
74
+ strategy: strategy.toLowerCase(),
75
+ browser,
76
+ args,
77
+ columns: def.columns,
78
+ pipeline: def.pipeline,
79
+ timeout: def.timeout,
80
+ type: 'yaml',
81
+ };
82
+ } catch (err: any) {
83
+ process.stderr.write(`Warning: failed to parse ${filePath}: ${err.message}\n`);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function scanTs(filePath: string, site: string): ManifestEntry {
89
+ // TS adapters self-register via cli() at import time.
90
+ // We record their module path for lazy dynamic import.
91
+ const baseName = path.basename(filePath, path.extname(filePath));
92
+ const relativePath = `${site}/${baseName}.js`;
93
+ return {
94
+ site,
95
+ name: baseName,
96
+ description: '',
97
+ strategy: 'cookie',
98
+ browser: true,
99
+ args: [],
100
+ type: 'ts',
101
+ modulePath: relativePath,
102
+ };
103
+ }
104
+
105
+ // Main
106
+ const manifest: ManifestEntry[] = [];
107
+
108
+ if (fs.existsSync(CLIS_DIR)) {
109
+ for (const site of fs.readdirSync(CLIS_DIR)) {
110
+ const siteDir = path.join(CLIS_DIR, site);
111
+ if (!fs.statSync(siteDir).isDirectory()) continue;
112
+ for (const file of fs.readdirSync(siteDir)) {
113
+ const filePath = path.join(siteDir, file);
114
+ if (file.endsWith('.yaml') || file.endsWith('.yml')) {
115
+ const entry = scanYaml(filePath, site);
116
+ if (entry) manifest.push(entry);
117
+ } else if (
118
+ (file.endsWith('.ts') && !file.endsWith('.d.ts') && file !== 'index.ts') ||
119
+ (file.endsWith('.js') && !file.endsWith('.d.js') && file !== 'index.js')
120
+ ) {
121
+ manifest.push(scanTs(filePath, site));
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ // Ensure output directory exists
128
+ fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
129
+ fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
130
+
131
+ const yamlCount = manifest.filter(e => e.type === 'yaml').length;
132
+ const tsCount = manifest.filter(e => e.type === 'ts').length;
133
+ console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);
@@ -0,0 +1,50 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+ import { fetchJson, getSelfUid, resolveUid } from '../../bilibili.js';
4
+
5
+ cli({
6
+ site: 'bilibili',
7
+ name: 'following',
8
+ description: '获取 Bilibili 用户的关注列表',
9
+ strategy: Strategy.COOKIE,
10
+ args: [
11
+ { name: 'uid', required: false, help: '目标用户 ID(默认为当前登录用户)' },
12
+ { name: 'page', type: 'int', required: false, default: 1, help: '页码' },
13
+ { name: 'limit', type: 'int', required: false, default: 50, help: '每页数量 (最大 50)' },
14
+ ],
15
+ columns: ['mid', 'name', 'sign', 'following', 'fans'],
16
+ func: async (page: IPage | null, kwargs: any) => {
17
+ if (!page) throw new Error('Requires browser');
18
+
19
+ // 1. Resolve UID (default to self)
20
+ const uid = kwargs.uid
21
+ ? await resolveUid(page, kwargs.uid)
22
+ : await getSelfUid(page);
23
+
24
+ const pn = kwargs.page ?? 1;
25
+ const ps = Math.min(kwargs.limit ?? 50, 50);
26
+
27
+ // 2. Fetch following list (standard Cookie API, no Wbi signing needed)
28
+ const payload = await fetchJson(page,
29
+ `https://api.bilibili.com/x/relation/followings?vmid=${uid}&pn=${pn}&ps=${ps}&order=desc`
30
+ );
31
+
32
+ if (payload.code !== 0) {
33
+ throw new Error(`获取关注列表失败: ${payload.message} (${payload.code})`);
34
+ }
35
+
36
+ const list = payload.data?.list || [];
37
+ if (list.length === 0) {
38
+ return [{ mid: '-', name: `共 ${payload.data?.total ?? 0} 人关注,当前页无数据`, sign: '', following: '', fans: '' }];
39
+ }
40
+
41
+ // 3. Map to output
42
+ return list.map((u: any) => ({
43
+ mid: u.mid,
44
+ name: u.uname,
45
+ sign: (u.sign || '').slice(0, 40),
46
+ following: u.attribute === 6 ? '互相关注' : '已关注',
47
+ fans: u.official_verify?.desc || '',
48
+ }));
49
+ },
50
+ });
@@ -0,0 +1,100 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+ import { apiGet } from '../../bilibili.js';
4
+
5
+ cli({
6
+ site: 'bilibili',
7
+ name: 'subtitle',
8
+ description: '获取 Bilibili 视频的字幕',
9
+ strategy: Strategy.COOKIE,
10
+ args: [
11
+ { name: 'bvid', required: true },
12
+ { name: 'lang', required: false, help: '字幕语言代码 (如 zh-CN, en-US, ai-zh),默认取第一个' },
13
+ ],
14
+ columns: ['index', 'from', 'to', 'content'],
15
+ func: async (page: IPage | null, kwargs: any) => {
16
+ if (!page) throw new Error('Requires browser');
17
+ // 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
18
+ await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
19
+
20
+ // 2. 利用 __INITIAL_STATE__ 获取基础信息,拿 CID
21
+ const cid = await page.evaluate(`(async () => {
22
+ const state = window.__INITIAL_STATE__ || {};
23
+ return state?.videoData?.cid;
24
+ })()`);
25
+
26
+ if (!cid) {
27
+ throw new Error('无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
28
+ }
29
+
30
+ // 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
31
+ // 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
32
+ const payload = await apiGet(page, '/x/player/wbi/v2', {
33
+ params: { bvid: kwargs.bvid, cid },
34
+ signed: true, // 开启 wbi_sign 自动签名
35
+ });
36
+
37
+ if (payload.code !== 0) {
38
+ throw new Error(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
39
+ }
40
+
41
+ const subtitles = payload.data?.subtitle?.subtitles || [];
42
+ if (subtitles.length === 0) {
43
+ throw new Error('此视频没有发现外挂或智能字幕。');
44
+ }
45
+
46
+ // 4. 选择目标字幕语言
47
+ const target = kwargs.lang
48
+ ? subtitles.find((s: any) => s.lan === kwargs.lang) || subtitles[0]
49
+ : subtitles[0];
50
+
51
+ const targetSubUrl = target.subtitle_url;
52
+ if (!targetSubUrl || targetSubUrl === '') {
53
+ throw new Error('[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
54
+ }
55
+
56
+ const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
57
+
58
+
59
+ // 5. 解析并拉取 CDN 的 JSON 文件
60
+ const fetchJs = `
61
+ (async () => {
62
+ const url = ${JSON.stringify(finalUrl)};
63
+ const res = await fetch(url);
64
+ const text = await res.text();
65
+
66
+ if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) {
67
+ return { error: 'HTML', text: text.substring(0, 100), url };
68
+ }
69
+
70
+ try {
71
+ const subJson = JSON.parse(text);
72
+ // B站真实返回格式是 { font_size: 0.4, font_color: "#FFFFFF", background_alpha: 0.5, background_color: "#9C27B0", Stroke: "none", type: "json" , body: [{from: 0, to: 0, content: ""}] }
73
+ if (Array.isArray(subJson?.body)) return { success: true, data: subJson.body };
74
+ if (Array.isArray(subJson)) return { success: true, data: subJson };
75
+ return { error: 'UNKNOWN_JSON', data: subJson };
76
+ } catch (e) {
77
+ return { error: 'PARSE_FAILED', text: text.substring(0, 100) };
78
+ }
79
+ })()
80
+ `;
81
+ const items = await page.evaluate(fetchJs);
82
+
83
+ if (items?.error) {
84
+ throw new Error(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
85
+ }
86
+
87
+ const finalItems = items?.data || [];
88
+ if (!Array.isArray(finalItems)) {
89
+ throw new Error('解析到的字幕列表对象不符合数组格式');
90
+ }
91
+
92
+ // 6. 数据映射
93
+ return finalItems.map((item: any, idx: number) => ({
94
+ index: idx + 1,
95
+ from: Number(item.from || 0).toFixed(2) + 's',
96
+ to: Number(item.to || 0).toFixed(2) + 's',
97
+ content: item.content
98
+ }));
99
+ },
100
+ });
package/src/engine.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  /**
2
2
  * CLI discovery: finds YAML/TS CLI definitions and registers them.
3
+ *
4
+ * Supports two modes:
5
+ * 1. FAST PATH (manifest): If a pre-compiled cli-manifest.json exists,
6
+ * registers all YAML commands instantly without runtime YAML parsing.
7
+ * TS modules are loaded lazily only when their command is executed.
8
+ * 2. FALLBACK (filesystem scan): Traditional runtime discovery for development.
3
9
  */
4
10
 
5
11
  import * as fs from 'node:fs';
@@ -9,25 +15,99 @@ import { type CliCommand, type Arg, Strategy, registerCommand } from './registry
9
15
  import type { IPage } from './types.js';
10
16
  import { executePipeline } from './pipeline.js';
11
17
 
18
+ /** Set of TS module paths that have been loaded */
19
+ const _loadedModules = new Set<string>();
20
+
21
+ /**
22
+ * Discover and register CLI commands.
23
+ * Uses pre-compiled manifest when available for instant startup.
24
+ */
12
25
  export async function discoverClis(...dirs: string[]): Promise<void> {
13
- const promises: Promise<any>[] = [];
26
+ // Fast path: try manifest first (production / post-build)
14
27
  for (const dir of dirs) {
15
- if (!fs.existsSync(dir)) continue;
16
- for (const site of fs.readdirSync(dir)) {
17
- const siteDir = path.join(dir, site);
18
- if (!fs.statSync(siteDir).isDirectory()) continue;
19
- for (const file of fs.readdirSync(siteDir)) {
20
- const filePath = path.join(siteDir, file);
21
- if (file.endsWith('.yaml') || file.endsWith('.yml')) {
22
- registerYamlCli(filePath, site);
23
- } else if (file.endsWith('.js')) {
24
- // Dynamic import of compiled adapter modules
25
- promises.push(
26
- import(`file://${filePath}`).catch((err: any) => {
27
- process.stderr.write(`Warning: failed to load module ${filePath}: ${err.message}\n`);
28
- })
29
- );
30
- }
28
+ const manifestPath = path.resolve(dir, '..', 'cli-manifest.json');
29
+ if (fs.existsSync(manifestPath)) {
30
+ loadFromManifest(manifestPath, dir);
31
+ continue; // Skip filesystem scan for this directory
32
+ }
33
+ // Fallback: runtime filesystem scan (development)
34
+ await discoverClisFromFs(dir);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Fast-path: register commands from pre-compiled manifest.
40
+ * YAML pipelines are inlined — zero YAML parsing at runtime.
41
+ * TS modules are deferred — loaded lazily on first execution.
42
+ */
43
+ function loadFromManifest(manifestPath: string, clisDir: string): void {
44
+ try {
45
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as any[];
46
+ for (const entry of manifest) {
47
+ if (entry.type === 'yaml') {
48
+ // YAML pipelines fully inlined in manifest — register directly
49
+ const strategy = (Strategy as any)[entry.strategy.toUpperCase()] ?? Strategy.COOKIE;
50
+ const cmd: CliCommand = {
51
+ site: entry.site,
52
+ name: entry.name,
53
+ description: entry.description ?? '',
54
+ domain: entry.domain,
55
+ strategy,
56
+ browser: entry.browser,
57
+ args: entry.args ?? [],
58
+ columns: entry.columns,
59
+ pipeline: entry.pipeline,
60
+ timeoutSeconds: entry.timeout,
61
+ source: `manifest:${entry.site}/${entry.name}`,
62
+ };
63
+ registerCommand(cmd);
64
+ } else if (entry.type === 'ts' && entry.modulePath) {
65
+ // TS adapters: register a lightweight stub.
66
+ // The actual module is loaded lazily on first executeCommand().
67
+ const strategy = (Strategy as any)[(entry.strategy ?? 'cookie').toUpperCase()] ?? Strategy.COOKIE;
68
+ const modulePath = path.resolve(clisDir, entry.modulePath);
69
+ const cmd: CliCommand = {
70
+ site: entry.site,
71
+ name: entry.name,
72
+ description: entry.description ?? '',
73
+ domain: entry.domain,
74
+ strategy,
75
+ browser: entry.browser ?? true,
76
+ args: entry.args ?? [],
77
+ columns: entry.columns,
78
+ timeoutSeconds: entry.timeout,
79
+ source: modulePath,
80
+ // Mark as lazy — executeCommand will load the module before running
81
+ _lazy: true,
82
+ _modulePath: modulePath,
83
+ };
84
+ registerCommand(cmd);
85
+ }
86
+ }
87
+ } catch (err: any) {
88
+ process.stderr.write(`Warning: failed to load manifest ${manifestPath}: ${err.message}\n`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Fallback: traditional filesystem scan (used during development with tsx).
94
+ */
95
+ async function discoverClisFromFs(dir: string): Promise<void> {
96
+ if (!fs.existsSync(dir)) return;
97
+ const promises: Promise<any>[] = [];
98
+ for (const site of fs.readdirSync(dir)) {
99
+ const siteDir = path.join(dir, site);
100
+ if (!fs.statSync(siteDir).isDirectory()) continue;
101
+ for (const file of fs.readdirSync(siteDir)) {
102
+ const filePath = path.join(siteDir, file);
103
+ if (file.endsWith('.yaml') || file.endsWith('.yml')) {
104
+ registerYamlCli(filePath, site);
105
+ } else if (file.endsWith('.js') && !file.endsWith('.d.js')) {
106
+ promises.push(
107
+ import(`file://${filePath}`).catch((err: any) => {
108
+ process.stderr.write(`Warning: failed to load module ${filePath}: ${err.message}\n`);
109
+ })
110
+ );
31
111
  }
32
112
  }
33
113
  }
@@ -80,12 +160,38 @@ function registerYamlCli(filePath: string, defaultSite: string): void {
80
160
  }
81
161
  }
82
162
 
163
+ /**
164
+ * Execute a CLI command. Handles lazy-loading of TS modules.
165
+ */
83
166
  export async function executeCommand(
84
167
  cmd: CliCommand,
85
168
  page: IPage | null,
86
169
  kwargs: Record<string, any>,
87
170
  debug: boolean = false,
88
171
  ): Promise<any> {
172
+ // Lazy-load TS module on first execution
173
+ if ((cmd as any)._lazy && (cmd as any)._modulePath) {
174
+ const modulePath = (cmd as any)._modulePath;
175
+ if (!_loadedModules.has(modulePath)) {
176
+ try {
177
+ await import(`file://${modulePath}`);
178
+ _loadedModules.add(modulePath);
179
+ } catch (err: any) {
180
+ throw new Error(`Failed to load adapter module ${modulePath}: ${err.message}`);
181
+ }
182
+ }
183
+ // After loading, the module's cli() call will have updated the registry
184
+ // with the real func/pipeline. Re-fetch the command.
185
+ const { getRegistry, fullName } = await import('./registry.js');
186
+ const updated = getRegistry().get(fullName(cmd));
187
+ if (updated && updated.func) {
188
+ return updated.func(page, kwargs, debug);
189
+ }
190
+ if (updated && updated.pipeline) {
191
+ return executePipeline(page, updated.pipeline, { args: kwargs, debug });
192
+ }
193
+ }
194
+
89
195
  if (cmd.func) {
90
196
  return cmd.func(page, kwargs, debug);
91
197
  }
package/src/explore.ts CHANGED
@@ -184,6 +184,8 @@ function scoreEndpoint(ep: { contentType: string; responseAnalysis: any; pattern
184
184
  if (ep.hasPaginationParam) s += 2;
185
185
  if (ep.hasLimitParam) s += 2;
186
186
  if (ep.status === 200) s += 2;
187
+ // Anti-Bot Empty Value Detection: penalize JSON endpoints returning empty data
188
+ if (ep.responseAnalysis && ep.responseAnalysis.itemCount === 0 && ep.contentType.includes('json')) s -= 3;
187
189
  return s;
188
190
  }
189
191
 
@@ -277,6 +279,30 @@ export interface DiscoveredStore {
277
279
  stateKeys: string[];
278
280
  }
279
281
 
282
+ // ── Auto-Interaction (Fuzzing) ─────────────────────────────────────────────
283
+
284
+ const INTERACT_FUZZ_JS = `
285
+ async () => {
286
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
287
+ const clickables = Array.from(document.querySelectorAll(
288
+ 'button, [role="button"], [role="tab"], .tab, .btn, a[href="javascript:void(0)"], a[href="#"]'
289
+ )).slice(0, 15); // limit to 15 to avoid endless loops
290
+
291
+ let clicked = 0;
292
+ for (const el of clickables) {
293
+ try {
294
+ const rect = el.getBoundingClientRect();
295
+ if (rect.width > 0 && rect.height > 0) {
296
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
297
+ clicked++;
298
+ await sleep(300); // give it time to trigger network
299
+ }
300
+ } catch {}
301
+ }
302
+ return clicked;
303
+ }
304
+ `;
305
+
280
306
  // ── Main explore function ──────────────────────────────────────────────────
281
307
 
282
308
  export async function exploreUrl(
@@ -300,6 +326,31 @@ export async function exploreUrl(
300
326
  // Step 2: Auto-scroll to trigger lazy loading (use keyboard since page.scroll may not exist)
301
327
  for (let i = 0; i < 3; i++) { try { await page.pressKey('End'); } catch {} await page.wait(1); }
302
328
 
329
+ // Step 2.5: Interactive Fuzzing (if requested)
330
+ if (opts.auto) {
331
+ try {
332
+ // First: targeted clicks by label (e.g. "字幕", "CC", "评论")
333
+ if (opts.clickLabels?.length) {
334
+ for (const label of opts.clickLabels) {
335
+ const safeLabel = label.replace(/'/g, "\\'");
336
+ await page.evaluate(`
337
+ (() => {
338
+ const el = [...document.querySelectorAll('button, [role="button"], [role="tab"], a, span')]
339
+ .find(e => e.textContent && e.textContent.trim().includes('${safeLabel}'));
340
+ if (el) el.click();
341
+ })()
342
+ `);
343
+ await page.wait(1);
344
+ }
345
+ }
346
+ // Then: blind fuzzing on generic interactive elements
347
+ const clicks = await page.evaluate(INTERACT_FUZZ_JS);
348
+ await page.wait(2); // wait for XHRs to settle
349
+ } catch (e) {
350
+ // fuzzing is best-effort, don't fail the whole explore
351
+ }
352
+ }
353
+
303
354
  // Step 3: Read page metadata
304
355
  const metadata = await readPageMetadata(page);
305
356
 
package/src/main.ts CHANGED
@@ -53,8 +53,8 @@ program.command('validate').description('Validate CLI definitions').argument('[t
53
53
  program.command('verify').description('Validate + smoke test').argument('[target]').option('--smoke', 'Run smoke tests', false)
54
54
  .action(async (target, opts) => { const { verifyClis, renderVerifyReport } = await import('./verify.js'); const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke }); console.log(renderVerifyReport(r)); process.exitCode = r.ok ? 0 : 1; });
55
55
 
56
- program.command('explore').alias('probe').description('Explore a website: discover APIs, stores, and recommend strategies').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3')
57
- .action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: PlaywrightMCP, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait) }))); });
56
+ program.command('explore').alias('probe').description('Explore a website: discover APIs, stores, and recommend strategies').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3').option('--auto', 'Enable interactive fuzzing (simulate clicks to trigger lazy APIs)').option('--click <labels>', 'Comma-separated labels to click before fuzzing (e.g. "字幕,CC,评论")')
57
+ .action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); const clickLabels = opts.click ? opts.click.split(',').map((s: string) => s.trim()) : undefined; console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: PlaywrightMCP, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait), auto: opts.auto, clickLabels }))); });
58
58
 
59
59
  program.command('synthesize').description('Synthesize CLIs from explore').argument('<target>').option('--top <n>', '', '3')
60
60
  .action(async (target, opts) => { const { synthesizeFromExplore, renderSynthesizeSummary } = await import('./synthesize.js'); console.log(renderSynthesizeSummary(synthesizeFromExplore(target, { top: parseInt(opts.top) }))); });
@@ -31,13 +31,10 @@ export async function stepWait(page: IPage, params: any, data: any, args: Record
31
31
  if (typeof params === 'number') await page.wait(params);
32
32
  else if (typeof params === 'object' && params) {
33
33
  if ('text' in params) {
34
- const timeout = params.timeout ?? 10;
35
- const start = Date.now();
36
- while ((Date.now() - start) / 1000 < timeout) {
37
- const snap = await page.snapshot({ raw: true });
38
- if (typeof snap === 'string' && snap.includes(params.text)) break;
39
- await page.wait(0.5);
40
- }
34
+ await page.wait({
35
+ text: String(render(params.text, { args, data })),
36
+ timeout: params.timeout
37
+ });
41
38
  } else if ('time' in params) await page.wait(Number(params.time));
42
39
  } else if (typeof params === 'string') await page.wait(Number(render(params, { args, data })));
43
40
  return data;