@jackwener/opencli 0.1.0 → 0.1.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 (135) hide show
  1. package/CLI-CREATOR.md +594 -0
  2. package/README.md +124 -39
  3. package/README.zh-CN.md +151 -0
  4. package/SKILL.md +178 -102
  5. package/dist/bilibili.d.ts +6 -5
  6. package/dist/browser.d.ts +3 -1
  7. package/dist/browser.js +44 -2
  8. package/dist/cascade.d.ts +46 -0
  9. package/dist/cascade.js +180 -0
  10. package/dist/clis/bbc/news.js +42 -0
  11. package/dist/clis/bilibili/hot.yaml +38 -0
  12. package/dist/clis/boss/search.js +47 -0
  13. package/dist/clis/ctrip/search.d.ts +1 -0
  14. package/dist/clis/ctrip/search.js +62 -0
  15. package/dist/clis/hackernews/top.yaml +36 -0
  16. package/dist/clis/index.d.ts +10 -1
  17. package/dist/clis/index.js +19 -1
  18. package/dist/clis/reddit/hot.yaml +46 -0
  19. package/dist/clis/reuters/search.d.ts +1 -0
  20. package/dist/clis/reuters/search.js +52 -0
  21. package/dist/clis/smzdm/search.d.ts +1 -0
  22. package/dist/clis/smzdm/search.js +66 -0
  23. package/dist/clis/twitter/trending.yaml +40 -0
  24. package/dist/clis/v2ex/hot.yaml +25 -0
  25. package/dist/clis/v2ex/latest.yaml +25 -0
  26. package/dist/clis/v2ex/topic.yaml +27 -0
  27. package/dist/clis/weibo/hot.d.ts +1 -0
  28. package/dist/clis/weibo/hot.js +41 -0
  29. package/dist/clis/xiaohongshu/feed.yaml +32 -0
  30. package/dist/clis/xiaohongshu/notifications.yaml +38 -0
  31. package/dist/clis/xiaohongshu/search.d.ts +5 -0
  32. package/dist/clis/xiaohongshu/search.js +68 -0
  33. package/dist/clis/yahoo-finance/quote.d.ts +1 -0
  34. package/dist/clis/yahoo-finance/quote.js +74 -0
  35. package/dist/clis/youtube/search.d.ts +1 -0
  36. package/dist/clis/youtube/search.js +60 -0
  37. package/dist/clis/zhihu/hot.yaml +42 -0
  38. package/dist/clis/zhihu/question.d.ts +1 -0
  39. package/dist/clis/zhihu/question.js +39 -0
  40. package/dist/clis/zhihu/search.yaml +55 -0
  41. package/dist/engine.d.ts +2 -1
  42. package/dist/explore.d.ts +23 -13
  43. package/dist/explore.js +293 -422
  44. package/dist/generate.js +2 -1
  45. package/dist/main.js +21 -2
  46. package/dist/pipeline/executor.d.ts +9 -0
  47. package/dist/pipeline/executor.js +88 -0
  48. package/dist/pipeline/index.d.ts +5 -0
  49. package/dist/pipeline/index.js +5 -0
  50. package/dist/pipeline/steps/browser.d.ts +12 -0
  51. package/dist/pipeline/steps/browser.js +68 -0
  52. package/dist/pipeline/steps/fetch.d.ts +5 -0
  53. package/dist/pipeline/steps/fetch.js +50 -0
  54. package/dist/pipeline/steps/intercept.d.ts +5 -0
  55. package/dist/pipeline/steps/intercept.js +75 -0
  56. package/dist/pipeline/steps/tap.d.ts +12 -0
  57. package/dist/pipeline/steps/tap.js +130 -0
  58. package/dist/pipeline/steps/transform.d.ts +8 -0
  59. package/dist/pipeline/steps/transform.js +53 -0
  60. package/dist/pipeline/template.d.ts +16 -0
  61. package/dist/pipeline/template.js +115 -0
  62. package/dist/pipeline/template.test.d.ts +4 -0
  63. package/dist/pipeline/template.test.js +102 -0
  64. package/dist/pipeline/transform.test.d.ts +4 -0
  65. package/dist/pipeline/transform.test.js +90 -0
  66. package/dist/pipeline.d.ts +5 -7
  67. package/dist/pipeline.js +5 -313
  68. package/dist/registry.d.ts +3 -2
  69. package/dist/runtime.d.ts +2 -1
  70. package/dist/synthesize.d.ts +11 -8
  71. package/dist/synthesize.js +142 -118
  72. package/dist/types.d.ts +27 -0
  73. package/dist/types.js +7 -0
  74. package/package.json +9 -4
  75. package/src/bilibili.ts +9 -7
  76. package/src/browser.ts +41 -3
  77. package/src/cascade.ts +218 -0
  78. package/src/clis/bbc/news.ts +42 -0
  79. package/src/clis/boss/search.ts +47 -0
  80. package/src/clis/ctrip/search.ts +62 -0
  81. package/src/clis/index.ts +28 -1
  82. package/src/clis/reddit/hot.yaml +46 -0
  83. package/src/clis/reuters/search.ts +52 -0
  84. package/src/clis/smzdm/search.ts +66 -0
  85. package/src/clis/v2ex/hot.yaml +5 -9
  86. package/src/clis/v2ex/latest.yaml +5 -8
  87. package/src/clis/v2ex/topic.yaml +27 -0
  88. package/src/clis/weibo/hot.ts +41 -0
  89. package/src/clis/xiaohongshu/feed.yaml +32 -0
  90. package/src/clis/xiaohongshu/notifications.yaml +38 -0
  91. package/src/clis/xiaohongshu/search.ts +71 -0
  92. package/src/clis/yahoo-finance/quote.ts +74 -0
  93. package/src/clis/youtube/search.ts +60 -0
  94. package/src/clis/zhihu/hot.yaml +22 -8
  95. package/src/clis/zhihu/question.ts +45 -0
  96. package/src/clis/zhihu/search.yaml +55 -0
  97. package/src/engine.ts +2 -1
  98. package/src/explore.ts +303 -465
  99. package/src/generate.ts +3 -1
  100. package/src/main.ts +18 -2
  101. package/src/pipeline/executor.ts +98 -0
  102. package/src/pipeline/index.ts +6 -0
  103. package/src/pipeline/steps/browser.ts +67 -0
  104. package/src/pipeline/steps/fetch.ts +60 -0
  105. package/src/pipeline/steps/intercept.ts +78 -0
  106. package/src/pipeline/steps/tap.ts +137 -0
  107. package/src/pipeline/steps/transform.ts +50 -0
  108. package/src/pipeline/template.test.ts +107 -0
  109. package/src/pipeline/template.ts +101 -0
  110. package/src/pipeline/transform.test.ts +107 -0
  111. package/src/pipeline.ts +5 -292
  112. package/src/registry.ts +4 -2
  113. package/src/runtime.ts +3 -1
  114. package/src/synthesize.ts +142 -137
  115. package/src/types.ts +23 -0
  116. package/vitest.config.ts +7 -0
  117. package/dist/clis/github/search.js +0 -20
  118. package/dist/clis/zhihu/search.js +0 -58
  119. package/dist/promote.d.ts +0 -1
  120. package/dist/promote.js +0 -3
  121. package/dist/register.d.ts +0 -2
  122. package/dist/register.js +0 -2
  123. package/dist/scaffold.d.ts +0 -2
  124. package/dist/scaffold.js +0 -2
  125. package/dist/smoke.d.ts +0 -2
  126. package/dist/smoke.js +0 -2
  127. package/src/clis/github/search.ts +0 -21
  128. package/src/clis/github/trending.yaml +0 -58
  129. package/src/clis/zhihu/search.ts +0 -65
  130. package/src/promote.ts +0 -3
  131. package/src/register.ts +0 -2
  132. package/src/scaffold.ts +0 -2
  133. package/src/smoke.ts +0 -2
  134. /package/dist/clis/{github/search.d.ts → bbc/news.d.ts} +0 -0
  135. /package/dist/clis/{zhihu → boss}/search.d.ts +0 -0
package/CLI-CREATOR.md ADDED
@@ -0,0 +1,594 @@
1
+ # CLI-CREATOR — 适配器开发完全指南
2
+
3
+ > 本文档教你(或 AI Agent)如何为 OpenCLI 添加一个新网站的命令。
4
+ > 从零到发布,覆盖 API 发现、方案选择、适配器编写、测试验证全流程。
5
+
6
+ ## 核心流程
7
+
8
+ ```
9
+ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────┐
10
+ │ 1. 发现 API │ ──▶ │ 2. 选择策略 │ ──▶ │ 3. 写适配器 │ ──▶ │ 4. 测试 │
11
+ └─────────────┘ └─────────────┘ └──────────────┘ └────────┘
12
+ explore cascade YAML / TS run + verify
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Step 1: 发现 API
18
+
19
+ ### 1a. 自动化发现(推荐)
20
+
21
+ OpenCLI 内置 Deep Explore,自动分析网站网络请求:
22
+
23
+ ```bash
24
+ opencli explore https://www.example.com --site mysite
25
+ ```
26
+
27
+ 输出到 `.opencli/explore/mysite/`:
28
+
29
+ | 文件 | 内容 |
30
+ |------|------|
31
+ | `manifest.json` | 站点元数据、框架检测(Vue2/3、React、Next.js、Pinia、Vuex) |
32
+ | `endpoints.json` | 已发现的 API 端点,按评分排序,含 URL pattern、方法、响应类型 |
33
+ | `capabilities.json` | 推理出的功能(`hot`、`search`、`feed`…),含置信度和推荐参数 |
34
+ | `auth.json` | 认证方式检测(Cookie/Header/无认证),策略候选列表 |
35
+
36
+ ### 1b. 手动抓包验证
37
+
38
+ Explore 的自动分析可能不完美,用 verbose 模式手动确认:
39
+
40
+ ```bash
41
+ # 在浏览器中打开目标页面,观察网络请求
42
+ opencli explore https://www.example.com --site mysite -v
43
+
44
+ # 或直接用 evaluate 测试 API
45
+ opencli bilibili hot -v # 查看已有命令的 pipeline 每步数据流
46
+ ```
47
+
48
+ 关注抓包结果中的关键信息:
49
+ - **URL pattern**: `/api/v2/hot?limit=20` → 这就是你要调用的端点
50
+ - **Method**: `GET` / `POST`
51
+ - **Request Headers**: Cookie? Bearer? 自定义签名头(X-s、X-t)?
52
+ - **Response Body**: JSON 结构,特别是数据在哪个路径(`data.items`、`data.list`)
53
+
54
+ ### 1c. 框架检测
55
+
56
+ Explore 自动检测前端框架。如果需要手动确认:
57
+
58
+ ```bash
59
+ # 在已打开目标网站的情况下
60
+ opencli evaluate "(()=>{
61
+ const vue3 = !!document.querySelector('#app')?.__vue_app__;
62
+ const vue2 = !!document.querySelector('#app')?.__vue__;
63
+ const react = !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
64
+ const pinia = vue3 && !!document.querySelector('#app').__vue_app__.config.globalProperties.\$pinia;
65
+ return JSON.stringify({vue3, vue2, react, pinia});
66
+ })()"
67
+ ```
68
+
69
+ Vue + Pinia 的站点(如小红书)可以直接通过 Store Action 绕过签名。
70
+
71
+ ---
72
+
73
+ ## Step 2: 选择认证策略
74
+
75
+ OpenCLI 提供 5 级认证策略。使用 `cascade` 命令自动探测:
76
+
77
+ ```bash
78
+ opencli cascade https://api.example.com/hot
79
+ ```
80
+
81
+ ### 策略决策树
82
+
83
+ ```
84
+ 直接 fetch(url) 能拿到数据?
85
+ → ✅ Tier 1: public(公开 API,不需要浏览器)
86
+ → ❌ fetch(url, {credentials:'include'}) 带 Cookie 能拿到?
87
+ → ✅ Tier 2: cookie(最常见,evaluate 步骤内 fetch)
88
+ → ❌ → 加上 Bearer / CSRF header 后能拿到?
89
+ → ✅ Tier 3: header(如 Twitter ct0 + Bearer)
90
+ → ❌ → 网站有 Pinia/Vuex Store?
91
+ → ✅ Tier 4: intercept(Store Action + XHR 拦截)
92
+ → ❌ Tier 5: ui(UI 自动化,最后手段)
93
+ ```
94
+
95
+ ### 各策略对比
96
+
97
+ | Tier | 策略 | 速度 | 复杂度 | 适用场景 | 实例 |
98
+ |------|------|------|--------|---------|------|
99
+ | 1 | `public` | ⚡ ~1s | 最简 | 公开 API,无需登录 | Hacker News, V2EX |
100
+ | 2 | `cookie` | 🔄 ~7s | 简单 | Cookie 认证即可 | Bilibili, Zhihu, Reddit |
101
+ | 3 | `header` | 🔄 ~7s | 中等 | 需要 CSRF token 或 Bearer | Twitter GraphQL |
102
+ | 4 | `intercept` | 🔄 ~10s | 较高 | 请求有复杂签名 | 小红书 (Pinia + XHR) |
103
+ | 5 | `ui` | 🐌 ~15s+ | 最高 | 无 API,纯 DOM 解析 | 遗留网站 |
104
+
105
+ ---
106
+
107
+ ## Step 3: 编写适配器
108
+
109
+ ### YAML vs TS?先看决策树
110
+
111
+ ```
112
+ 你的 pipeline 里有 evaluate 步骤(内嵌 JS 代码)?
113
+ → ✅ 用 TypeScript (src/clis/<site>/<name>.ts),需在 index.ts 注册
114
+ → ❌ 纯声明式(navigate + tap + map + limit)?
115
+ → ✅ 用 YAML (src/clis/<site>/<name>.yaml),放入即自动注册
116
+ ```
117
+
118
+ | 场景 | 选择 | 示例 |
119
+ |------|------|------|
120
+ | 纯 fetch/select/map/limit | YAML | `v2ex/hot.yaml`, `hackernews/top.yaml` |
121
+ | navigate + evaluate(fetch) + map | YAML(评估复杂度) | `zhihu/hot.yaml` |
122
+ | navigate + tap + map | YAML ✅ | `xiaohongshu/feed.yaml`, `xiaohongshu/notifications.yaml` |
123
+ | 有复杂 JS 逻辑(Pinia state 读取、条件分支) | TS | `xiaohongshu/me.ts`, `bilibili/me.ts` |
124
+ | XHR 拦截 + 签名 | TS | `xiaohongshu/search.ts` |
125
+ | GraphQL / 分页 / Wbi 签名 | TS | `bilibili/search.ts`, `twitter/search.ts` |
126
+
127
+ > **经验法则**:如果你发现 YAML 里嵌了超过 10 行 JS,改用 TS 更可维护。
128
+
129
+ ### 方式 A: YAML Pipeline(声明式,推荐)
130
+
131
+ 文件路径: `src/clis/<site>/<name>.yaml`,放入即自动注册。
132
+
133
+ #### Tier 1 — 公开 API 模板
134
+
135
+ ```yaml
136
+ # src/clis/v2ex/hot.yaml
137
+ site: v2ex
138
+ name: hot
139
+ description: V2EX 热门话题
140
+ domain: www.v2ex.com
141
+ strategy: public
142
+ browser: false
143
+
144
+ args:
145
+ limit:
146
+ type: int
147
+ default: 20
148
+
149
+ pipeline:
150
+ - fetch:
151
+ url: https://www.v2ex.com/api/topics/hot.json
152
+
153
+ - map:
154
+ rank: ${{ index + 1 }}
155
+ title: ${{ item.title }}
156
+ replies: ${{ item.replies }}
157
+
158
+ - limit: ${{ args.limit }}
159
+
160
+ columns: [rank, title, replies]
161
+ ```
162
+
163
+ #### Tier 2 — Cookie 认证模板(最常用)
164
+
165
+ ```yaml
166
+ # src/clis/zhihu/hot.yaml
167
+ site: zhihu
168
+ name: hot
169
+ description: 知乎热榜
170
+ domain: www.zhihu.com
171
+
172
+ pipeline:
173
+ - navigate: https://www.zhihu.com # 先加载页面建立 session
174
+
175
+ - evaluate: | # 在浏览器内发请求,自动带 Cookie
176
+ (async () => {
177
+ const res = await fetch('/api/v3/feed/topstory/hot-lists/total?limit=50', {
178
+ credentials: 'include'
179
+ });
180
+ const d = await res.json();
181
+ return (d?.data || []).map(item => {
182
+ const t = item.target || {};
183
+ return {
184
+ title: t.title,
185
+ heat: item.detail_text || '',
186
+ answers: t.answer_count,
187
+ };
188
+ });
189
+ })()
190
+
191
+ - map:
192
+ rank: ${{ index + 1 }}
193
+ title: ${{ item.title }}
194
+ heat: ${{ item.heat }}
195
+ answers: ${{ item.answers }}
196
+
197
+ - limit: ${{ args.limit }}
198
+
199
+ columns: [rank, title, heat, answers]
200
+ ```
201
+
202
+ > **关键**: `evaluate` 步骤内的 `fetch` 运行在浏览器页面内,自动携带 `credentials: 'include'`,无需手动处理 Cookie。
203
+
204
+ #### 进阶 — 带搜索参数
205
+
206
+ ```yaml
207
+ # src/clis/zhihu/search.yaml
208
+ site: zhihu
209
+ name: search
210
+ description: 知乎搜索
211
+
212
+ args:
213
+ keyword:
214
+ type: str
215
+ required: true
216
+ description: Search keyword
217
+ limit:
218
+ type: int
219
+ default: 10
220
+
221
+ pipeline:
222
+ - navigate: https://www.zhihu.com
223
+
224
+ - evaluate: |
225
+ (async () => {
226
+ const q = encodeURIComponent('${{ args.keyword }}');
227
+ const res = await fetch('/api/v4/search_v3?q=' + q + '&t=general&limit=${{ args.limit }}', {
228
+ credentials: 'include'
229
+ });
230
+ const d = await res.json();
231
+ return (d?.data || [])
232
+ .filter(item => item.type === 'search_result')
233
+ .map(item => ({
234
+ title: (item.object?.title || '').replace(/<[^>]+>/g, ''),
235
+ type: item.object?.type || '',
236
+ author: item.object?.author?.name || '',
237
+ votes: item.object?.voteup_count || 0,
238
+ }));
239
+ })()
240
+
241
+ - map:
242
+ rank: ${{ index + 1 }}
243
+ title: ${{ item.title }}
244
+ type: ${{ item.type }}
245
+ author: ${{ item.author }}
246
+ votes: ${{ item.votes }}
247
+
248
+ - limit: ${{ args.limit }}
249
+
250
+ columns: [rank, title, type, author, votes]
251
+ ```
252
+
253
+ #### Tier 4 — Store Action Bridge(`tap` 步骤,intercept 策略推荐)
254
+
255
+ 适用于 Vue + Pinia/Vuex 的网站(如小红书),无须手动写 XHR 拦截代码:
256
+
257
+ ```yaml
258
+ # src/clis/xiaohongshu/notifications.yaml
259
+ site: xiaohongshu
260
+ name: notifications
261
+ description: "小红书通知"
262
+ domain: www.xiaohongshu.com
263
+ strategy: intercept
264
+ browser: true
265
+
266
+ args:
267
+ type:
268
+ type: str
269
+ default: mentions
270
+ description: "Notification type: mentions, likes, or connections"
271
+ limit:
272
+ type: int
273
+ default: 20
274
+
275
+ columns: [rank, user, action, content, note, time]
276
+
277
+ pipeline:
278
+ - navigate: https://www.xiaohongshu.com/notification
279
+ - wait: 3
280
+ - tap:
281
+ store: notification # Pinia store name
282
+ action: getNotification # Store action to call
283
+ args: # Action arguments
284
+ - ${{ args.type | default('mentions') }}
285
+ capture: /you/ # URL pattern to capture response
286
+ select: data.message_list # Extract sub-path from response
287
+ timeout: 8
288
+ - map:
289
+ rank: ${{ index + 1 }}
290
+ user: ${{ item.user_info.nickname }}
291
+ action: ${{ item.title }}
292
+ content: ${{ item.comment_info.content }}
293
+ - limit: ${{ args.limit | default(20) }}
294
+ ```
295
+
296
+ > **`tap` 步骤自动完成**:注入 fetch+XHR 双拦截 → 查找 Pinia/Vuex store → 调用 action → 捕获匹配 URL 的响应 → 清理拦截。
297
+ > 如果 store 或 action 找不到,会返回 `hint` 列出所有可用的 store actions,方便调试。
298
+
299
+ | tap 参数 | 必填 | 说明 |
300
+ |---------|------|------|
301
+ | `store` | ✅ | Pinia store 名称(如 `feed`, `search`, `notification`) |
302
+ | `action` | ✅ | Store action 方法名 |
303
+ | `capture` | ✅ | URL 子串匹配(匹配网络请求 URL) |
304
+ | `args` | ❌ | 传给 action 的参数数组 |
305
+ | `select` | ❌ | 从 captured JSON 中提取的路径(如 `data.items`) |
306
+ | `timeout` | ❌ | 等待网络响应的超时秒数(默认 5s) |
307
+ | `framework` | ❌ | `pinia` 或 `vuex`(默认自动检测) |
308
+
309
+ ### 方式 B: TypeScript 适配器(编程式)
310
+
311
+ 适用于需要嵌入 JS 代码读取 Pinia state、XHR 拦截、GraphQL、分页、复杂数据转换等场景。
312
+
313
+ 文件路径: `src/clis/<site>/<name>.ts`,还需要在 `src/clis/index.ts` 中 import 注册。
314
+
315
+ #### Tier 3 — Header 认证(Twitter)
316
+
317
+ ```typescript
318
+ // src/clis/twitter/search.ts
319
+ import { cli, Strategy } from '../../registry.js';
320
+
321
+ cli({
322
+ site: 'twitter',
323
+ name: 'search',
324
+ description: 'Search tweets',
325
+ strategy: Strategy.HEADER,
326
+ args: [{ name: 'keyword', required: true }],
327
+ columns: ['rank', 'author', 'text', 'likes'],
328
+ func: async (page, kwargs) => {
329
+ await page.goto('https://x.com');
330
+ const data = await page.evaluate(`
331
+ (async () => {
332
+ // 从 Cookie 提取 CSRF token
333
+ const ct0 = document.cookie.split(';')
334
+ .map(c => c.trim())
335
+ .find(c => c.startsWith('ct0='))?.split('=')[1];
336
+ if (!ct0) return { error: 'Not logged in' };
337
+
338
+ const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D...';
339
+ const headers = {
340
+ 'Authorization': 'Bearer ' + decodeURIComponent(bearer),
341
+ 'X-Csrf-Token': ct0,
342
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
343
+ };
344
+
345
+ const variables = JSON.stringify({ rawQuery: '${kwargs.keyword}', count: 20 });
346
+ const url = '/i/api/graphql/xxx/SearchTimeline?variables=' + encodeURIComponent(variables);
347
+ const res = await fetch(url, { headers, credentials: 'include' });
348
+ return await res.json();
349
+ })()
350
+ `);
351
+ // ... 解析 data
352
+ },
353
+ });
354
+ ```
355
+
356
+ #### Tier 4 — Store Action + XHR 拦截(小红书)
357
+
358
+ ```typescript
359
+ // src/clis/xiaohongshu/search.ts
360
+ import { cli, Strategy } from '../../registry.js';
361
+
362
+ cli({
363
+ site: 'xiaohongshu',
364
+ name: 'search',
365
+ description: '搜索小红书笔记',
366
+ strategy: Strategy.COOKIE, // 实际是 intercept 模式
367
+ args: [{ name: 'keyword', required: true }],
368
+ columns: ['rank', 'title', 'author', 'likes', 'type'],
369
+ func: async (page, kwargs) => {
370
+ await page.goto('https://www.xiaohongshu.com');
371
+ await page.wait(2);
372
+
373
+ const data = await page.evaluate(`
374
+ (async () => {
375
+ const app = document.querySelector('#app')?.__vue_app__;
376
+ const pinia = app?.config?.globalProperties?.$pinia;
377
+ if (!pinia?._s) return { error: 'Page not ready' };
378
+
379
+ const searchStore = pinia._s.get('search');
380
+ if (!searchStore) return { error: 'Search store not found' };
381
+
382
+ // XHR 拦截:捕获 store action 发出的请求
383
+ let captured = null;
384
+ const origOpen = XMLHttpRequest.prototype.open;
385
+ const origSend = XMLHttpRequest.prototype.send;
386
+ XMLHttpRequest.prototype.open = function(m, u) {
387
+ this.__url = u;
388
+ return origOpen.apply(this, arguments);
389
+ };
390
+ XMLHttpRequest.prototype.send = function(b) {
391
+ if (this.__url?.includes('search/notes')) {
392
+ const x = this;
393
+ const orig = x.onreadystatechange;
394
+ x.onreadystatechange = function() {
395
+ if (x.readyState === 4 && !captured) {
396
+ try { captured = JSON.parse(x.responseText); } catch {}
397
+ }
398
+ if (orig) orig.apply(this, arguments);
399
+ };
400
+ }
401
+ return origSend.apply(this, arguments);
402
+ };
403
+
404
+ try {
405
+ // 触发 Store Action,让网站自己签名发请求
406
+ searchStore.mutateSearchValue('${kwargs.keyword}');
407
+ await searchStore.loadMore();
408
+ await new Promise(r => setTimeout(r, 800));
409
+ } finally {
410
+ // 恢复原始 XHR
411
+ XMLHttpRequest.prototype.open = origOpen;
412
+ XMLHttpRequest.prototype.send = origSend;
413
+ }
414
+
415
+ if (!captured?.success) return { error: captured?.msg || 'Search failed' };
416
+ return (captured.data?.items || []).map(i => ({
417
+ title: i.note_card?.display_title || '',
418
+ author: i.note_card?.user?.nickname || '',
419
+ likes: i.note_card?.interact_info?.liked_count || '0',
420
+ type: i.note_card?.type || '',
421
+ }));
422
+ })()
423
+ `);
424
+
425
+ if (!Array.isArray(data)) return [];
426
+ return data.slice(0, kwargs.limit || 20).map((item, i) => ({
427
+ rank: i + 1, ...item,
428
+ }));
429
+ },
430
+ });
431
+ ```
432
+
433
+ > **XHR 拦截核心思路**:不自己构造签名,而是劫持网站自己的 `XMLHttpRequest`,让网站的 Store Action 发出正确签名的请求,我们只是"窃听"响应。用完后必须恢复原始方法。
434
+
435
+ ---
436
+
437
+ ## Step 4: 测试
438
+
439
+ > **⚠️ 构建通过 ≠ 功能正常**。`npm run build` 只验证 TypeScript / YAML 语法,不验证运行时行为。
440
+ > 每个新命令 **必须实际运行** 并确认输出正确后才算完成。
441
+
442
+ ### 必做清单
443
+
444
+ ```bash
445
+ # 1. 构建(确认语法无误)
446
+ npm run build
447
+
448
+ # 2. 确认命令已注册
449
+ opencli list | grep mysite
450
+
451
+ # 3. 实际运行命令(最关键!)
452
+ opencli mysite hot --limit 3 -v # verbose 查看每步数据流
453
+ opencli mysite hot --limit 3 -f json # JSON 输出确认字段完整
454
+ ```
455
+
456
+ ### tap 步骤调试(intercept 策略专用)
457
+
458
+ > **⚠️ 不要猜 store name / action name**。先用 evaluate 探索,再写 YAML。
459
+
460
+ #### Step 1: 列出所有 Pinia store
461
+
462
+ 在浏览器中打开目标网站后:
463
+
464
+ ```bash
465
+ opencli evaluate "(() => {
466
+ const app = document.querySelector('#app')?.__vue_app__;
467
+ const pinia = app?.config?.globalProperties?.\$pinia;
468
+ return [...pinia._s.keys()];
469
+ })()"
470
+ # 输出: ["user", "feed", "search", "notification", ...]
471
+ ```
472
+
473
+ #### Step 2: 查看 store 的 action 名称
474
+
475
+ 故意写一个错误 action 名,tap 会返回所有可用 actions:
476
+
477
+ ```
478
+ ⚠ tap: Action not found: wrongName on store notification
479
+ 💡 Available: getNotification, replyComment, getNotificationCount, reset
480
+ ```
481
+
482
+ #### Step 3: 用 network requests 确认 capture 模式
483
+
484
+ ```bash
485
+ # 在浏览器打开目标页面,查看网络请求
486
+ # 找到目标 API 的 URL 特征(如 "/you/mentions"、"homefeed")
487
+ ```
488
+
489
+ #### 完整流程
490
+
491
+ ```
492
+ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────┐
493
+ │ 1. navigate │ ──▶ │ 2. 探索 store │ ──▶ │ 3. 写 YAML │ ──▶ │ 4. 测试 │
494
+ │ 到目标页面 │ │ name/action │ │ tap 步骤 │ │ 运行验证 │
495
+ └──────────────┘ └──────────────┘ └──────────────┘ └────────┘
496
+ ```
497
+
498
+ ### Verbose 模式
499
+
500
+ ```bash
501
+ # 查看 pipeline 每步的输入输出
502
+ opencli bilibili hot --limit 1 -v
503
+ ```
504
+
505
+ 输出示例:
506
+ ```
507
+ [1/4] navigate → https://www.bilibili.com
508
+ → (no data)
509
+ [2/4] evaluate → (async () => { const res = await fetch(…
510
+ → [{title: "…", author: "…", play: 230835}]
511
+ [3/4] map (rank, title, author, play, danmaku)
512
+ → [{rank: 1, title: "…", author: "…"}]
513
+ [4/4] limit → 1
514
+ → [{rank: 1, title: "…"}]
515
+ ```
516
+
517
+ ### 输出格式验证
518
+
519
+ ```bash
520
+ # 确认表格渲染正确
521
+ opencli mysite hot -f table
522
+
523
+ # 确认 JSON 可被 jq 解析
524
+ opencli mysite hot -f json | jq '.[0]'
525
+
526
+ # 确认 CSV 可被导入
527
+ opencli mysite hot -f csv > data.csv
528
+ ```
529
+
530
+ ---
531
+
532
+ ## Step 5: 注册 & 发布
533
+
534
+ ### YAML 适配器
535
+
536
+ 放入 `src/clis/<site>/<name>.yaml` 即自动注册,无需额外操作。
537
+
538
+ ### TS 适配器
539
+
540
+ 在 `src/clis/index.ts` 添加 import:
541
+
542
+ ```typescript
543
+ import './mysite/search.js';
544
+ ```
545
+
546
+ ### 验证注册
547
+
548
+ ```bash
549
+ opencli list # 确认新命令出现
550
+ opencli validate mysite # 校验定义完整性
551
+ ```
552
+
553
+ ### 提交
554
+
555
+ ```bash
556
+ git add src/clis/mysite/
557
+ git commit -m "feat(mysite): add hot and search adapters"
558
+ git push
559
+ ```
560
+
561
+ ---
562
+
563
+ ## 常见陷阱
564
+
565
+ | 陷阱 | 表现 | 解决方案 |
566
+ |------|------|---------|
567
+ | 缺少 `navigate` | evaluate 报 `Target page context` 错误 | 在 evaluate 前加 `navigate:` 步骤 |
568
+ | 嵌套字段访问 | `${{ item.node?.title }}` 不工作 | 在 evaluate 中 flatten 数据,不在模板中用 optional chaining |
569
+ | 缺少 `strategy: public` | 公开 API 也启动浏览器,7s → 1s | 公开 API 加上 `strategy: public` + `browser: false` |
570
+ | evaluate 返回字符串 | map 步骤收到 `""` 而非数组 | pipeline 有 auto-parse,但建议在 evaluate 内 `.map()` 整形 |
571
+ | 搜索参数被 URL 编码 | `${{ args.keyword }}` 被浏览器二次编码 | 在 evaluate 内用 `encodeURIComponent()` 手动编码 |
572
+ | Cookie 过期 | 返回 401 / 空数据 | 在浏览器里重新登录目标站点 |
573
+ | Extension tab 残留 | Chrome 多出 `chrome-extension://` tab | 已自动清理;若残留,手动关闭即可 |
574
+ | TS evaluate 格式 | `() => {}` 报 `result is not a function` | TS 中 `page.evaluate()` 必须用 IIFE:`(async () => { ... })()` |
575
+ | 页面异步加载 | evaluate 拿到空数据(store state 还没更新) | 在 evaluate 内用 polling 等待数据出现,或增加 `wait` 时间 |
576
+ | YAML 内嵌大段 JS | 调试困难,字符串转义问题 | 超过 10 行 JS 的命令改用 TS adapter |
577
+
578
+ ---
579
+
580
+ ## 用 AI Agent 自动生成适配器
581
+
582
+ 最快的方式是让 AI Agent 完成全流程:
583
+
584
+ ```bash
585
+ # 一键:探索 → 分析 → 合成 → 注册
586
+ opencli generate https://www.example.com --goal "hot"
587
+
588
+ # 或分步执行:
589
+ opencli explore https://www.example.com --site mysite # 发现 API
590
+ opencli synthesize mysite # 生成候选 YAML
591
+ opencli verify mysite/hot --smoke # 冒烟测试
592
+ ```
593
+
594
+ 生成的候选 YAML 保存在 `.opencli/explore/mysite/candidates/`,可直接复制到 `src/clis/mysite/` 并微调。