- 默认 auto 只走免费链 searxng -> duckduckgo;即使环境里有 付费 key 也不调用 Perplexity/Exa/Tavily/Brave,SearXNG 失败也不回落付费,默认使用零付费花费。 - 付费后端代码/测试/文档全部保留;显式 pin(tavily/exa/ perplexity/brave)仍照常调用,是有意覆盖不是默认。 - 解冻开关 WEBSEARCH_ENABLE_PAID,仅精确值 "1" 才恢复旧链 (perplexity/exa/tavily/brave/searxng/duckduckgo),默认关; 开关经 worker search_env 透传进 MCP config env。 - HttpClient 超时/DNS 失败改报显式错误(timeout after Ns / URL error),绝不把超时写成“无结果”。 - SearXNG 默认参数核实:代码无 SEARXNG_URL 硬编码默认值; SEARXNG_ENGINES 默认 bing,360search 已在基线;WEBSEARCH_TIMEOUT 默认 20 秒。主机 env 实测仅 SEARXNG_URL=http://searx.diante.me 一行,故默认 pin 生效;文档 https 漂移改回 http(主机 nginx 仅听 80、无 443 vhost,https 在 TLS 层即失败)。 - 旧测试更新原因:test_websearch_mcp 原断言默认 auto 为 perplexity 优先,与新冻结语义直接冲突,故改为断言冻结后 的 searxng -> duckduckgo 回退 + 不泄漏 key;旧 perplexity -> exa 回退断言移至解冻开关测试(WEBSEARCH_ENABLE_PAID=1 下)。 未删断言、未加 skip、未放宽门禁。 - 文档另删 WEBSEARCH_PROVIDERS 说法:代码从未实现该变量。 - 未推、未建 PR、未合并、未部署、未改主机 env/nginx/filter/ 证书、未做付费后端 live 验证、未创建生产任务。
408 lines
22 KiB
Markdown
408 lines
22 KiB
Markdown
# 网页搜索 MCP v1(oh-my-pi 模式,Python 实现)
|
||
|
||
目标:给 codex 任务一个可用的 `web_search` / `web_fetch`,
|
||
不把 oh-my-pi 的 TypeScript 代码原样搬过来(codex 认不出
|
||
pi 的工具),只复用它的模式:统一工具 + provider 抽象 +
|
||
`auto` 链式回退 + 抓取转干净文本。
|
||
|
||
## 文件
|
||
|
||
- `cloud/websearch.py`:provider 链、抓取、codex-home 播种
|
||
助手。纯标准库,无第三方依赖,这样它能不经 pip 安装就
|
||
在宿主和 Docker 容器里跑。
|
||
- `cloud/websearch_mcp.py`:stdio JSON-RPC MCP 服务器,
|
||
只做协议帧,逻辑全在 websearch.py。日志只写 stderr,
|
||
stdout 是 JSON-RPC 流,绝不能混日志。
|
||
- `cloud/tests/test_websearch_mcp.py`:12 个单测,全部
|
||
mock HTTP,不联网、不需要真实 key。
|
||
- `cloud/tests/test_code_search.py`:编程作用域与抽取回退链
|
||
的单测,同样全部 mock HTTP、不联网、不需要真实 key。
|
||
- `cloud/tests/test_code_search_ext.py`:任务级
|
||
`allowed_hosts` 扩展的单测(指定 / 未指定 / 点边界 /
|
||
空值与非法值 / Perplexity 超 20 域 / MCP 透传)。
|
||
- `cloud/tests/test_websearch_paid_freeze.py`:付费冻结单测
|
||
(默认 auto 无付费后端、SearXNG 失败不回落付费、显式
|
||
pin 仍工作、解冻开关关时不变 / 开时恢复旧链、
|
||
DuckDuckGo 兜底失败报错清晰、超时报错清晰;全部
|
||
mock HTTP,不联网、不需要真实 key)。
|
||
- `cloud/tests/test_js_heavy_extract.py`:JS 重型页面
|
||
抽取评估的 mock 单测(直接抓取壳-only、provider 抽取
|
||
补与不补、回退语义;live SearXNG 烟囱默认跳过)。
|
||
|
||
## 工具
|
||
|
||
- `web_search(query, limit=5, provider="auto", scope="general")`:返回
|
||
`{provider, results:[{title, url, snippet, score?}], answer?, attempted, scope}`,
|
||
`scope="code"` 时另带 `code_domains`。
|
||
`answer` 只有 provider 本身给合成答案时才有(目前是
|
||
Perplexity Sonar);其它 provider 的总结仍由主模型做。
|
||
`scope` 详见下面“编程搜索 scope=code”一节。
|
||
- `web_fetch(url, char_limit=15000)`:走 `fetch_best` 抽取回退链
|
||
(Tavily extract → Exa contents → 直接抓取),返回
|
||
`{url, title, text, links, truncated, source}`(`source` 为
|
||
`tavily-extract` / `exa-contents` / `direct-fetch`)。
|
||
超长按头 75% + 尾 25% 截断并标 `[TRUNCATED]`;只接受 http(s),拒绝
|
||
localhost 与字面私有 IP;抽取与直接抓取都失败时报错,不伪造页面。
|
||
|
||
## Provider 链:默认冻结,显式 pin 与显式解冻除外
|
||
|
||
冻结语义(本轨起,默认 `auto` 只走免费路线,顺序固定):
|
||
|
||
1. `searxng`:`GET $SEARXNG_URL/search?format=json`,需自托管
|
||
实例且 JSON 格式已打开。默认参数:`engines=bing,360search`
|
||
(2026-10-30 主机实测:默认 general 类别 20.0s 撞超时,
|
||
pinned 两引擎 0.47s;`SEARXNG_ENGINES` 可覆盖,空值恢复
|
||
不 pin)。`SEARXNG_URL` 在代码里无默认值:未配时该后端
|
||
直接报 `searxng not configured (SEARXNG_URL unset)`,链
|
||
只剩 DuckDuckGo;生产值 `http://searx.diante.me` 来自主
|
||
机 env(见“生产接线”),不在代码里写死;主机 env 实测
|
||
仅有 `SEARXNG_URL` 这一行、无 `SEARXNG_ENGINES` 行,
|
||
所以默认 pin(bing,360search)生效。注意是 http 不是
|
||
https:主机 nginx 只听 80、无 443 vhost,https 会在 TLS
|
||
层证书主机名不匹配失败,根本到不了 SearXNG。
|
||
2. `duckduckgo`:解析 `https://html.duckduckgo.com/html/`,
|
||
免 key 兜底,布局漂移时只会返回空结果并继续走链,
|
||
不会把工具打崩;但它在两种出口下都不可作可用兜底:
|
||
本 VM 直连回 202 + 机器人挑战页、0 条,主机 Tinyproxy
|
||
路径约 25 秒超时。兜底失败时记为 `no results` / 超时并
|
||
如实报错,不伪造结果;因此 SearXNG 挂时冻结默认链可能
|
||
以诚实失败告终,这是已知边界,不是静默降级。
|
||
|
||
冻结保证:即使环境里配了付费 key,默认 auto 也绝不调用
|
||
Perplexity / Exa / Tavily / Brave、绝不产生付费花费;
|
||
SearXNG 失败后默认也不回落到付费后端,只走 DuckDuckGo。
|
||
付费后端的代码、测试、文档一律保留,未删除。
|
||
|
||
显式 pin 仍工作:`provider="tavily"` / `"exa"` /
|
||
`"perplexity"` / `"brave"` 显式指定时照常调用,这是用户
|
||
有意的覆盖,不是默认行为;pin 失败不回退(与冻结前一致)。
|
||
|
||
解冻开关:`WEBSEARCH_ENABLE_PAID=1`(且仅精确值 `1`,
|
||
`true` / `yes` / `0` 都不算开,默认关)时,默认 auto
|
||
恢复旧链,顺序为 Perplexity → Exa → Tavily → Brave →
|
||
SearXNG → DuckDuckGo(旧链里只试有 key 的付费后端 +
|
||
已配 URL 的 SearXNG + DuckDuckGo)。解冻是显式动作,
|
||
会产生付费花费,绝不能被默认链静默触发;开关走 worker
|
||
环境透传进 MCP config env(见“生产接线”)。
|
||
|
||
旧链各后端(解冻后 / 显式 pin 时)的请求形态不变:
|
||
|
||
1. `perplexity`:`POST https://api.perplexity.ai/chat/completions`,
|
||
模型 `sonar`,`Authorization: Bearer $PERPLEXITY_API_KEY`。
|
||
返回合成答案 + `search_results` / `citations`。
|
||
2. `exa`:`POST https://api.exa.ai/search`,头 `x-api-key`,
|
||
`type: auto`,`contents.text.maxCharacters: 2000` +
|
||
`highlights: true`。
|
||
3. `tavily`:`POST https://api.tavily.com/search`,
|
||
`Authorization: Bearer $TAVILY_API_KEY`,
|
||
`search_depth: basic`,`include_answer: false`(工具只返
|
||
结果,总结留给模型)。
|
||
4. `brave`:`GET https://api.search.brave.com/res/v1/web/search`,
|
||
头 `X-Subscription-Token`,`extra_snippets: true`。
|
||
|
||
规则:任一后端 HTTP 失败或 0 结果都继续下一个;全部失败
|
||
返回各后端错误(不含 key)。超时失败报
|
||
`timeout after Ns waiting for HTTP response`(N 为
|
||
`WEBSEARCH_TIMEOUT`,默认 20 秒),并记在该后端的
|
||
attempted 条目里,绝不把超时写成“无结果”或空答案。
|
||
`WEBSEARCH_TIMEOUT`(秒,默认 20)改单次超时。另注意:
|
||
旧文档曾写可用 `WEBSEARCH_PROVIDERS` 改默认链顺序,
|
||
代码里从未实现该变量,本轨已删此说法(不另新加该变量,
|
||
保持改动最小)。
|
||
|
||
## 接入 codex(测试项目做法)
|
||
|
||
codex 从 codex-home 的 `config.toml` 读 MCP 配置:
|
||
|
||
```toml
|
||
[mcp_servers.websearch]
|
||
command = "python3"
|
||
args = ["/path/to/codex-home/websearch_mcp.py"]
|
||
```
|
||
|
||
`ensure_codex_home_websearch(codex_home_dir, command_args=...)`
|
||
会把 `websearch.py` / `websearch_mcp.py` 拷进该 codex-home,
|
||
并在缺失时追加上面这段(已有 section 绝不重写;key 绝不
|
||
写进 config.toml,key 只走 MCP 进程的环境变量)。
|
||
注意:同一个 config.toml 不能同时装宿主路径和容器路径,
|
||
Docker 租户必须传容器内路径
|
||
`/var/lib/codex-app-server/codex-home/websearch_mcp.py`,
|
||
因为该目录正是租户 codex-home 的容器内挂载点。
|
||
|
||
## 生产接线(worker + egress + 自托管 SearXNG)
|
||
|
||
- worker 自动播种:`cloud/worker.py --websearch-mcp`(默认关)。
|
||
开启后 `_tenant_dirs` 在播种 config.toml 之后调播种助手,
|
||
写容器内路径 `/var/lib/codex-app-server/codex-home/websearch_mcp.py`。
|
||
老租户下一次任务自动合并(`_tenant_dirs` 每次派发都调到),
|
||
已有 section 不重写。autoscaler unit 已加 `--websearch-mcp`,
|
||
但 unit 文件不在 deploy tar 里,主机上须单独 scp +
|
||
daemon-reload + restart autoscaler 才生效(见部署教训)。
|
||
- egress 白名单:`deploy/egress/tinyproxy-filter` 已加
|
||
Perplexity、Exa、Tavily、Brave、DuckDuckGo 与
|
||
`searx.diante.me`(精确匹配,不用 diante.me 通配,否则
|
||
任务经代理也能摸到 api.diante.me 控制面;filter 行即
|
||
`^searx\.diante\.me$`,本轨不改这行)。主机上须把
|
||
该文件拷到 `/etc/tinyproxy/tinyproxy-filter` 并重载
|
||
tinyproxy,重载后先验中转域名还通,再验新域名。
|
||
- 自托管 SearXNG(公开子域名路):主机 Docker 跑
|
||
`searxng/searxng`,经 nginx(仅听 80,无 443 vhost、无
|
||
证书)反代到 `searx.diante.me`,MCP 侧
|
||
`SEARXNG_URL=http://searx.diante.me` 走 worker 进程
|
||
环境(`/etc/codex-app-server/env`)继承到 codex 再到
|
||
MCP,不写进 config.toml。`WEBSEARCH_ENABLE_PAID` 同理
|
||
走 worker 的 search_env 透传进 `[mcp_servers.websearch.env]`
|
||
(worker 代码已含该键);主机侧改 env 文件后需重启相关
|
||
unit 才生效——那是部署动作,不在本轨。公开实例的代价是可能被外人
|
||
刷:SearXNG limiter 保持开启,JSON 格式是 MCP 要用的
|
||
唯一格式。未加域名进白名单之前,Docker 任务的搜索
|
||
会被代理 403 挡住(fail closed,工具报错,不会静默绕过)。
|
||
- 付费后端实测:付费后端只以 mock 响应验证了请求
|
||
字段与结果映射,未用真实 key 跑过;上生产前须用真实
|
||
key 在测试项目跑一遍,再谈默认链顺序是否合理。
|
||
|
||
## 编程搜索 scope=code(v2 新增,只做编程任务搜索)
|
||
|
||
选型:`web_search` 加 `scope="code"` 参数,不另立
|
||
`code_search` 工具。理由:同一条 provider 链、同一套
|
||
回退与 key 规则,只多一层域名限定;另立工具会复制
|
||
schema、handler 与播种文档两套,且调用方要在两个
|
||
工具间二选一。代码(`cloud/websearch.py` 的
|
||
`CODE_SEARCH_DOMAINS` / `search(..., scope=...)`)、
|
||
MCP 工具描述(`cloud/websearch_mcp.py` 的
|
||
`web_search` 描述与 `scope` enum)与本文三处一致,
|
||
只有 `general` / `code` 两个值,未知 scope 在发任何
|
||
HTTP 之前就报 `ValueError`,绝不静默退化成无限定搜索。
|
||
|
||
### 允许域清单(实际落代码的 15 个,含子域名)
|
||
|
||
- 官方文档:`docs.python.org`、`nodejs.org`、`go.dev`、
|
||
`pkg.go.dev`、`docs.rs`、`doc.rust-lang.org`、
|
||
`developer.mozilla.org`
|
||
- 代码托管:`github.com`、`githubusercontent.com`
|
||
(GitHub 自己的 raw host,blob 页之外原始文件所用)
|
||
- 包仓库:`pypi.org`、`npmjs.com`、`crates.io`
|
||
- 问答与安全:`stackoverflow.com`、`nvd.nist.gov`、`cve.org`
|
||
|
||
匹配规则是域名本身或其子域名(点边界匹配:
|
||
`gist.github.com` 算、`evilgithub.com` 不算)。
|
||
changelog / release notes 只有落在上述域名时才在
|
||
范围内(例如项目在 GitHub 的 Releases 页);没有
|
||
“项目官方域名自动识别”——不在清单里的项目官网
|
||
(如某框架自己的 docs 站)本版不返回,这是明确的
|
||
缺口,不是泛化成任意站的口子。
|
||
|
||
### 不在范围清单(scope=code 下一律过滤掉)
|
||
|
||
通用新闻、购物、社交、地点、机票酒店、天气、行情、
|
||
视频/图片站、博客聚合与其它任意站。`scope="code"`
|
||
不是“优先编程来源”,是硬过滤:非清单域的结果不
|
||
返回、不降级展示,只在 `attempted` 里记
|
||
`domain_filtered_out` 数量。
|
||
|
||
### 各后端如何限定(先核实请求字段再写代码)
|
||
|
||
- 原生支持,直接传:Tavily `include_domains`、
|
||
Exa `include_domains`、Perplexity Sonar chat
|
||
completions 的顶层 `search_domain_filter`(上限
|
||
20 个域,本清单 15 个,整单可传)。
|
||
- 原生不支持,不编造参数:Brave 网页搜索端点没有
|
||
`include_domains` 参数(多域 `site:` 改写语义未经
|
||
证实,不改写查询),SearXNG / DuckDuckGo 同样不传;
|
||
这三家只靠 `search()` 返回后的统一事后过滤。
|
||
- 双保险:原生限定的后端返回后同样再过一次事后
|
||
过滤,所以各后端在 code 作用域下的语义一致。
|
||
- 链语义:某后端结果被全部滤掉时,该后端记为
|
||
“no results”(`code-domain filter removed all N
|
||
results`),链继续走下一个后端;全部后端都空才
|
||
报错。
|
||
|
||
### 清单外项目官网域:任务级显式扩展 `allowed_hosts`
|
||
|
||
这不是“任意站搜索”,仍是显式白名单机制,只是白名单
|
||
可以由调用方在当次调用里显式加域。选型是任务级参数
|
||
`(a)`,不是配置级扩展清单 `(b)`,理由:改动最小
|
||
(只动 `search()` 签名与 MCP 透传,不动 worker、
|
||
unit、env、播种与主机配置);按调用隔离(某任务要
|
||
某项目官网,不会把该域放宽给所有任务——配置级清单
|
||
一旦写入就是全局放宽,安全姿态更差);用完即弃
|
||
(不持久化,下次调用不带就不生效)。
|
||
|
||
用法:`search(..., scope="code",
|
||
allowed_hosts=["docs.example-project.dev"])`,MCP
|
||
侧 `web_search` 的 `allowed_hosts` 数组字段同名透传。
|
||
规则(全部由 `cloud/websearch.py` 的
|
||
`_normalize_allowed_hosts` 在发任何 HTTP 之前强制):
|
||
|
||
- 只在 `scope="code"` 下生效;`scope="general"` 下传
|
||
`allowed_hosts` 直接报 `ValueError`,绝不静默忽略;
|
||
不传时行为与本版之前完全一致(仍是 15 域)。
|
||
- 生效清单 = 内置 15 域 + 本次显式 extras(去重、保序);
|
||
返回结果的 `code_domains` 即为该生效清单。
|
||
- 匹配仍是点边界:子域名算(`api.docs.example-project.dev`
|
||
算),`evildocs.example-project.dev` 与
|
||
`docs.example-project.dev.evil.com` 都不算。
|
||
- 空值 / 非法值报错不静默退化:`[]`、裸字符串
|
||
(可迭代但不是域名列表)、`*` 通配、带 scheme /
|
||
端口 / 路径 / userinfo 的值、前导点、空标签、非字符串
|
||
元素、以及全部等于内置域(即“扩展了个寂寞”)都报
|
||
`ValueError`,且此时尚未发任何 HTTP。
|
||
- 扩展值绝不写进日志:错误串与 `attempted` 条目里不
|
||
出现扩展值;扩展值只出现在调用方自己那次结果的
|
||
`code_domains` 回显里(调用方自己指定的值回显给
|
||
自己,不是日志)。
|
||
- 与原生后端限定方式的关系,和现有逻辑一致:原生
|
||
支持的后端(Tavily / Exa 的 `include_domains`)直接传
|
||
生效清单;Perplexity 的 `search_domain_filter` 上限
|
||
20 个域,内置 15 + 扩展超 20 时跳过其原生过滤、只靠
|
||
事后过滤(事后过滤永远是权威,语义不变);原生不
|
||
支持的后端(Brave、SearXNG、DuckDuckGo)本来就不传
|
||
参数,只靠 `search()` 返回后的统一事后过滤。
|
||
- `web_fetch` 不受 `allowed_hosts` 影响:fetch 本来就是
|
||
按指定 URL 抓取,没有域名白名单可扩展。
|
||
|
||
### 抓取回退链与 403 语义(web_fetch)
|
||
|
||
`web_fetch` 实际调 `fetch_best`,顺序固定:
|
||
|
||
1. Tavily extract(`POST https://api.tavily.com/extract`,
|
||
`urls:[url]`,取 `results[0].raw_content`;空结果或
|
||
`failed_results` 命中都算失败)——只在配了
|
||
`TAVILY_API_KEY` 时试。
|
||
2. Exa contents(`POST https://api.exa.ai/contents`,
|
||
`ids:[url]` + `text.maxCharacters`,取 `results[0].text`,
|
||
无 text 时取 highlights 拼接;空算失败)——只在
|
||
配了 `EXA_API_KEY` 时试。
|
||
3. 直接抓取(旧 `fetch_url` 的 HTML 清洗与截断)。
|
||
|
||
抽取与直接都失败时,错误同时写明两边失败,不伪造
|
||
页面。无任何抽取 key 时 `fetch_via_provider` 单独调会
|
||
明确报“no extract provider configured”,但 `fetch_best`
|
||
会直接走直接抓取(与旧 web_fetch 行为一致),不是
|
||
报错。localhost / 私有 IP 在抽取之前就拒绝,不会经
|
||
抽取商去探内网。
|
||
|
||
403 语义:抽取走的是搜索商 API 域名(`tavily.com`、
|
||
`exa.ai`,已在 Tinyproxy 白名单里),不直接碰目标站,
|
||
所以文档站本身不在白名单时抽取仍可通;只有回退到
|
||
直接抓取时,目标域不在白名单才会被代理 403 挡住。
|
||
这是 fail-closed:报错、不静默绕过、不承诺“任意
|
||
文档站都能直接抓”。本轨**没有改**
|
||
`deploy/egress/tinyproxy-filter`(也不改主机上的文件):
|
||
若将来主机侧要让直接抓取也通,需另行走主机变更,
|
||
filter 行须用 POSIX BASIC 正则(`^\(.*\.\)*域名$`)、
|
||
文件 644,extended 锚定写法会把允许域也挡成 403
|
||
(见部署教训);该变更不在本轨授权内。
|
||
|
||
### 安全与隔离(不变)
|
||
|
||
- key 只走 MCP 进程环境变量,绝不写进 config.toml、
|
||
日志、测试、文档;本轨测试用的 key 全是
|
||
`*-SECRET-*` 假值。
|
||
- worker 开关默认关;租户播种幂等、已有 section
|
||
不重写;Docker 环境变量走 extra_env +
|
||
`[mcp_servers.websearch.env]` 路径。
|
||
- `web_fetch` 仍只接受 http(s)、拒 localhost 与私有
|
||
IP,且它不是沙箱边界(danger-full-access 任务本
|
||
来就能 curl)。
|
||
- 老租户注意:播种助手从不覆盖内容已变的租户脚本
|
||
(既有防覆盖规则),所以 scope 只对新播种的租户
|
||
立即生效;老租户拿到新脚本需另行决策,不在本轨
|
||
改动该规则。
|
||
|
||
## 诚实边界
|
||
|
||
- 本批只做代码 + 单测 + 测试租户目录的配置合并验证,
|
||
未推分支、未建 PR、未合并、未部署、未改生产 worker
|
||
与 egress 配置、未跑真实 codex 任务。
|
||
- 付费冻结本轨同样只在本地提交里:生产默认链要等部署
|
||
新代码后才变;解冻开关在主机上默认仍是关(除非主机
|
||
env 显式设为 `WEBSEARCH_ENABLE_PAID=1`,且那需要
|
||
主机侧改 env + 重启 unit,属部署动作)。
|
||
- DuckDuckGo 的 HTML 解析是兜底手段,布局一变就可能
|
||
0 结果;它存在的意义是“没 key 也能走通链路”,不是
|
||
长期依赖的搜索后端。实测(2026-10-28,VM 出口):
|
||
html / lite 端点均回 202 + 机器人挑战页(anomaly),
|
||
不是解析器 bug——在这个出口下免 key 搜索实际不通,
|
||
免 key 可用性最终要靠自托管 SearXNG 或付费后端。
|
||
- `web_fetch` 不是安全边界:danger-full-access 的任务
|
||
本来就能 curl,fetch 只挡了最明显的非 http(s) 与
|
||
本地私有地址,不应把它当沙箱用。
|
||
- 编程搜索本轨未做(见回执):真实 key 的 live 测试、
|
||
主机 unit / filter / env 接线、GitHub API token
|
||
(本设计走网页搜索后端限定域名,不调 GitHub API,
|
||
所以不需要 token;真要 GitHub 代码内容搜索 API 是
|
||
另一功能,不在本轨)、老租户脚本升级。清单外项目
|
||
官网域本轨已做(见上节 `allowed_hosts`,本地提交、
|
||
未推未部署),仍缺真实 key 的 live 验证。
|
||
|
||
## JS 重型站点抽取效果评估(本轨新增,mock 为主)
|
||
|
||
评估对象是已有抽取链,不改默认抽取顺序、不因评估换
|
||
后端:`fetch_best` 固定为 Tavily extract → Exa contents
|
||
→ 直接抓取。结论只写在这里,不改代码行为。
|
||
测试文件:`cloud/tests/test_js_heavy_extract.py`(全部
|
||
mock HTTP,只有一个显式开关的 live 烟囱测试,默认跳过)。
|
||
|
||
### 阶段 1:直接抓取能拿到什么、拿不到什么
|
||
|
||
直接抓取是标准库 HTML 解析:`<script>` / `<style>` /
|
||
`<noscript>` 内容一律剥离,只留静态文本与绝对 http(s)
|
||
链接(相对链接不进 `links` 列表)。所以:
|
||
|
||
- 能拿到:静态壳里的标题、占位文本(如 "Loading…")、
|
||
静态正文、绝对链接;首屏静态内容本身就是正文的页面
|
||
不受影响。
|
||
- 拿不到:正文靠 JS 渲染的段落(mock 壳里
|
||
`RENDERED-BODY-PARAGRAPH` 只存在于 `<script>` 数据块
|
||
中,直接抓取结果里没有它,也不把数据块原文泄漏进
|
||
文本);分页靠 JS 加载的后续项(mock 里第 2 页
|
||
`ITEM-PAGE-2` 只经 `/api/items?page=2` 到达,直接抓取
|
||
只有第 1 页静态项,也拿不到该 API 地址本身)。
|
||
- 为什么:解析器没有 JS 引擎,也从不执行脚本或追
|
||
JS 发起的请求;这是结构性缺失,不是解析器 bug,
|
||
调解析参数补不回来。直接抓取在 JS 重型站上只能
|
||
给出“壳 + 首屏静态部分”,不能谎称是全文。
|
||
|
||
### 阶段 2:provider 抽取在 mock 层面能补什么、补不了什么
|
||
|
||
mock 层面(伪造的 Tavily extract 响应返回完整渲染文本)
|
||
证明链路本身能补:`fetch_via_provider` 返回
|
||
`source=tavily-extract` 的完整文本;抽取与直接都失败时
|
||
`fetch_best` 回退到直接抓取的壳(`source=direct-fetch`),
|
||
仍拿不到渲染正文——回退保证的是“不断链”,不是“补全文”。
|
||
|
||
mock 补不了、也证明不了的部分:
|
||
|
||
- 真实 provider 是否真的渲染该站的 JS(取决于各家
|
||
extract 实现是否跑无头浏览器、等多久、反爬策略),
|
||
mock 响应是“我假设它渲染了”,不是实测证据。
|
||
- JS 分页 / 交互(点“加载更多”、登录后内容、滚动加载)
|
||
provider 抽取一般只取单 URL 的渲染结果,不会替调用方
|
||
翻页或交互;多页内容仍需逐 URL fetch。
|
||
- 抽取返回空时是失败,不是空页:`fetch_via_provider`
|
||
空结果报 `WebSearchError`,不伪造页面;但 `fetch_best`
|
||
随后回退直接抓取会返回壳文本——调用方须以 `source`
|
||
字段分辨“渲染全文”与“壳”,不能只看 `text` 非空就当全文。
|
||
|
||
### Live 对照(与 mock 分开写,未验证的不写成已验证)
|
||
|
||
- 无 key 的 SearXNG 路线:旧 VM 测试曾用
|
||
`SEARXNG_URL=https://searx.diante.me` 失败(`URLError:
|
||
Remote end closed connection without response`)——该失败
|
||
现已归因:https 是错的,主机 nginx 只听 80、无 443
|
||
vhost,https 在 TLS 层证书主机名不匹配失败,根本没到
|
||
SearXNG,不能据此断言服务挂了。2026-11-01 探针实测:
|
||
http 经同一 Tinyproxy 0.34 秒回 200、16 条结果,服务
|
||
活着;主机 env 仅 `SEARXNG_URL=http://searx.diante.me`
|
||
一行、无 `SEARXNG_ENGINES`,默认 pin 生效。容器内真实
|
||
任务派发仍是 blocker(不由本轨创建生产任务),live
|
||
对照在该项上仍记为**未完成**;测试文件里的 live 烟囱
|
||
测试默认跳过,只在 `WEBSEARCH_EVAL_LIVE_SEARXNG=1`
|
||
且配了 `SEARXNG_URL` 时才跑。
|
||
- 付费后端 live 效果(Tavily extract / Exa contents /
|
||
Perplexity / Brave 的真实 JS 渲染能力):无 key,本轨
|
||
**一律未验证**,不编造效果,逐项列为 blocker(见回执),
|
||
在拿到 key 之前,文档里关于 provider 渲染能力的任何
|
||
表述都只是 mock 层面的链路结论。
|