# HTML scraping regex 必须先 curl 真实 HTML 验证命中数

**日期**：2026-05-17
**触发场景**：FFAI web_search DuckDuckGo provider 实现 → 端到端测试返回 0 结果

## 现象

写 `WebSearchService.searchDuckDuckGo()` 用正则解析 DDG HTML 返回。代码上线后：
- DDG HTTP 200，37 KB body
- 但 `results = []` 返回给 LLM
- LLM 自然回复"我目前没办法查到 Faraday Future 的新闻"

调试发现 `blockRe = /<div[^>]*class="result__body"[\s\S]*?<\/div>\s*<\/div>/g` 凭印象写——以为 result__body 是 result 的外层 wrapper，里面恰好嵌套 2 个 div。

实际 DDG HTML：
```html
<div class="result results_links results_links_deep web-result">
  <div class="links_main links_deep result__body">
    <h2 class="result__title">  ← 不是 div！
      <a class="result__a" href="//duckduckgo.com/l/?uddg=...">title</a>
    </h2>
    <a class="result__snippet" href="...">snippet text</a>
    ...
  </div>
</div>
```

`result__body` 内含 `<h2>` 子节点，**`</div>\s*</div>` 永远在文档末尾才匹配上**，blockRe 命中 0 次。

## 怎么定位

10 秒诊断：

```bash
# 1. 直接拉一份真实 HTML（带 UA，避免被 anti-bot 卡）
curl -sS -A "Mozilla/5.0 (compatible; YourBot/1.0)" \
  "https://html.duckduckgo.com/html/?q=test" -o /tmp/page.html

# 2. 看你的正则关键 token 命中几次
grep -c 'class="result__a"' /tmp/page.html        # 应 = N
grep -c 'class="result__snippet"' /tmp/page.html  # 应 = N（对齐）

# 3. grep -B1 -A1 看 HTML 实际嵌套层级
grep -B1 -A1 'class="result__a"' /tmp/page.html | head -10
```

期望命中数 vs 你的正则命中数对不上 → 你的正则是错的，先看 HTML 结构再改。

## 预防规则

**HTML 抓取写 regex 三步骤**（任何一步漏了都会上线翻车）：

1. **先 curl 真实 HTML 落盘**，肉眼看 / `grep -c` 你的 selector 命中数
2. **写正则后立刻 unit test** —— `node -e "const html=fs.readFileSync(...); const re=/.../g; let m, c=0; while((m=re.exec(html))!==null) c++; console.log(c)"` 应 = 期望命中数
3. **降级到全局抽取 + 索引配对**，避免"先分 block 再抽 fields"嵌套不准的坑：
   ```ts
   const titles = [...html.matchAll(titleRe)];
   const snippets = [...html.matchAll(snippetRe)];
   // titles.length 应 === snippets.length，按 index 配对
   ```

## 项目侧 root cause

`web_search` 是新工具，没 L1 集成测试覆盖（PR 范围内承诺 follow-up）。修复后**应该立刻补 L1**——给 mock HTML fixture，确保 parser 对照实际 DDG snapshot 不漂。

跟 `e2e_test_required_for_every_change`（[[test_every_change]]）相关——build 过 ≠ 功能对。
