Skip to content

Commit dff0fe5

Browse files
yee94yee.wangjackwener
authored
feat(record): add live recording command for API capture (#300)
* feat(record): add live recording command for API capture - Add `opencli record <url>` command that injects fetch/XHR interceptors into all tabs in the automation window, polls captured requests, and auto-generates YAML candidate adapters - Support multi-tab recording: new tabs discovered during polling are automatically injected - Add --timeout (default 60s) for agent-friendly non-blocking operation; stops on Enter, timeout, or SIGINT — whichever comes first - Fix idempotent re-injection: restores original fetch/XHR before re-patching so guard flag no longer blocks subsequent record runs - Add --poll interval option (default 2000ms) - Expand SKILL.md with full Record Workflow section: interceptor internals, page-type capture expectations, YAML→TS conversion guide, and troubleshooting table * fix(record): fix XHR listener leak, pathChain syntax error, readline hang & args interpolation - XHR send(): add __rec_listener_added guard to prevent duplicate event listeners when XHR is reused (abort → open → send) - pathChain: when findArrayPath returns '' (root-level array), data access is just 'data' not 'data?.' which was invalid JS syntax - waitForEnter(): return cleanup fn so timeout path can close readline.Interface preventing the process from hanging on stdin after auto-timeout - buildRecordedYaml: replace search/page query param values with template vars ({{args.keyword}}, {{args.page}}) so generated YAML actually uses the declared args instead of hardcoding the recorded URL --------- Co-authored-by: yee.wang <yee.wang@lazada.com> Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 4343ec0 commit dff0fe5

File tree

3 files changed

+776
-0
lines changed

3 files changed

+776
-0
lines changed

SKILL.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,18 @@ opencli synthesize <site>
259259
# Generate: one-shot explore → synthesize → register
260260
opencli generate <url> --goal "hot"
261261

262+
# Record: YOU operate the page, opencli captures every API call → YAML candidates
263+
# Opens the URL in automation window, injects fetch/XHR interceptor into ALL tabs,
264+
# polls every 2s, auto-stops after 60s (or press Enter to stop early).
265+
opencli record <url> # 录制,site name 从域名推断
266+
opencli record <url> --site mysite # 指定 site name
267+
opencli record <url> --timeout 120000 # 自定义超时(毫秒,默认 60000)
268+
opencli record <url> --poll 1000 # 缩短轮询间隔(毫秒,默认 2000)
269+
opencli record <url> --out .opencli/record/x # 自定义输出目录
270+
# Output:
271+
# .opencli/record/<site>/captured.json ← 原始捕获数据(带 url/method/body)
272+
# .opencli/record/<site>/candidates/*.yaml ← 高置信度候选适配器(score ≥ 8,有 array 结果)
273+
262274
# Strategy Cascade: auto-probe PUBLIC → COOKIE → HEADER
263275
opencli cascade <api-url>
264276

@@ -289,6 +301,129 @@ opencli bilibili hot -f csv # CSV
289301
opencli bilibili hot -v # Show each pipeline step and data flow
290302
```
291303

304+
## Record Workflow
305+
306+
`record` 是为「无法用 `explore` 自动发现」的页面(需要登录操作、复杂交互、SPA 内路由)准备的手动录制方案。
307+
308+
### 工作原理
309+
310+
```
311+
opencli record <url>
312+
→ 打开 automation window 并导航到目标 URL
313+
→ 向所有 tab 注入 fetch/XHR 拦截器(幂等,可重复注入)
314+
→ 每 2s 轮询一次:发现新 tab 自动注入,drain 所有 tab 的捕获缓冲区
315+
→ 超时(默认 60s)或按 Enter 停止
316+
→ 分析捕获到的 JSON 请求:去重 → 评分 → 生成候选 YAML
317+
```
318+
319+
**拦截器特性**
320+
- 同时 patch `window.fetch``XMLHttpRequest`
321+
- 只捕获 `Content-Type: application/json` 的响应
322+
- 过滤纯对象少于 2 个 key 的响应(避免 tracking/ping)
323+
- 跨 tab 隔离:每个 tab 独立缓冲区,轮询时分别 drain
324+
- 幂等注入:同一 tab 二次注入时先 restore 原始函数再重新 patch,不丢失已捕获数据
325+
326+
### 使用步骤
327+
328+
```bash
329+
# 1. 启动录制(建议 --timeout 给足操作时间)
330+
opencli record "https://example.com/page" --timeout 120000
331+
332+
# 2. 在弹出的 automation window 里正常操作页面:
333+
# - 打开列表、搜索、点击条目、切换 Tab
334+
# - 凡是触发网络请求的操作都会被捕获
335+
336+
# 3. 完成操作后按 Enter 停止(或等超时自动停止)
337+
338+
# 4. 查看结果
339+
cat .opencli/record/<site>/captured.json # 原始捕获
340+
ls .opencli/record/<site>/candidates/ # 候选 YAML
341+
```
342+
343+
### 页面类型与捕获预期
344+
345+
| 页面类型 | 预期捕获量 | 说明 |
346+
|---------|-----------|------|
347+
| 列表/搜索页 | 多(5~20+) | 每次搜索/翻页都会触发新请求 |
348+
| 详情页(只读) | 少(1~5) | 首屏数据一次性返回,后续操作走 form/redirect |
349+
| SPA 内路由跳转 | 中等 | 路由切换会触发新接口,但首屏请求在注入前已发出 |
350+
| 需要登录的页面 | 视操作而定 | 确保 Chrome 已登录目标网站 |
351+
352+
> **注意**:如果页面在导航完成前就发出了大部分请求(服务端渲染 / SSR 注水),拦截器会错过这些请求。
353+
> 解决方案:在页面加载完成后,手动触发能产生新请求的操作(搜索、翻页、切 Tab、展开折叠项等)。
354+
355+
### 候选 YAML → TS CLI 转换
356+
357+
生成的候选 YAML 是起点,通常需要转换为 TypeScript(尤其是 tae 等内部系统):
358+
359+
**候选 YAML 结构**(自动生成):
360+
```yaml
361+
site: tae
362+
name: getList # 从 URL path 推断的名称
363+
strategy: cookie
364+
browser: true
365+
pipeline:
366+
- navigate: https://...
367+
- evaluate: |
368+
(async () => {
369+
const res = await fetch('/approval/getList.json?procInsId=...', { credentials: 'include' });
370+
const data = await res.json();
371+
return (data?.content?.operatorRecords || []).map(item => ({ ... }));
372+
})()
373+
```
374+
375+
**转换为 TS CLI**(参考 `src/clis/tae/add-expense.ts` 风格):
376+
```typescript
377+
import { cli, Strategy } from '../../registry.js';
378+
379+
cli({
380+
site: 'tae',
381+
name: 'get-approval',
382+
description: '查看报销单审批流程和操作记录',
383+
domain: 'tae.alibaba-inc.com',
384+
strategy: Strategy.COOKIE,
385+
browser: true,
386+
args: [
387+
{ name: 'proc_ins_id', type: 'string', required: true, positional: true, help: '流程实例 ID(procInsId)' },
388+
],
389+
columns: ['step', 'operator', 'action', 'time'],
390+
func: async (page, kwargs) => {
391+
await page.goto('https://tae.alibaba-inc.com/expense/pc.html?_authType=SAML');
392+
await page.wait(2);
393+
const result = await page.evaluate(`(async () => {
394+
const res = await fetch('/approval/getList.json?taskId=&procInsId=${kwargs.proc_ins_id}', {
395+
credentials: 'include'
396+
});
397+
const data = await res.json();
398+
return data?.content?.operatorRecords || [];
399+
})()`);
400+
return (result as any[]).map((r, i) => ({
401+
step: i + 1,
402+
operator: r.operatorName || r.userId,
403+
action: r.operationType,
404+
time: r.operateTime,
405+
}));
406+
},
407+
});
408+
```
409+
410+
**转换要点**
411+
1. URL 中的动态 ID(`procInsId``taskId` 等)提取为 `args`
412+
2. `captured.json` 里的真实 body 结构用于确定正确的数据路径(如 `content.operatorRecords`
413+
3. tae 系统统一用 `{ success, content, errorCode, errorMsg }` 外层包裹,取数据要走 `content.*`
414+
4. 认证方式:cookie(`credentials: 'include'`),不需要额外 header
415+
5. 文件放入 `src/clis/<site>/`,无需手动注册,`npm run build` 后自动发现
416+
417+
### 故障排查
418+
419+
| 现象 | 原因 | 解法 |
420+
|------|------|------|
421+
| 捕获 0 条请求 | 拦截器注入失败,或页面无 JSON API | 检查 daemon 是否运行:`curl localhost:19825/status` |
422+
| 捕获量少(1~3 条) | 页面是只读详情页,首屏数据已在注入前发出 | 手动操作触发更多请求(搜索/翻页),或换用列表页 |
423+
| 候选 YAML 为 0 | 捕获到的 JSON 都没有 array 结构 | 直接看 `captured.json` 手写 TS CLI |
424+
| 新开的 tab 没有被拦截 | 轮询间隔内 tab 已关闭 | 缩短 `--poll 500` |
425+
| 二次运行 record 时数据不连续 | 正常,每次 `record` 启动都是新的 automation window | 无需处理 |
426+
292427
## Creating Adapters
293428

294429
> [!TIP]

src/cli.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,30 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
183183
process.exitCode = r.ok ? 0 : 1;
184184
});
185185

186+
// ── Built-in: record ─────────────────────────────────────────────────────
187+
188+
program
189+
.command('record')
190+
.description('Record API calls from a live browser session → generate YAML candidates')
191+
.argument('<url>', 'URL to open and record')
192+
.option('--site <name>', 'Site name (inferred from URL if omitted)')
193+
.option('--out <dir>', 'Output directory for candidates')
194+
.option('--poll <ms>', 'Poll interval in milliseconds', '2000')
195+
.option('--timeout <ms>', 'Auto-stop after N milliseconds (default: 60000)', '60000')
196+
.action(async (url, opts) => {
197+
const { recordSession, renderRecordSummary } = await import('./record.js');
198+
const result = await recordSession({
199+
BrowserFactory: getBrowserFactory() as any,
200+
url,
201+
site: opts.site,
202+
outDir: opts.out,
203+
pollMs: parseInt(opts.poll, 10),
204+
timeoutMs: parseInt(opts.timeout, 10),
205+
});
206+
console.log(renderRecordSummary(result));
207+
process.exitCode = result.candidateCount > 0 ? 0 : 1;
208+
});
209+
186210
program
187211
.command('cascade')
188212
.description('Strategy cascade: find simplest working strategy')

0 commit comments

Comments
 (0)