# 标准采集工作流

采集类任务的固定范式。三个动作串起来，解决 SPA 场景三大坑：
**重复打开同页堆积 tab、弹窗挡住主体、主体没渲染就采**。

## 三件套（缺一不可）

| 动作 | 解决的坑 | 怎么做 |
|---|---|---|
| 复用已有 tab | 重复打开同一页面，堆积 tab | `browser.listTabs()` 找同 host 的 tab，有就复用，没有才 newTab |
| 清弹窗 | 网页内弹窗挡住主体，snapshot/点击失效 | `evaluate` 关闭 modal/popup，或点关闭按钮 |
| 等就绪 | SPA 加载慢，采集时主体还没渲染 | `waitForSelector` 等业务节点出现 |

## 标准采集 step 模板

```ts
import { BrowserClient } from "mooncat-browser";

const SITE = "example.com";
// 端口要和实例配置 rpcPort 一致(默认 17322,多实例各自不同)
const browser = new BrowserClient({ baseUrl: "http://127.0.0.1:17322" });

// 1. 复用已有 tab (不要盲目 newTab)
//    高危平台:open({ routeMode: "extension" }),见 high-risk.md
await browser.open({ headless: false });
const tabs = await browser.listTabs();
let tab = tabs.find((t) => t.url?.includes(SITE));
if (!tab) tab = await browser.newTab({ url: `https://${SITE}/orders` });
// newTab()/listTabs() 统一返回 TabInfo, operate 传 tab.pageHandle
const page = tab.pageHandle;

// 2. 清弹窗 (网页内 modal/popup, 会挡住主体)
await browser.operate({
  pageHandle: page, action: "evaluate",
  params: { source: "() => { document.querySelectorAll('.modal-close,.popup-close').forEach(b=>b.click()); return document.querySelectorAll('[class*=modal]').length }" },
});

// 3. 等就绪 (业务节点出现才算能采)
await browser.operate({
  pageHandle: page, action: "waitForSelector",
  params: { selector: "#orders-table", timeout: 30000 },
});

// 4. 采集 (此时主体已就绪, 没弹窗)
const data = await browser.operate({
  pageHandle: page, action: "evaluate",
  params: { source: "() => Array.from(document.querySelectorAll('.order')).map(e=>e.innerText)" },
});
```

## 顺序不能乱

```
复用 tab (listTabs 找)
    ↓
清弹窗 (弹窗可能挡住就绪判定元素)
    ↓
等就绪 (主体渲染完成)
    ↓
采集
```

**清弹窗在等就绪前**：弹窗挡住时，`waitForSelector` 可能抓不到就绪元素（遮罩盖住），
导致误判没就绪。先清弹窗，再判就绪。

**不能跳过等就绪**：SPA 主体是 JS 动态渲染的，`goto`/`newTab` 返回时主体大概率没出来。
直接采会采到空。

## 弹窗类型区分（只有两类）

所有弹窗本质只有模态 / 非模态两类, 处理逻辑完全不同, 看点空白能不能关:

| 弹窗类型 | 特征 | 处理 |
|---|---|---|
| **模态弹窗** | 有遮罩(mask/overlay), 点空白**不能**关, 阻塞主体 | 找弹窗内**关闭按钮点掉**(×/关闭/我知道了/确定) |
| **非模态弹窗** | 无遮罩 或 点空白/遮罩区**能**关, 不阻塞 | **点空白处关**(点 body/遮罩外侧) 或直接隐藏 |
| 浏览器原生 dialog | alert/confirm/prompt | `setDialogHandler` action 注册处理器 |

国内站点引导弹窗（"我知道了/开始使用/领取优惠"）常见, 模态的按文本点关闭:

```ts
await browser.operate({
  pageHandle: page, action: "evaluate",
  params: { source: "() => { const btns=[...document.querySelectorAll('button,span,a')]; const t=btns.find(b=>/我知道了|确定|关闭|开始使用/.test(b.textContent||'')); if(t){t.click();return true} return false }" },
});
```

**优先判定"点空白能不能关", 不看弹窗叫什么名字。** 模态点空白没用必须找按钮;
非模态点空白就关。混淆会点不到或点错。

### 通用清弹窗范式（开页第一件事, 关键页操作前必走一次）

开页后第一件事是清弹窗, 然后才等就绪/采数据/点控件。万相台/生意参谋/淘宝系站点尤其多
新手引导热点遮罩（**文本为空的半透明遮罩**, 不存盘只看 DOM 极隐蔽）。

```ts
// 通用清弹窗: 模态找按钮点, 非模态点空白关。一次扫两类。
// 返回关了几个, 供日志。每个关键页操作前必走一次。
async function clearPopups(page) {
  return await browser.operate({
    pageHandle: page, action: "evaluate",
    params: { source: `() => {
      let cleared = 0;
      // 1. 模态弹窗: 有遮罩且有按钮的, 点按钮(优先 ×/关闭/我知道了/确定)
      const modals = [...document.querySelectorAll('[class*=modal],[class*=dialog],[class*=popup],[role=dialog],[class*=mask],[class*=overlay],[class*=guide-hotspot],[class*=hotspot]')]
        .filter(e => e.offsetParent);
      for (const m of modals) {
        const btn = [...m.querySelectorAll('button,span,a,i,div,[role=button]')]
          .find(b => b.offsetParent && /×|✕|关闭|我知道了|知道了|确定|不再提示|跳过|开始使用|下次再说/.test((b.textContent||'').trim()));
        if (btn) { btn.click(); cleared++; }
      }
      // 2. 通用兜底: 全局找 ×/关闭/我知道了 按钮
      const gBtns = [...document.querySelectorAll('button,span,a,i,[role=button]')]
        .filter(e => e.offsetParent && /×|✕|关闭|我知道了|知道了|确定|跳过/.test((e.textContent||'').trim().slice(0,8)));
      for (const b of gBtns.slice(0,3)) { b.click(); cleared++; }
      // 3. 非模态弹窗: 点空白处关(点 body 顶层, 触发 clickoutside)
      const masksNoBtn = modals.filter(m => ![...m.querySelectorAll('*')].some(b => b.offsetParent && /×|关闭|我知道了|确定/.test((b.textContent||''))));
      if (masksNoBtn.length) { document.body.click(); cleared++; }
      return { cleared, remainModal: modals.length };
    }` },
  }).catch(() => ({ cleared: 0 }));
}

// 每个关键页操作前的标准顺序: goto → 清弹窗 → 等就绪 → 操作
await browser.operate({ pageHandle: page, action: "goto", params: { url } });
await sleep(2500);
await clearPopups(page);          // ← 开页第一件事, 清弹窗
await browser.operate({ pageHandle: page, action: "waitForSelector", params: { selector: targetEl, timeout: 15000 } });
```

**为什么清弹窗在等就绪前**: 弹窗遮罩盖住主体时, waitForSelector 可能抽不到就绪元素,
误判没就绪; 点控件会点到遮罩上。先清干净再判就绪。

## 等就绪的判定源

`waitForSelector` 等业务元素出现即就绪。判定源选择：

- **selector**：业务关键节点（如 `#orders-table`、`.data-panel`）出现即就绪
- **兜底**：用 `waitForLoadState`（state=networkidle）等网络空闲

没就绪的处理：等待超时后**停下报告用户**，不要采垃圾数据。SPA 主体没出来就采，
采到的是空壳，浪费整轮。

## 何时不用这套

- **简单静态页**（无弹窗、主体在 DOMContentLoaded 就绪）：直接 `waitForSelector` + 采集。
- **纯读取场景**（status/snapshot/screenshot）：本身不改状态，但若页面有弹窗挡住，仍建议先清弹窗。

## 中间产物落盘

采集每一步的中间产物（snapshot / 截图 / 抓取的数据）建议落盘到本地目录，便于审计 + 给用户看。
用 Node 原生 `fs` 写即可（`mooncat-browser` 不提供文件落盘能力，那是你应用层的职责）：

```ts
import { writeFileSync } from "node:fs";

const snap = await browser.operate({ pageHandle: page, action: "snapshot" });
writeFileSync("./trace/orders-snapshot.yaml", snap.yaml);

const shot = await browser.operate({ pageHandle: page, action: "screenshot" });
if (shot?.dataUrl) writeFileSync("./trace/orders.png", Buffer.from(shot.dataUrl.split(",")[1], "base64"));
```

> 如果你在更大的系统里（有自己的 trace/artifacts 体系，如 mooncat 的 var/probe + cateye-probe），
> 用那套体系替代这里的手动 fs 即可。本 skill 只规定"中间产物要落盘"的纪律。
