@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/CLI-CREATOR.md CHANGED
@@ -3,6 +3,94 @@
3
3
  > 本文档教你(或 AI Agent)如何为 OpenCLI 添加一个新网站的命令。
4
4
  > 从零到发布,覆盖 API 发现、方案选择、适配器编写、测试验证全流程。
5
5
 
6
+ ---
7
+
8
+ ## ⚠️ AI Agent 开发者必读:用 Playwright MCP Bridge 探索
9
+
10
+ > [!CAUTION]
11
+ > **你(AI Agent)必须通过 Playwright MCP Bridge 打开浏览器去访问目标网站!**
12
+ > 不要只靠 `opencli explore` 命令或静态分析来发现 API。
13
+ > 你拥有 Playwright MCP 工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
14
+
15
+ ### 为什么?
16
+
17
+ 很多 API 是**懒加载**的(用户必须点击某个按钮/标签才会触发网络请求)。字幕、评论、关注列表等深层数据不会在页面首次加载时出现在 Network 面板中。**如果你不主动去浏览和交互页面,你永远发现不了这些 API。**
18
+
19
+ ### AI Agent 探索工作流(必须遵循)
20
+
21
+ ```
22
+ Step 0: 用 Playwright MCP 打开浏览器
23
+
24
+ Step 1: 导航到目标页面,观察页面结构
25
+
26
+ Step 2: 查看 Network 请求(browser_network_requests)
27
+
28
+ Step 3: 模拟用户交互(点击按钮/标签/展开评论)
29
+
30
+ Step 4: 再次查看 Network,发现新触发的 API
31
+
32
+ Step 5: 分析 API 的请求参数、响应结构、鉴权方式
33
+
34
+ Step 6: 编写适配器代码
35
+ ```
36
+
37
+ ### 具体操作步骤
38
+
39
+ **Step 0: 打开浏览器**
40
+ ```
41
+ 工具: browser_navigate
42
+ URL: https://www.bilibili.com/video/BV1xxxxx
43
+ ```
44
+
45
+ **Step 1: 获取页面快照,了解页面结构**
46
+ ```
47
+ 工具: browser_snapshot
48
+ → 观察页面上有哪些可交互元素(按钮、标签、链接)
49
+ ```
50
+
51
+ **Step 2: 查看已有的网络请求**
52
+ ```
53
+ 工具: browser_network_requests
54
+ → 筛选出 JSON API 端点(忽略静态资源)
55
+ → 记录 URL pattern、请求头、响应结构
56
+ ```
57
+
58
+ **Step 3: 模拟用户交互发现深层 API**
59
+ ```
60
+ 工具: browser_click (点击"字幕"按钮、"评论"标签、"关注"链接等)
61
+ 工具: browser_wait_for (等待数据加载)
62
+ ```
63
+
64
+ **Step 4: 再次抓包,发现新 API**
65
+ ```
66
+ 工具: browser_network_requests
67
+ → 对比 Step 2,找出新触发的 API 端点
68
+ ```
69
+
70
+ **Step 5: 用 evaluate 测试 API 可行性**
71
+ ```
72
+ 工具: browser_evaluate
73
+ 代码: async () => {
74
+ const res = await fetch('https://api.bilibili.com/x/player/wbi/v2?bvid=BV1xxx&cid=123',
75
+ { credentials: 'include' });
76
+ return await res.json();
77
+ }
78
+ → 验证返回的数据结构和字段
79
+ → 如果返回空/403:检查是否需要签名(Wbi)或特殊 Header
80
+ ```
81
+
82
+ ### 常犯错误
83
+
84
+ | ❌ 错误做法 | ✅ 正确做法 |
85
+ |------------|------------|
86
+ | 只用 `opencli explore` 命令,等结果自动出来 | 用 MCP Bridge 打开浏览器,主动浏览页面 |
87
+ | 直接在代码里 `fetch(url)`,不看浏览器实际请求 | 先在浏览器中确认 API 可用,再写代码 |
88
+ | 页面打开后直接抓包,期望所有 API 都出现 | 模拟点击交互(展开评论/切换标签/加载更多) |
89
+ | 遇到 HTTP 200 但空数据就放弃 | 检查是否需要 Wbi 签名或 Cookie 鉴权 |
90
+ | 完全依赖 `__INITIAL_STATE__` 拿所有数据 | `__INITIAL_STATE__` 只有首屏数据,深层数据要调 API |
91
+
92
+ ---
93
+
6
94
  ## 核心流程
7
95
 
8
96
  ```
@@ -57,8 +145,9 @@ opencli bilibili hot -v # 查看已有命令的 pipeline 每步数据流
57
145
 
58
146
  1. **后缀爆破法 (`.json`)**: 像 Reddit 这样复杂的网站,只要在其 URL 后加上 `.json`(例如 `/r/all.json`),就能在带 Cookie 的情况下直接利用 `fetch` 拿到极其干净的 REST 数据(Tier 2 Cookie 策略极速秒杀)。另外如功能完备的**雪球 (xueqiu)** 也可以走这种纯 API 的方式极简获取,成为你构建简单 YAML 的黄金标杆。
59
147
  2. **全局状态查找法 (`__INITIAL_STATE__`)**: 许多服务端渲染 (SSR) 的网站(如小红书、Bilibili)会将首页或详情页的完整数据挂载到全局 window 对象上。与其去拦截网络请求,不如直接 `page.evaluate('() => window.__INITIAL_STATE__')` 获取整个数据树。
60
- 3. **框架探测与 Store Action 截断**: 如果站点使用 Vue + Pinia,可以使用 `tap` 步骤调用 action,让前端框架代替你完成复杂的鉴权签名封装。
61
- 4. **底层 XHR/Fetch 拦截**: 最后手段,当上述都不行时,使用 TypeScript 适配器进行无侵入式的请求抓取。
148
+ 3. **主动交互触发法 (Active Interaction)**: 很多深层 API(如视频字幕、评论下的回复)是懒加载的。在静态抓包找不到数据时,尝试在 `evaluate` 步骤或手动打断点时,主动去**点击(Click)页面上的对应按钮**(如"CC"、"展开全部"),从而诱发隐藏的 Network Fetch。
149
+ 4. **框架探测与 Store Action 截断**: 如果站点使用 Vue + Pinia,可以使用 `tap` 步骤调用 action,让前端框架代替你完成复杂的鉴权签名封装。
150
+ 5. **底层 XHR/Fetch 拦截**: 最后手段,当上述都不行时,使用 TypeScript 适配器进行无侵入式的请求抓取。
62
151
 
63
152
  ### 1d. 框架检测
64
153
 
@@ -411,6 +500,35 @@ cli({
411
500
 
412
501
  > **拦截核心思路**:不自己构造签名,而是利用 `installInterceptor` 劫持网站自己的 `XMLHttpRequest` 和 `fetch`,让网站发请求,我们直接在底层取出解析好的 `response.json()`。
413
502
 
503
+ #### 进阶场景 1: 级联请求 (Cascading Requests) 与鉴权绕过
504
+
505
+ 部分 API 获取是非常复杂的连环请求(例如 B 站获取视频字幕:先需要 `bvid` 获取核心 `cid`,再通过 `cid` 获取包含签名/Wbi 的字幕列表拉取地址,最后 fetch 真实的 CDN 资源)。在此类场景中,你必须在一个 `evaluate` 块内部或者在 TypeScript Node 端编排整个请求链条:
506
+
507
+ ```typescript
508
+ // 真实场景:B站获取视频字幕的级联获取思路
509
+ const subtitleUrls = await page.evaluate(async (bvid) => {
510
+ // Step 1: 拿 CID (通常可以通过页面全局状态极速提取)
511
+ const cid = window.__INITIAL_STATE__?.videoData?.cid;
512
+
513
+ // Step 2: 依据 BVID 和 CID 拿字幕配置 (可能需要携带 W_RID 签名或依赖浏览器当前登录状态 Cookie)
514
+ const res = await fetch(\`/x/player/wbi/v2?bvid=\${bvid}&cid=\${cid}\`, { credentials: 'include' });
515
+ const data = await res.json();
516
+
517
+ // Step 3: 风控拦截/未登录降级空值检测 (Anti-Bot Empty Value Detection) ⚠️ 极其重要
518
+ // 很多大厂 API 只要签名失败或无强登录 Cookie 依然会返回 HTTP 200,但把关键 URL 设为 ""
519
+ const firstSubUrl = data.data?.subtitle?.subtitles?.[0]?.subtitle_url;
520
+ if (!firstSubUrl) {
521
+ throw new Error('被风控降级或需登录:拿不到真实的 subtitle_url,请检查 Cookie 状态 (Tier 2/3)');
522
+ }
523
+
524
+ return firstSubUrl;
525
+ }, kwargs.bvid);
526
+
527
+ // Step 4: 拉取最终的 CDN 静态文件 (无鉴权)
528
+ const finalRes = await fetch(subtitleUrls.startsWith('//') ? 'https:' + subtitleUrls : subtitleUrls);
529
+ const subtitles = await finalRes.json();
530
+ ```
531
+
414
532
  ---
415
533
 
416
534
  ## Step 4: 测试
@@ -539,6 +657,70 @@ git push
539
657
 
540
658
  ---
541
659
 
660
+ ## 进阶模式: 级联请求 (Cascading Requests)
661
+
662
+ 当目标数据需要多步 API 链式获取时(如 `BVID → CID → 字幕列表 → 字幕内容`),必须使用 **TS 适配器**。YAML 无法处理这种多步逻辑。
663
+
664
+ ### 模板代码
665
+
666
+ ```typescript
667
+ import { cli, Strategy } from '../../registry.js';
668
+ import type { IPage } from '../../types.js';
669
+ import { apiGet } from '../../bilibili.js'; // 复用平台 SDK
670
+
671
+ cli({
672
+ site: 'bilibili',
673
+ name: 'subtitle',
674
+ strategy: Strategy.COOKIE,
675
+ args: [{ name: 'bvid', required: true }],
676
+ columns: ['index', 'from', 'to', 'content'],
677
+ func: async (page: IPage | null, kwargs: any) => {
678
+ if (!page) throw new Error('Requires browser');
679
+
680
+ // Step 1: 建立 Session
681
+ await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
682
+
683
+ // Step 2: 从页面提取中间 ID (__INITIAL_STATE__)
684
+ const cid = await page.evaluate(`(async () => {
685
+ return window.__INITIAL_STATE__?.videoData?.cid;
686
+ })()`);
687
+ if (!cid) throw new Error('无法提取 CID');
688
+
689
+ // Step 3: 用中间 ID 调用下一级 API (自动 Wbi 签名)
690
+ const payload = await apiGet(page, '/x/player/wbi/v2', {
691
+ params: { bvid: kwargs.bvid, cid },
692
+ signed: true, // ← 自动生成 w_rid
693
+ });
694
+
695
+ // Step 4: 检测风控降级 (空值断言)
696
+ const subtitles = payload.data?.subtitle?.subtitles || [];
697
+ const url = subtitles[0]?.subtitle_url;
698
+ if (!url) throw new Error('subtitle_url 为空,疑似风控降级');
699
+
700
+ // Step 5: 拉取最终数据 (CDN JSON)
701
+ const items = await page.evaluate(`(async () => {
702
+ const res = await fetch(${JSON.stringify('https:' + url)});
703
+ const json = await res.json();
704
+ return { data: json.body || json };
705
+ })()`);
706
+
707
+ return items.data.map((item, idx) => ({ ... }));
708
+ },
709
+ });
710
+ ```
711
+
712
+ ### 关键要点
713
+
714
+ | 步骤 | 注意事项 |
715
+ |------|----------|
716
+ | 提取中间 ID | 优先从 `__INITIAL_STATE__` 拿,避免额外 API 调用 |
717
+ | Wbi 签名 | B 站 `/wbi/` 接口**强制校验** `w_rid`,纯 `fetch` 会被 403 |
718
+ | 空值断言 | 即使 HTTP 200,核心字段可能为空串(风控降级) |
719
+ | CDN URL | 常以 `//` 开头,记得补 `https:` |
720
+ | `JSON.stringify` | 拼接 URL 到 evaluate 时必须用它转义,避免注入 |
721
+
722
+ ---
723
+
542
724
  ## 常见陷阱
543
725
 
544
726
  | 陷阱 | 表现 | 解决方案 |
@@ -553,6 +735,8 @@ git push
553
735
  | TS evaluate 格式 | `() => {}` 报 `result is not a function` | TS 中 `page.evaluate()` 必须用 IIFE:`(async () => { ... })()` |
554
736
  | 页面异步加载 | evaluate 拿到空数据(store state 还没更新) | 在 evaluate 内用 polling 等待数据出现,或增加 `wait` 时间 |
555
737
  | YAML 内嵌大段 JS | 调试困难,字符串转义问题 | 超过 10 行 JS 的命令改用 TS adapter |
738
+ | **风控被拦截(伪200)** | 获取到的 JSON 里核心数据是 `""` (空串) | 极易被误判。必须添加断言!无核心数据立刻要求升级鉴权 Tier 并重新配置 Cookie |
739
+ | **API 没找见** | `explore` 工具打分出来的都拿不到深层数据 | 点击页面按钮诱发懒加载数据,再结合 `getInterceptedRequests` 获取 |
556
740
 
557
741
  ---
558
742
 
@@ -565,9 +749,10 @@ git push
565
749
  opencli generate https://www.example.com --goal "hot"
566
750
 
567
751
  # 或分步执行:
568
- opencli explore https://www.example.com --site mysite # 发现 API
569
- opencli synthesize mysite # 生成候选 YAML
570
- opencli verify mysite/hot --smoke # 冒烟测试
752
+ opencli explore https://www.example.com --site mysite # 发现 API
753
+ opencli explore https://www.example.com --auto --click "字幕,CC" # 模拟点击触发懒加载 API
754
+ opencli synthesize mysite # 生成候选 YAML
755
+ opencli verify mysite/hot --smoke # 冒烟测试
571
756
  ```
572
757
 
573
758
  生成的候选 YAML 保存在 `.opencli/explore/mysite/candidates/`,可直接复制到 `src/clis/mysite/` 并微调。
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  [![npm](https://img.shields.io/npm/v/@jackwener/opencli)](https://www.npmjs.com/package/@jackwener/opencli)
9
9
 
10
- A CLI tool that turns **any website** into a command-line interface. **35+ 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.
10
+ A CLI tool that turns **any website** into a command-line interface. **47 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.
11
11
 
12
12
  ## ✨ Highlights
13
13
 
@@ -82,12 +82,12 @@ Public API commands (`hackernews`, `github search`, `v2ex`) need no browser at a
82
82
 
83
83
  | Site | Commands | Mode |
84
84
  |------|----------|------|
85
- | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` | 🔐 Browser |
85
+ | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` `subtitle` `dynamic` `ranking` `following` | 🔐 Browser |
86
86
  | **zhihu** | `hot` `search` `question` | 🔐 Browser |
87
- | **xiaohongshu** | `search` `notifications` `feed` | 🔐 Browser |
87
+ | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 Browser |
88
88
  | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
89
- | **twitter** | `trending` `bookmarks` | 🔐 Browser |
90
- | **reddit** | `hot` | 🔐 Browser |
89
+ | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` | 🔐 Browser |
90
+ | **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 Browser |
91
91
  | **weibo** | `hot` | 🔐 Browser |
92
92
  | **boss** | `search` | 🔐 Browser |
93
93
  | **youtube** | `search` | 🔐 Browser |
@@ -95,7 +95,7 @@ Public API commands (`hackernews`, `github search`, `v2ex`) need no browser at a
95
95
  | **reuters** | `search` | 🔐 Browser |
96
96
  | **smzdm** | `search` | 🔐 Browser |
97
97
  | **ctrip** | `search` | 🔐 Browser |
98
- | **github** | `trending` `search` | 🔐 / 🌐 |
98
+ | **github** | `search` | 🌐 Public |
99
99
  | **v2ex** | `hot` `latest` `topic` | 🌐 Public |
100
100
  | **hackernews** | `top` | 🌐 Public |
101
101
  | **bbc** | `news` | 🌐 Public |
package/README.zh-CN.md CHANGED
@@ -11,7 +11,7 @@ OpenCLI 通过 Chrome 浏览器 + [Playwright MCP Bridge](https://github.com/nic
11
11
 
12
12
  ## ✨ 亮点
13
13
 
14
- - 🌐 **35+ 命令,17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube
14
+ - 🌐 **47 个命令,17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube
15
15
  - 🔐 **零风控** — 复用 Chrome 登录态,无需存储任何凭证
16
16
  - 🤖 **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
17
17
  - 🚀 **动态加载引擎** — 只需将 `.ts` 或 `.yaml` 适配器放入 `clis/` 文件夹即可自动注册生效
@@ -83,12 +83,12 @@ npm install -g @jackwener/opencli@latest
83
83
 
84
84
  | 站点 | 命令 | 模式 |
85
85
  |------|------|------|
86
- | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` | 🔐 浏览器 |
86
+ | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` `subtitle` `dynamic` `ranking` `following` | 🔐 浏览器 |
87
87
  | **zhihu** | `hot` `search` `question` | 🔐 浏览器 |
88
- | **xiaohongshu** | `search` `notifications` `feed` | 🔐 浏览器 |
88
+ | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 浏览器 |
89
89
  | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 浏览器 |
90
- | **twitter** | `trending` `bookmarks` | 🔐 浏览器 |
91
- | **reddit** | `hot` | 🔐 浏览器 |
90
+ | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` | 🔐 浏览器 |
91
+ | **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 浏览器 |
92
92
  | **weibo** | `hot` | 🔐 浏览器 |
93
93
  | **boss** | `search` | 🔐 浏览器 |
94
94
  | **youtube** | `search` | 🔐 浏览器 |
@@ -96,7 +96,7 @@ npm install -g @jackwener/opencli@latest
96
96
  | **reuters** | `search` | 🔐 浏览器 |
97
97
  | **smzdm** | `search` | 🔐 浏览器 |
98
98
  | **ctrip** | `search` | 🔐 浏览器 |
99
- | **github** | `trending` `search` | 🔐 / 🌐 |
99
+ | **github** | `search` | 🌐 公共 API |
100
100
  | **v2ex** | `hot` `latest` `topic` | 🌐 公共 API |
101
101
  | **hackernews** | `top` | 🌐 公共 API |
102
102
  | **bbc** | `news` | 🌐 公共 API |
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.1.0
4
+ version: 0.4.0
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
  ---
@@ -49,6 +49,10 @@ opencli bilibili favorite # 我的收藏
49
49
  opencli bilibili history --limit 20 # 观看历史
50
50
  opencli bilibili feed --limit 10 # 动态时间线
51
51
  opencli bilibili user-videos --uid 12345 # 用户投稿
52
+ opencli bilibili subtitle --bvid BV1xxx # 获取视频字幕 (支持 --lang zh-CN)
53
+ opencli bilibili dynamic --limit 10 # 动态
54
+ opencli bilibili ranking --limit 10 # 排行榜
55
+ opencli bilibili following --limit 20 # 我的关注列表 (支持 --uid 查看他人)
52
56
 
53
57
  # 知乎 (browser)
54
58
  opencli zhihu hot --limit 10 # 知乎热榜
@@ -59,24 +63,33 @@ opencli zhihu question --id 34816524 # 问题详情和回答
59
63
  opencli xiaohongshu search --keyword "美食" # 搜索笔记
60
64
  opencli xiaohongshu notifications # 通知(mentions/likes/connections)
61
65
  opencli xiaohongshu feed --limit 10 # 推荐 Feed
66
+ opencli xiaohongshu me # 我的信息
67
+ opencli xiaohongshu user --uid xxx # 用户主页
62
68
 
63
69
  # 雪球 Xueqiu (browser)
64
70
  opencli xueqiu hot-stock --limit 10 # 雪球热门股票榜
65
71
  opencli xueqiu stock --symbol SH600519 # 查看股票实时行情
66
72
  opencli xueqiu watchlist # 获取自选股/持仓列表
67
73
  opencli xueqiu feed # 我的关注 timeline
74
+ opencli xueqiu hot --limit 10 # 雪球热榜
75
+ opencli xueqiu search --keyword "特斯拉" # 搜索
68
76
 
69
- # GitHub (trending=browser, search=public)
70
- opencli github trending --limit 10 # GitHub Trending
77
+ # GitHub (public)
71
78
  opencli github search --keyword "cli" # 搜索仓库
72
79
 
73
80
  # Twitter/X (browser)
74
81
  opencli twitter trending --limit 10 # 热门话题
75
82
  opencli twitter bookmarks --limit 20 # 获取收藏的书签推文
83
+ opencli twitter search --keyword "AI" # 搜索推文
84
+ opencli twitter profile --username elonmusk # 用户资料
85
+ opencli twitter timeline --limit 20 # 时间线
76
86
 
77
87
  # Reddit (browser)
78
88
  opencli reddit hot --limit 10 # 热门帖子
79
89
  opencli reddit hot --subreddit programming # 指定子版块
90
+ opencli reddit frontpage --limit 10 # 首页
91
+ opencli reddit search --keyword "AI" # 搜索
92
+ opencli reddit subreddit --name rust # 子版块浏览
80
93
 
81
94
  # V2EX (public)
82
95
  opencli v2ex hot --limit 10 # 热门话题
@@ -135,6 +148,9 @@ opencli generate <url> --goal "hot"
135
148
  # Strategy Cascade: auto-probe PUBLIC → COOKIE → HEADER
136
149
  opencli cascade <api-url>
137
150
 
151
+ # Explore with interactive fuzzing (click buttons to trigger lazy APIs)
152
+ opencli explore <url> --auto --click "字幕,CC,评论"
153
+
138
154
  # Verify: smoke-test a generated adapter
139
155
  opencli verify <site/name> --smoke
140
156
  ```
package/dist/browser.d.ts CHANGED
@@ -23,7 +23,11 @@ export declare class Page implements IPage {
23
23
  click(ref: string): Promise<void>;
24
24
  typeText(ref: string, text: string): Promise<void>;
25
25
  pressKey(key: string): Promise<void>;
26
- wait(seconds: number): Promise<void>;
26
+ wait(options: number | {
27
+ text?: string;
28
+ time?: number;
29
+ timeout?: number;
30
+ }): Promise<void>;
27
31
  tabs(): Promise<any>;
28
32
  closeTab(index?: number): Promise<void>;
29
33
  newTab(): Promise<void>;
package/dist/browser.js CHANGED
@@ -110,8 +110,14 @@ export class Page {
110
110
  async pressKey(key) {
111
111
  await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
112
112
  }
113
- async wait(seconds) {
114
- await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: seconds } });
113
+ async wait(options) {
114
+ if (typeof options === 'number') {
115
+ await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
116
+ }
117
+ else {
118
+ // Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
119
+ await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
120
+ }
115
121
  }
116
122
  async tabs() {
117
123
  return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
@@ -137,10 +143,32 @@ export class Page {
137
143
  async autoScroll(options = {}) {
138
144
  const times = options.times ?? 3;
139
145
  const delayMs = options.delayMs ?? 2000;
140
- for (let i = 0; i < times; i++) {
141
- await this.evaluate('() => window.scrollTo(0, document.body.scrollHeight)');
142
- await this.wait(delayMs / 1000);
146
+ const js = `
147
+ async () => {
148
+ const maxTimes = ${times};
149
+ const maxWaitMs = ${delayMs};
150
+ for (let i = 0; i < maxTimes; i++) {
151
+ const lastHeight = document.body.scrollHeight;
152
+ window.scrollTo(0, lastHeight);
153
+ await new Promise(resolve => {
154
+ let timeoutId;
155
+ const observer = new MutationObserver(() => {
156
+ if (document.body.scrollHeight > lastHeight) {
157
+ clearTimeout(timeoutId);
158
+ observer.disconnect();
159
+ setTimeout(resolve, 100); // Small debounce for rendering
160
+ }
161
+ });
162
+ observer.observe(document.body, { childList: true, subtree: true });
163
+ timeoutId = setTimeout(() => {
164
+ observer.disconnect();
165
+ resolve(null);
166
+ }, maxWaitMs);
167
+ });
143
168
  }
169
+ }
170
+ `;
171
+ await this.evaluate(js);
144
172
  }
145
173
  async installInterceptor(pattern) {
146
174
  const js = `
@@ -0,0 +1,11 @@
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
+ export {};
@@ -0,0 +1,101 @@
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
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import yaml from 'js-yaml';
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const CLIS_DIR = path.resolve(__dirname, 'clis');
17
+ const OUTPUT = path.resolve(__dirname, '..', 'dist', 'cli-manifest.json');
18
+ function scanYaml(filePath, site) {
19
+ try {
20
+ const raw = fs.readFileSync(filePath, 'utf-8');
21
+ const def = yaml.load(raw);
22
+ if (!def || typeof def !== 'object')
23
+ return null;
24
+ const strategyStr = def.strategy ?? (def.browser === false ? 'public' : 'cookie');
25
+ const strategy = strategyStr.toUpperCase();
26
+ const browser = def.browser ?? (strategy !== 'PUBLIC');
27
+ const args = [];
28
+ if (def.args && typeof def.args === 'object') {
29
+ for (const [argName, argDef] of Object.entries(def.args)) {
30
+ args.push({
31
+ name: argName,
32
+ type: argDef?.type ?? 'str',
33
+ default: argDef?.default,
34
+ required: argDef?.required ?? false,
35
+ help: argDef?.description ?? argDef?.help ?? '',
36
+ choices: argDef?.choices,
37
+ });
38
+ }
39
+ }
40
+ return {
41
+ site: def.site ?? site,
42
+ name: def.name ?? path.basename(filePath, path.extname(filePath)),
43
+ description: def.description ?? '',
44
+ domain: def.domain,
45
+ strategy: strategy.toLowerCase(),
46
+ browser,
47
+ args,
48
+ columns: def.columns,
49
+ pipeline: def.pipeline,
50
+ timeout: def.timeout,
51
+ type: 'yaml',
52
+ };
53
+ }
54
+ catch (err) {
55
+ process.stderr.write(`Warning: failed to parse ${filePath}: ${err.message}\n`);
56
+ return null;
57
+ }
58
+ }
59
+ function scanTs(filePath, site) {
60
+ // TS adapters self-register via cli() at import time.
61
+ // We record their module path for lazy dynamic import.
62
+ const baseName = path.basename(filePath, path.extname(filePath));
63
+ const relativePath = `${site}/${baseName}.js`;
64
+ return {
65
+ site,
66
+ name: baseName,
67
+ description: '',
68
+ strategy: 'cookie',
69
+ browser: true,
70
+ args: [],
71
+ type: 'ts',
72
+ modulePath: relativePath,
73
+ };
74
+ }
75
+ // Main
76
+ const manifest = [];
77
+ if (fs.existsSync(CLIS_DIR)) {
78
+ for (const site of fs.readdirSync(CLIS_DIR)) {
79
+ const siteDir = path.join(CLIS_DIR, site);
80
+ if (!fs.statSync(siteDir).isDirectory())
81
+ continue;
82
+ for (const file of fs.readdirSync(siteDir)) {
83
+ const filePath = path.join(siteDir, file);
84
+ if (file.endsWith('.yaml') || file.endsWith('.yml')) {
85
+ const entry = scanYaml(filePath, site);
86
+ if (entry)
87
+ manifest.push(entry);
88
+ }
89
+ else if ((file.endsWith('.ts') && !file.endsWith('.d.ts') && file !== 'index.ts') ||
90
+ (file.endsWith('.js') && !file.endsWith('.d.js') && file !== 'index.js')) {
91
+ manifest.push(scanTs(filePath, site));
92
+ }
93
+ }
94
+ }
95
+ }
96
+ // Ensure output directory exists
97
+ fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
98
+ fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
99
+ const yamlCount = manifest.filter(e => e.type === 'yaml').length;
100
+ const tsCount = manifest.filter(e => e.type === 'ts').length;
101
+ console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);