<!-- Source: https://deepseek-browser-use.io.nexus/10 -->

# 站点配方、技能与异步作业

本文讲三件与「让智能体少踩坑」有关的事：把站点经验固化成可执行序列的**配方**、给人读也给智能体读的**技能文档**，以及长批次用的**异步作业**。

[[toc]]

## 一、站点配方（recipes）

把「某个站点上必须这么点」的经验固化成服务端的 JSON 命令序列。每个配方一个 `.json` 文件，**文件名（不含扩展名）就是配方名**。

| 项 | 说明 |
| --- | --- |
| 目录 | 默认 `<服务启动目录>/recipes`，可用 `browser.recipes.dir` 改；`get_config` 能查到实际目录 |
| 列出 | `list_recipes`（返回名称、说明、参数、默认值、步数、命令序列） |
| 执行 | `{"method":"run_recipe","params":{"name":"配方名","vars":{...}}}` |
| 生效方式 | **必须显式点名**才执行，不做「看到这个域名就自动套用」的隐式推断 |

### 文件格式

```json
{
  "name": "配方名（可省，默认取文件名）",
  "description": "一句话说明它解决什么问题",
  "params": { "变量名": "给人看的说明" },
  "defaults": { "变量名": "默认值，调用方不传时用这个" },
  "commands": [
    { "命令名": { "参数": "值" } },
    { "close_modal": { "title": "{{title}}" } }
  ]
}
```

- `commands` 与批量接口 `commands` 的格式**完全一致**：每项一个键（外加可选的 `expect` 断言）。所以配方里的每一步都能单独贴出来手跑，排障时不用整段重来。
- `{{变量}}` 由 `vars` 注入，走 JSON 编码：字符串自动带引号并转义，中文、引号、换行都不用自己转义。带引号的写法（`"{{x}}"`）与裸写法（`{{x}}`）都支持。
- 变量先取 `defaults`，再用调用方 `vars` 覆盖 —— 写好了默认值的配方可以不传参数直接跑。
- 回执与 `commands` 批量一致（`count` / `succeeded` / `failed` / `results`），另加 `recipe` 与 `recipeDescription`。

### 仓库自带的配方

| 配方 | 用途 |
| --- | --- |
| `close-all-modals` | 清掉页面上所有可见的 DOM 弹窗（ant 确认框、用户服务协议层、抽屉）。这类按钮只认真实鼠标事件，JS 派发点了没反应 |
| `query-and-read-table` | 点「查询」→ 等表格内容稳定 → 读表格。搜索结果是异步刷新的，点完立刻读会读到上一次的结果 |
| `cnipa-list-drafts` | 中国商标网：「我的账户 → 申请管理 → 未提交」并把日期筛选切到「近三个月」再读列表。默认筛选是「今天」，只看默认视图很容易得出「没保存成功」的错误结论 |
| `open-console-from-iframe` | 主站把第三方控制台套在跨域 iframe 里时的通用套路 |
| `wework-qykit-open-console` | 企业微信后台的「邮件 / 微盘 / 文档 / 会议」：现取一个未被消费的 token，把第三方控制台当顶层页面打开 |
| `gov-site-search` | 国产政府/事业单位站的**站内全文检索**：进高级搜索 → 选「包含完整关键词」→ 填词 → 可选日期区间 → 每页 50 → 点搜索。这类站的检索参数全在页面 JS 状态里，**直接拼 URL 会被静默重置成 0 条结果**，必须走表单 |

## 站点技能（`.agents/skills/`）

仓库自带的技能文档除了主技能，还有一批**站点操作手册**。它们在仓库里启动会话时会被自动发现，所以做对应站点时直接点名即可，不必把踩过的坑重新踩一遍：

| 技能 | 什么时候读 |
| --- | --- |
| `cn-gov-site-search` | 在省/市/县/局各级政务网站上查公告、批复、征地信息。讲透检索必须走表单、精确短语与日期区间怎么开、公告正文是图片时怎么读、征地类信息在哪个栏目，以及站上确实没有时怎么转依申请公开 |
| `web-data-as-text` | 任何「页面在数字上」的任务（行情、账单、后台列表、看板）。给出文本来源的优先次序、交叉校验与时间基准，并写明**例外**：内容本身就是图片时，读图才是第一条路 |
| `wecom-*` / `cnipa-*` / `bilibili-*` … | 各自站点的手册（企业微信后台、中国商标网、B 站投稿等） |

技能文档里**单个反引号包起来的名字会被当成命令名做构建期检查**，所以页面里的标识符（CSS 类名、表单字段、URL 参数）要写成双反引号 —— 约定见仓库的 `docs/SKILL-CONVENTIONS.md`。


### 套路：主站套第三方控制台

企业微信后台把「邮件」套在第三方域名的**跨域 iframe** 里（微盘 / 文档 / 会议同理）。这是**一整类站点**，不是个例。症状是 `get_browser_state` 只拿到主站外壳、iframe 内部一个元素都没有。

**先用正规解法**：`get_browser_state` 传 `includeFrames: true`，或先 `list_frames` 看有哪些 frame。Playwright 能跨 frame（走浏览器协议取内容，不依赖往页面里注入脚本），拿到 frame 之后读、点、填都能做。

**为什么还需要这个配方**：有些控制台在 iframe 里的可用性受主站外壳影响（iframe 尺寸小、控件被裁掉、主站轮询把状态重置），这时「把控制台当顶层页面打开」更稳。步骤是：

1. 从 `iframe.src` 反查第三方 URL 与参数（`list_frames`，或用 `execute_js` 列出页面上所有 `iframe` 的 `src`）；
2. 在 `get_requests` 里按 `token` / `oauth` / `sso` 等关键字找出主站发 token 或换登录态的接口；
3. 用同源 `execute_js` 调它（`credentials: 'same-origin'` 会自动带上主站的登录 Cookie），拿一个**新的、还没被消费的** token；
4. 用这个 token 把控制台当顶层页面打开，之后就能当普通页面读写。

> **直接 `go_to_url` 打开 iframe 的 `src` 会失败**：那里面的 token 是一次性的，已经被 iframe 消费掉了。这是这类站点最容易踩的一步。

### 写配方的纪律

- 只固化**稳定**的步骤：菜单文本、按钮文本这类会随站点改版变化的东西尽量放进 `params` 或 `defaults`，别硬编码在 `commands` 里；
- 每一步都要能解释「为什么必须这样」；靠猜试出来的步骤不要写进配方；
- 涉及不可逆操作的站点，配方里不要带自动提交类步骤。

## 二、技能文档（skills）

技能是**给智能体读的方法论**，配方是**给服务执行的命令序列**。两者互补：

| | 技能 | 配方 |
| --- | --- | --- |
| 形态 | Markdown 文档 | JSON 命令序列 |
| 谁来用 | 智能体读进去，自己决定怎么做 | 服务端按名字执行 |
| 适用 | 需要判断、分支、排障的流程 | 步骤固定、可以照抄的操作 |
| 目录 | `.agents/skills/` | `recipes/` |

当前仓库的技能组织方式：

| 位置 | 内容 |
| --- | --- |
| `.agents/skills/deepseek-browser-use/SKILL.md` | **总纲**：核心命令用法与参考文档入口 |
| `.agents/skills/deepseek-browser-use/references/` | 协议、客户端、人机协作、排障等按需读取的分册；商户表单经验位于 `payment-onboarding.md` |
| `.agents/skills/<技能名>/SKILL.md` | 具体站点的实操手册，记录该站点特有的坑与验证过的步骤 |

技能能否被自动发现取决于宿主支持的目录约定；当前仓库使用 `.agents/skills/`。技能只提供**使用规则**，后端服务仍需单独启动。公共教程中的操作经验见[表单与多层弹窗排障](<https://deepseek-browser-use.io.nexus/08>)。

**技能文档与命令表有防漂移检查**：测试会校验技能文档覆盖了命令表里的每一个方法，且文档里不出现已废弃的方法名。所以新增命令时不要忘了补技能文档，否则构建会失败。

## 三、异步作业

批次可能跑几十秒以上，超过客户端自己的超时时间。在批次参数里加 `"async": true`：

```json
{
  "id":"1001",
  "method":"commands",
  "params":{"async":true,"commands":[
    {"go_to_url":{"url":"https://example.com"}},
    {"wait_for_stable":{"selector":".vxe-table--body-wrapper","quietMs":900,"timeoutSeconds":20}},
    {"get_browser_state":{}}
  ]}
}
```

接口立刻返回：

```json
{"data":{"jobId":"<作业 ID>","status":"running","browserId":"1001"}}
```

之后用 `get_job` 取结果、`list_jobs` 看全部、`cancel_job` 取消。

| 项 | 说明 |
| --- | --- |
| 状态 | `running` / `done` / `failed` / `cancelled` |
| 取消 | **协作式**的：批次会在每一步之间检查取消标记，所以取消在「下一步之前」生效，当前这一步会跑完 |
| 保留数量 | 只保留最近 50 个，更早的被淘汰 |
| 存储 | 结果在**内存**里，**服务重启即丢**。它是「一次调用的结果暂存」，不是持久化的任务队列 |
| 异常 | 批次内部抛异常会被记成 `failed`，不会把线程池打挂 |

**最大的好处是：客户端超时不再等于任务失败。** 超时之后批次其实还在服务端继续跑，重新用 `get_job` 取结果即可，不必重发整批 —— 重发在有副作用的操作（提交、下单）上会出问题。

需要跨服务重启保留任务、或需要人工在长时间等待后接着跑的场景，不要依赖作业：把进度写进任务自己的上下文（例如用 `execute_js` 存进 LocalStorage，或让智能体自己记录），或用站点配方把这段固定步骤固化下来。
