# 探查方法论

探查 = 搞清楚页面真实 DOM 结构，找出 selector / 就绪条件 / 元素位置。
**探查优先，绝不照文字/截图猜 DOM**。本文件是通用探查方法论，不依赖任何特定运行时。

## 目录

- [SOP 转自动化的固定顺序](#sop-转自动化的固定顺序)
- [frame 意识](#frame-意识探查任何元素的前提)
- [点击与验证节点的位置复核](#点击验证节点必须复核位置核心铁律)
- [五阶段采集模型](#五阶段采集模型写每个采集目标的根本方法论)
- [SOP 截图识图纪律](#sop-截图的识图纪律高亮--位置与路径标识)
- [成功判据](#成功判据必须可判定)
- [下载判据](#下载类操作的两层判据)
- [探查留痕](#探查留痕配合-cateye-probe推荐-非本包依赖)

## SOP 转自动化的固定顺序

收到文字、截图、表格或录屏整理出的 SOP 时，严格按以下顺序推进：

1. **完整读取全部材料**：文字读完，截图逐张查看，列出每张图对应的步骤和产物。
2. **转成可判定步骤**：为每步写清开始页面、转换动作、动作后页面、成功判据、目标操作。
3. **真实探查**：验证 URL、frame、容器、邻居、控件状态、下载文件名和完成条件。
4. **回看原材料**：确认每个步骤、分支和产物都有对应探查结果。
5. **再写自动化代码**：不得在材料未读完或关键节点未经探查时提前实现。

多产物 SOP 必须逐项对齐；不能完成第一个下载后就默认整个流程结束。

### 区分状态描述与操作指令

SOP 是给人看的，常把页面状态和用户动作写在一起。转写时按语义区分：

- “找到”“看到”“出现”“加载完成”通常是**就绪状态**，转换为等待或验证，不要擅自点击。
- “点击”“选择”“输入”“下载”“提交”才是**操作动作**。
- “点击下载”不代表存在文字为“下载”的按钮；它可能是链接、图标、下拉菜单或弹窗操作，
  必须探查真实 DOM。
- SOP 常省略菜单层级、默认 tab、弹窗和滚动动作，必须通过真实页面补全，不能凭业务常识猜。

### 探查结论与实现必须 1:1

探查验证过的 URL、frame 获取方式、容器锚点、点击方式、等待条件和下载判据，必须原样进入
实现代码。若某 API、selector 或跨 frame 方式在探查中失败，开发时不得换回该未验证方案。

每一步至少有一个机器可判定的成功条件，例如：

- 导航后 URL 与必要查询参数匹配。
- 目标业务容器出现并包含预期锚点。
- 控件进入选中、启用或提交完成状态。
- 下载记录是本次点击后新建、文件名匹配且状态为 complete。

“调用没有报错”“按钮点过了”“等待了几秒”都不是成功判据。

### 登录就绪判定

不要用品牌名、Logo 或通用导航文字单独判定登录成功。使用**业务 URL + 业务页面锚点**共同确认。
检测到登录页、验证码、权限确认或风险拦截时，按 [high-risk.md](high-risk.md) 停止并保留现场。

### 只询问真正无法探查的业务问题

页面和运行环境能回答的问题，由自动化自行读取，不要转交给非技术用户，例如当前 URL、
是否已登录、扩展是否连接、页面有几个 frame、按钮是否存在。

只有经过探查仍无法确定的业务意图才询问，例如：

- 多个同名计划或报表应选择哪一个。
- “前一天”指自然日还是工作日。
- 多个价格阶段中业务要求核对哪一列。

## frame 意识（探查任何元素的前提）

**现代 web 应用大量用 iframe 嵌套**（内容区、表单、tab、表格、弹窗、第三方组件都可能
是独立 iframe）。**枚举不完哪些东西在 iframe 里**——登录表单会、内容区会、报表会、
甚至一个按钮都可能。所以规则不是"X 在 iframe"（特定场景枚举），而是一个**通用前提**:
探查任何 DOM 元素之前，先搞清楚页面有几层 frame，目标在哪个 frame。

**铁律: 截图/文字里有的元素一定存在, 查不到就把全部可能枚举一遍, 不原地打转。**
SOP 截图或文字描述里出现的元素, 页面上一定有。查不到不是"换 selector 再试", 而是系统性枚举
所有可能的位置/形态逐个排查:

1. **顶层 document** — 默认 evaluate 查的这里。
2. **各子 iframe** — `operate({action:'listFrames'})` 拿到所有 frame（顶层 frameId:0 +
   各子 iframe）, **逐个子 frame eval 找**。现代 web 大量用 iframe 嵌套, 枚举不完哪些在 iframe,
   必须逐个查。
3. **动态渲染** — SPA 异步加载, 元素还没出来。加 `waitForTimeout` / `waitForSelector` 重查。
4. **portal 弹层** — ant-dropdown / modal / popover 等组件渲染在 `document.body` 末尾的
   portal 里, 不在其逻辑父容器下。点触发后去 body 末尾找（如 `.ant-dropdown-menu-item`）。
5. **shadow DOM** — 元素在 closed/open shadow root 里, 需穿透 shadow 查。

**原地打转的反模式**: 只查顶层一个地方, 查不到就反复换 selector/class/正则。永远找不到
iframe/portal/shadow 里的元素。正确做法是上面 5 类系统性枚举, 逐个排除。

**跨域 iframe 的硬约束** (安全策略):
- 跨域 iframe 不能从顶层读 contentDocument（为 null）。
- 必须在 iframe 自己的 frame context 里 eval（`evaluate` 支持 `frameId` 参数, 直接进对应
  Frame 执行）, 不能从顶层越权读。
- 所以 frameId 不是可选参数——目标是子 frame 元素时, frameId 是必须的。

### listFrames + 逐 frame eval 范式

```ts
import { BrowserClient } from "mooncat-browser";
// 端口 = 实例 rpcPort(默认 17322,多实例各自不同,看 config/browser.json)
const browser = new BrowserClient({ baseUrl: "http://127.0.0.1:17322" });

const tab = await browser.newTab({ url: "https://example.com" });
const page = tab.pageHandle;

// 1. 先看页面有几层 frame
const frames = await browser.operate({ pageHandle: page, action: "listFrames" });
// frames: [{ frameId: 0, url: "...", parentFrameId: null }, { frameId: 1, ... }, ...]

// 2. 在某个子 iframe 里 eval 找元素 (传 frameId)
const found = await browser.operate({
  pageHandle: page, action: "evaluate",
  params: {
    frameId: 1,   // 目标在子 iframe 时必须传
    source: "() => { const el = document.querySelector('#login-form'); return el ? 'found' : 'none'; }",
  },
});
```

## 点击/验证节点必须复核位置（核心铁律）

**找任何点击/验证节点，绝不能只靠文本匹配**。页面常出现**同名不同位置**的元素
（文本完全一样，但在不同容器里），只靠 `textContent==='取数报表'` 点击，很可能点到错的那个。

实战案例（生意参谋自助分析页）: 页面上有**两个** "取数报表":
- 左侧菜单项（导航入口，父级）
- 中间内容区 tab（分类筛选，子级）

两者文本完全相同，但功能不同。**只有同时"左侧菜单在取数报表 + 中间 tab 选取数报表"才显示
报表列表**。只靠文本匹配的 evaluate 永远会撞坑。

**探查时必须确认节点的周边位置（context）**:
- **所在容器**: 节点的祖先链是导航菜单? 内容区 tab 栏? 表格行操作列?
  探查时返回 `el.closest('[class*=menu]')` / `el.closest('[class*=tab]')` / `el.closest('tr')` 确认在哪个区。
- **同级兄弟**: 同一父级下还有哪些元素（定位是 tab 栏第 N 个，还是菜单第 N 项）。
- **区分同名**: 多个同名时，用**容器 + 路径**消歧，不是靠文本。例:
  `document.querySelector('[class*=menu] [class*=item]:nth-child(2)')`（左侧菜单第 2 项）
  vs `[class*=tabs] [class*=tab]:nth-child(4)`（中间 tab 第 4 个）。

**evaluate 探查必须返回 context，不只返回"找到了"**:
```js
// 正确: 返回周边位置, 能判定是哪个
const t = all.find(e => e.textContent.trim() === '取数报表');
return {
  text: '取数报表',
  tag: t.tagName,
  inMenu: !!t.closest('[class*=menu],[class*=side]'),      // 关键: 在哪个容器
  inTabBar: !!t.closest('[class*=tab],[role=tablist]'),
  parentClass: t.parentElement?.className,
  siblings: [...t.parentElement.children].map(e => e.textContent.trim()),  // 同级兄弟
};
// 错误: 只返回 ok:true, 无法区分是哪个同名元素
```

每个点击/验证节点都要写清**「在哪个容器 + 第几个」**，不能只写文字。
文本不是全部，位置也是 DOM 的一部分。

## 五阶段采集模型（写每个采集目标的根本方法论）

一个采集目标 = 五个阶段，顺序不能乱，缺一不可。这五阶段来自真实探查验证，不是脑补：

```
开始环境 → 动作 → 采集环境 → 锁定物(waitFor) → 目标(采集)
```

| 阶段 | 是什么 | 举例（生意参谋粉丝数） |
|---|---|---|
| **开始环境** | goto 直达的页面（动作前的初始状态） | goto 首页 portal/home |
| **动作** | 进入采集环境要做的交互（点 tab/切视图/滚动） | 点店铺资产 tab + 滚动到底 |
| **采集环境** | 动作完成后，目标数据"应该出现"的页面状态 | 店铺资产板块展开 + 页面底部 |
| **锁定物** | 在采集环境里 waitFor 的关键元素（确认环境就绪） | waitFor "粉丝" 文案出现 |
| **目标** | 真正要采集的数据（读 DOM/表格/文本） | 读"累积了 X 个粉丝" |

### 锁定物铁律

**锁定物一定是在"采集环境"里 waitFor 的，不是在"开始环境"里。**

- 错: goto 完就 waitFor 目标文案（此时还没做动作，文案当然没有）→ waitFor 触发刷新，把页面状态冲了
- 对: 做完动作（点 tab/切视图/滚动）进入采集环境后，**才** waitFor 目标文案

### waitFor 的 maxRefresh 区分场景

waitFor 内部有 maxRefresh（等不到就刷新页面重试）。**不是所有场景都该刷新**:

| 场景 | maxRefresh | 原因 |
|---|---|---|
| goto 直达页 | 2（可刷新） | 页面是初始态，刷新无害，应对风控/懒加载 |
| 需动作才到的环境（点表格后/点 tab 后） | **0**（只轮询不刷新） | 刷新会丢失动作状态（表格视图收起/tab 收回） |
| Tab/Frame 出现或消失、登录状态机 | **0（默认）** | 等的是状态转换，刷新会制造新的状态并掩盖真实转换 |

**点过 tab/切过视图的页面，waitFor 绝不刷新（maxRefresh=0），只轮询等。** 刷新=丢失状态=白做动作。

`tabTarget` / `frameTarget` 用于等待实体出现或消失；多个页面状态必须在同一次观测中判断时使用
`condition`，并返回命中的完整证据。连接、句柄或 Frame 读取错误需要与“状态未出现”分开时，设置
`failFastOnError: true`。

### 为什么固定 sleep 不够

高危平台有风控/懒加载/SPA 异步渲染，goto 后内容**不一定出来**（可能风控拦截/慢加载/需触发）。
固定 sleep N 秒后直接采，可能采到空。必须 waitFor 锁定物确认环境就绪。
固定 sleep 只在 waitFor 之后做"渲染稳定"用（如 waitFor 到表头后 sleep 1s 让数据行填完）。

## SOP 截图的识图纪律（高亮 = 位置与路径标识）

用户给的 SOP 截图里，**高亮/红框/箭头标注不是"示意该点什么"**，而是硬性的**页面位置标识**:
那个被高亮/选中的按钮或标签，标识了**用户当前停在哪个页面、从哪条路径进来的**。

- **高亮所在的那一排** = 路径层级。在中间 tab 栏第 4 个被选中 = 当前视图是
  「某模块 → 某分组 → 中间第 4 个 tab」。在左侧菜单某项被选中 = 当前在该菜单项的功能区。
- **高亮的选中态** = 这个位置现在是 active。要复现到这个页面，就得**让那个位置变成选中态**，
  即点到它高亮为止，不是点同名但不同位置的入口。
- **高亮是路标，不是装饰**。读图的第一件事是**找所有高亮元素，读出它们的位置**，由此倒推
  到达该页面的导航路径。而不是看截图"大概理解"后凭印象写 selector。

### 识图只看三点（统一标准）

用 `image_analyze` 读 SOP 截图时，统一问这三件事，不随手写 prompt:

> **识图约定（写进 prompt 开头）**: 截图里所有**红色粗框、红色字体都是用户给的操作标注**，
> 不是页面原有元素。**红色字体的数字编号（1/2/3...）是点击顺序**，按编号从小到大就是操作序列。

1. **用户标注（最重要）**: 列出所有红框框选的文字、所有红字编号 + 说明、红色箭头指向。
   按编号顺序整理成操作序列：1 点哪里、2 点哪里……
2. **到达路径**: 哪些元素是选中态/高亮态（蓝色高亮/下划线/背景突出）？分别在什么位置
   （哪个菜单选中、哪个 tab 选中）？由此倒推到达当前页面的导航路径。
3. **重要文本的邻居上下文（不报方位，报邻居）**: 对要点/要读的元素，报告它**周围的文字和按钮**
   ——它和哪些元素同处一组/互相挨着。邻居是防同名混淆的定位锚点，比"页面右下角"有用。
   **严禁报"页面上方/下方/左上角"这种像素方位**——对 DOM 探查无意义。

**为什么只问三点**: 卡片数值、logo、无关菜单都是噪音，淹没真正要的信息。prompt 越聚焦，
模型报告越准。

### 图证据 vs 探查结果矛盾

**图证据 > probe 结果**，但不是二选一。两者矛盾时**两者都复查**:
- 图显示某元素存在，探查顶层查不到 → 几乎总是**该元素在子 iframe**，listFrames + 进 iframe eval 就找到了。
- 探查可能错（selector 漏/查错 frame/SPA 没渲染够），图也可能错（prompt 差/模型臆测）。
- 矛盾时用更好 prompt 重读图 + 换手段重查（换 frame/换 selector/加等待），不要改结论迁就任一方。

## 成功判据必须可判定

把"加载完成""看到报表"这类人话，转成**机器可判定的二值判定**:
- `waitForSelector X visible`（元素出现即就绪）
- `innerText` / `snapshot` 含某文本

不可判定的判据是失败源，必须补成可检查的。每一步都要有成功判据，没有判据 = 不知道成功没成功。

## 下载类操作的两层判据

点击下载/导出涉及产物文件，必须能**二值判定下载是否成功**，否则"点了下载但文件没下来"
会静默失败。难点：浏览器下载是异步的，点按钮 ≠ 文件立即可见。判据分两层：

- **触发判据**（点没点到）：点击后按钮状态变化（"下载中" loading）/ 出现"导出成功" toast /
  `waitForSelector` 下载进度条出现。这是"动作生效"的证据，不是"文件就绪"。
- **就绪判据**（文件真的下来了）：**轮询下载目录**——文件出现且**不再增长**（size 稳定，
  即下载完成不是半截）+ 文件名含预期日期/关键词。

常见坑:
- 文件名带时间戳/随机串 → 判定用**模式匹配**（glob / 正则），不要等固定名。
- 同名文件已存在被覆盖 → 下载前记录目录 snapshot，下载后 diff 出新文件。
- 下载的是 .xls/.xlsx/.csv → 探查确认实际格式，别假设。
- 大文件导出有"准备中"阶段 → 点完按钮先 `waitForTimeout` + 轮询，别点完立刻判。

### 用 BrowserClient 的下载 API

WebPlater 扩展路基于 `chrome.downloads` 原生 API，有专门的下载查询方法:

```ts
// 列出最近下载
const { downloads } = await browser.listDownloads(20);

// 查单个下载状态
const { download } = await browser.getDownload(id);

// 轮询等下载完成 (按 filenameRegex + sinceMs 匹配, 等 state=complete)
const { download, reason } = await browser.waitForDownload({
  filenameRegex: "每日数据_.*\\.xlsx$",
  sinceMs: Date.now() - 1000,   // 只看点击后的新下载
  timeoutMs: 60000,
});
if (reason === "complete") { /* download.filename 是落盘路径 */ }
```

如果页面“下载”按钮的回调实际调用 `window.open(下载地址)`，扩展路的合成点击可能因
Chrome 缺少“用户激活”而无法打开新窗口。确认并取得真实下载 URL 后，不要用 `newTab`
模拟下载：下载响应会立即销毁临时 tab，可能产生 `tab lost`。直接走下载管理器：

```ts
const result = await browser.downloadUrlAndWait({
  url: downloadUrl,
  timeoutMs: 60000,
});
```

Python 等价入口：

```python
result = browser.download_url_and_wait(download_url, timeout_ms=60_000)
if result["reason"] == "complete":
    downloaded_file = result["download"]["filename"]
```

这一路径适用于页面已经暴露真实文件 URL 的情况；不能猜测或拼接未验证的业务地址。

### Chrome 下载拦截（隐蔽坑）

Chrome 有**自动下载保护**：点击下载后，文件可能被浏览器**静默拦截**（顶部出现
"xxx 已被拦截 / [保留危险文件]"黄色提示条），用户必须在浏览器 UI **手动点"保留"**
才真正落盘。

**这个拦截藏在浏览器 UI 层，不在页面 DOM 里**——页面 evaluate / snapshot / innerText 都读不到
这条提示。表现就是"点了下载但文件迟迟不出现"。往往要靠人眼看浏览器才发现。

判定: 点下载后轮询下载目录——文件出现且 size 稳定 = 成功；超时未出现 = 可能被拦截，
**截图取证**（整页截图能拍下浏览器顶部的拦截条，人能从截图看到）+ 报告用户"下载超时，可能
被浏览器拦截，请打开浏览器点[保留危险文件]"。

**绝不在 flow 里尝试关闭 Chrome 安全下载保护**——那是用户安全设置，只能通知，不能改。

## 探查留痕:配合 cateye-probe（推荐, 非本包依赖）

探查的价值不只是"这次探出来了", 更是**过程可回溯**——事后能重建"当时怎么一步步探出来的、
每步页面的反应是什么"。采集审计、bug 复现、问题排查都依赖这个。

**[cateye-probe](https://www.npmjs.com/package/cateye-probe)** 是一个独立的 trace 工具（npm 包），
能把探查过程结构化留痕：事件流（events.ndjson）+ 三态结果（pass/fail/skip）+ 产物附件（截图/数据）。
它和 BrowserClient 是**天然搭档**：browser 做探查动作，cateye-probe 记录反应。

> cateye-probe 是**可选推荐**，不是 `mooncat-browser` 的依赖。不需要它也能探查（手动 fs 落盘
> 即可）；需要可回溯 trace 时推荐搭配。两者都是独立包，互不耦合。

### 分工

| | 职责 | 来源 |
|---|---|---|
| browser | 决定做什么探查（导航/交互/读取），执行动作 | `mooncat-browser` 的 `BrowserClient` |
| cateye-probe | 把过程留痕（事件流/结果/产物） | 独立 npm 包 `cateye-probe`（`import { probe } from "cateye-probe/sdk"`） |

### 组合范式骨架

一个探查脚本里：browser 做动作，probe 在关键节点写痕迹。每个 step 问一个问题、留一段 trace。

```ts
import { BrowserClient } from "mooncat-browser";
import { probe } from "cateye-probe/sdk";   // 可选搭配, 独立包
import { writeFileSync } from "node:fs";

const browser = new BrowserClient({ baseUrl: "http://127.0.0.1:17322" });

const done = probe.section("采集订单");          // 结构化段落

// 高危平台:open({ routeMode: "extension" }),见 high-risk.md
await browser.open({ headless: false });
const tab = await browser.newTab({ url: "https://example.com/orders" });
const page = tab.pageHandle;
await browser.operate({
  pageHandle: page, action: "waitForSelector",
  params: { selector: "#orders-table", timeout: 15000 },
});

const snap = await browser.operate({ pageHandle: page, action: "snapshot" });
probe.metric("rows", String(snap.yaml).split("\n").length);   // 指标

const shot = await browser.operate({ pageHandle: page, action: "screenshot" });
writeFileSync("./tmp_shot.png", Buffer.from(shot.dataUrl.split(",")[1], "base64"));
probe.attach("订单页截图", "./tmp_shot.png", "orders.png");   // 产物进 probe artifacts

done();
probe.pass("订单采集完成");                                    // 三态结果
```

### 探查长会话分阶段（trace 驱动的探查节奏）

cateye-probe 的 session 天生分阶段——一个 session 跨多个 step，每个 step 问一个问题。
**严禁把"开页面+导航+探查+截图"全塞进单个 step，然后每个 step 都重开页面**。那是把分阶段的
step 退化成一次性脚本，白费了长会话。

**正确模型**：前面的 step 把页面开好 + 导航到位（状态留在浏览器），后面的 step 直接
**复用同一个 tab**（`browser.listTabs()` 按 url 匹配找到已开的 tab，拿它的 pageHandle）
继续探下一个问题，不重开。浏览器状态跨 step 持久——这才是长会话的意义。

> 高危平台（淘宝/京东/生意参谋）这是**安全红线**：反复 newTab+导航 = 高频异常访问 =
> 加速风控触发。同一个页面一旦打开，在 session 内反复复用。详见 [high-risk.md](high-risk.md)。

### 何时用这个组合

- **要留 trace**：探查过程要给人/AI 事后回溯（采集审计、bug 复现、问题排查）→ 用组合
- **一次操作即可**：随手打开页面看一眼、单次截图 → 直接 browser，不必套 cateye-probe

browser 的调用纪律（步进 co-work / 长会话 / 路由不泄漏）见 [SKILL.md](../SKILL.md)，
cateye-probe 的 API 与 session 管理见其包文档。本节只讲两者怎么在脚本里组合。
