feat(io): web-fetch 直连→浏览器渲染降级档 + fetchedBy 如实标注(Z5 机制)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
  两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
   开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
This commit is contained in:
AskaEth committed 2026-10-07 09:34:47 +08:00
1 parent 20f82c9679
commit c2e413ec38
6 files changed
+687 -41

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
package io
import (
"context"
"errors"
"fmt"
"mime"
"net/http"
"strings"
"unicode/utf8"
)
// 本文件实现 design/28 §1 M5(搜索/取正文的降级档)与 §8 B5:
//
// 直连 fetch(既有 SSRF 白名单)→ 失败 / 判定为动态页 ⇒ 浏览器渲染取正文
//
// 并在返回体里**如实标注实际走了哪条**(`fetchedBy`)。挂点是 web-fetch ——
// 「取正文」这件事在本项目里由它承担(web-search 返回的是上游给的摘要/正文片段,
// 它自己不抓页面正文)。
//
// 四条口径:
//
// 1. **默认关**(见 WithBrowserFallback):不注入渲染器就是关。浏览器渲染要起会话
// 甚至起进程,慢且重;M5 是**降级档**不是主路径,绝大多数站点直连足够。
// 2. **绝不静默替换**:走了哪条就写哪条;直连的失败、被跳过的事实、浏览器这一档
// 为什么没走成,全部照实留在返回体或错误里,不许用「看起来成功了」盖掉。
// 3. **不绕 SSRF**:被 SSRF 判定拒绝的目标**不触发**降级 —— 换条路去访问同一个目标
// 是绕过判定,不是降级(design/28 §4-1:判定源只有一个)。
// 4. **浏览器侧自己再判一次**:本包把**原始 URL**原样交给渲染器,不替它免检;
// 渲染器必须用同一份 `ValidateRequestURL` 自己过一遍(见 BodyRenderer 文档)。
// FetchedBy 标注「正文实际从哪条路来」。它是 design/28 §1 M5 的硬要求:
// 必须显式标注来源,不许静默替换。
const (
// FetchedByDirect 表示正文来自直连 fetch(既有 SSRF 白名单那条路)。
FetchedByDirect = "direct"
// FetchedByBrowser 表示正文来自浏览器渲染后的页面正文。
FetchedByBrowser = "browser"
)
const (
// dynamicTextMinChars 是「直连拿到的可见文字够不够,才不像动态页」的下限(按字符计)。
// 骨架页(React/Vue/Angular 的空挂载点)正常只剩导航与页脚,几十个字符量级;
// 200 是「明显不止骨架」的量级,宁可漏判(少起一次浏览器)也不误判。
dynamicTextMinChars = 200
// renderedContentType 是浏览器这一档回报的内容类型。浏览器渲染只对 HTML 页面有意义,
// 而直连失败时我们拿不到原始响应头 ⇒ 如实口径是「渲染出来的正文」,不冒充原始类型。
renderedContentType = "text/html"
// reasonNoBrowserRenderer 是「浏览器降级档没打开」的原因文案(进结果与错误,不美化)。
reasonNoBrowserRenderer = "browser fallback disabled: no body renderer configured"
)
// ErrEmptyRenderedBody 是「浏览器渲染成功但一个字都没取到」。它按**失败**处理:
// 渲染出空正文不是成功,绝不用空文本冒充「取到了」。
var ErrEmptyRenderedBody = errors.New("io: web-fetch: browser render returned empty text")
// RenderedBody 是浏览器渲染一次取回的正文(渲染器只需给这两样)。
type RenderedBody struct {
// Text 是**渲染后**页面的可见正文(已去标签的纯文本)。
Text string
// Source 是渲染来源描述(本机有头 / 已跑的 CDP 端点),如实标注、不含凭据。
Source string
}
// BodyRenderer 是「浏览器渲染取正文」的注入点(design/28 §1 M5 的降级档)。
//
// 实现方必须守住三条:
//
// - **只出站**:只做对外连接,不新开任何监听端口(AGENTS §9;CDP 端口由浏览器自己
// 绑在 127.0.0.1 的随机端口,我们是连接方);
// - **自己过 SSRF**:对拿到的原始 URL 用 plugins/tools/io 的 `ValidateRequestURL`
// 再判一次 —— 那是全仓唯一的判定源(design/28 §4-1),本包不替它免检;
// - **渲染完成后才取正文**:骨架页的 DOM 摘要不是正文(design/28 §6 坑 4),
// 等待条件必须显式。
type BodyRenderer interface {
RenderBody(ctx context.Context, rawURL string) (RenderedBody, error)
}
// fetchBody 是降级链的**唯一入口**:直连 →(失败 / 判定为动态页)⇒ 浏览器渲染。
// 它只决定走哪条,并把两条路的真实结果如实带回(不静默替换、不吞掉任何一条失败)。
func (w *webFetchTool) fetchBody(ctx context.Context, rawURL string, max int64) (bodyFetch, error) {
direct, directErr := w.fetchBodyDirect(ctx, rawURL, max)
if directErr == nil && !direct.dynamicSuspect {
// 直连既成功、又不像动态页 ⇒ 停在原地,**绝不**无谓地起一次浏览器。
return directBodyFetch(direct), nil
}
if errors.Is(directErr, ErrSSRF) {
// 被拒的目标不换路(口径 3)。
return bodyFetch{}, directErr
}
if w.renderer == nil {
// 这一档没打开:如实报原因,并把直连的真实结果/失败原封不动带出去。
return unavailableBrowserFallback(direct, directErr, reasonNoBrowserRenderer)
}
rendered, renderErr := w.renderBody(ctx, rawURL, max)
if renderErr != nil {
return browserFallbackFailed(direct, directErr, renderErr)
}
// 走成了浏览器:把直连那一档的**真实遭遇**一并标注(失败了就写失败原文)。
rendered.directError = errText(directErr)
rendered.dynamicSuspect = direct.dynamicSuspect
return rendered, nil
}
// bodyFetch 是一次取正文的结论。成功与降级失败共用一个形状,使每一条路径的
// 「如实标注」都只有**一个出口**,不会在几条分支里各写各的文案。
type bodyFetch struct {
text string
contentType string
truncated bool
// fetchedBy 是实际来源(FetchedByDirect / FetchedByBrowser)。
fetchedBy string
// directError 是直连那一档的失败原文(直连成功时为空)。
directError string
// browserSource 是浏览器渲染的来源描述(走了浏览器且成功时非空)。
browserSource string
// browserError 是浏览器那一档没能取到正文的原因:没打开、渲染失败、空正文之一。
browserError string
// dynamicSuspect 报告直连拿到的正文是否被判成「像动态页」。
dynamicSuspect bool
}
// directBody 是直连一次取回的原始结果(含「像不像动态页」的判定输入)。
type directBody struct {
text string
contentType string
truncated bool
dynamicSuspect bool
}
// fetchBodyDirect 直连取正文:与既有 web-fetch **完全同一条路** —— 同一份 SSRF 判定
// (validateRequestURL)、同一个客户端、同一个字节上限、同一套 HTML→文本。
func (w *webFetchTool) fetchBodyDirect(ctx context.Context, rawURL string, max int64) (directBody, error) {
if _, err := validateRequestURL(ctx, rawURL, w.allowedHosts); err != nil {
return directBody{}, err
}
if w.client == nil {
return directBody{}, fmt.Errorf("io: web-fetch: no underlying client")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return directBody{}, fmt.Errorf("io: web-fetch: build request: %w", err)
}
resp, err := w.client.Do(req)
if err != nil {
return directBody{}, fmt.Errorf("io: web-fetch: do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return directBody{}, fmt.Errorf("io: web-fetch: url returned status %d", resp.StatusCode)
}
mt := "text/plain"
if rawCT := resp.Header.Get("Content-Type"); rawCT != "" {
parsed, _, perr := mime.ParseMediaType(rawCT)
if perr != nil || !isTextContentType(parsed) {
return directBody{}, fmt.Errorf("io: web-fetch: unexpected content type %q", rawCT)
}
mt = parsed
}
data, truncated, err := readLimited(resp.Body, max)
if err != nil {
return directBody{}, fmt.Errorf("io: web-fetch: read: %w", err)
}
raw := string(data)
text := raw
if isHTML(mt) {
text = htmlToText(raw)
}
return directBody{
text: text,
contentType: mt,
truncated: truncated,
// 判定要在**原始 HTML**上做:转成文本后框架标记就没了。
dynamicSuspect: looksDynamic(mt, text, raw),
}, nil
}
// renderBody 走浏览器这一档,并把渲染结果收成 bodyFetch(内容类型的口径见常量注释)。
func (w *webFetchTool) renderBody(ctx context.Context, rawURL string, max int64) (bodyFetch, error) {
rendered, err := w.renderer.RenderBody(ctx, rawURL)
if err != nil {
return bodyFetch{}, fmt.Errorf("io: web-fetch: browser render: %w", err)
}
if strings.TrimSpace(rendered.Text) == "" {
return bodyFetch{}, ErrEmptyRenderedBody
}
text, truncated := truncateBodyText(rendered.Text, max)
return bodyFetch{
text: text,
contentType: renderedContentType,
truncated: truncated,
fetchedBy: FetchedByBrowser,
browserSource: rendered.Source,
}, nil
}
// unavailableBrowserFallback 处置「这一档压根没打开」:
//
// - 直连也失败 ⇒ **两条都照实报**(错误里既有直连的真实失败,也有这一档为什么没走成);
// - 直连成功但像动态页 ⇒ 仍把直连的正文交出去,但同时如实标注「页面可疑」+
// 「浏览器这一档为什么没走成」—— 不掩盖「本可以用浏览器核一遍」这件事。
func unavailableBrowserFallback(direct directBody, directErr error, reason string) (bodyFetch, error) {
if directErr != nil {
return bodyFetch{}, fmt.Errorf("io: web-fetch: %w; %s", directErr, reason)
}
out := directBodyFetch(direct)
out.browserError = reason
return out, nil
}
// browserFallbackFailed 处置「浏览器这一档走了但没取到正文」:
//
// - 直连也失败 ⇒ 两条失败**都出现在错误里**(谁也别替谁背锅);
// - 直连成功但像动态页 ⇒ 交出直连的正文,同时标注浏览器失败的原因(不掩盖)。
func browserFallbackFailed(direct directBody, directErr, renderErr error) (bodyFetch, error) {
if directErr != nil {
return bodyFetch{}, fmt.Errorf("io: web-fetch: %w; browser fallback failed too: %w", directErr, renderErr)
}
out := directBodyFetch(direct)
out.browserError = renderErr.Error()
return out, nil
}
// directBodyFetch 把直连结果收成 bodyFetch(fetchedBy 恒为 direct)。
func directBodyFetch(d directBody) bodyFetch {
return bodyFetch{
text: d.text,
contentType: d.contentType,
truncated: d.truncated,
fetchedBy: FetchedByDirect,
dynamicSuspect: d.dynamicSuspect,
}
}
// looksDynamic 报告直连拿到的 HTML 页**像不像动态页**:可见正文少于阈值,且原始 HTML
// 里带着前端框架的挂载点 / 水合数据标记 —— 那正是该让浏览器渲染的信号(design/28 §6 坑 4)。
// 非 HTML(纯文本、JSON 等)一律不算动态页:没有任何前端框架参与渲染。
func looksDynamic(mt, text, rawHTML string) bool {
if !isHTML(mt) {
return false
}
if utf8.RuneCountInString(strings.TrimSpace(text)) >= dynamicTextMinChars {
return false
}
return containsAnyFold(rawHTML, dynamicMarkers())
}
// dynamicMarkers 是前端框架「客户端渲染」的常见标记(大小写不敏感匹配)。
// 写成函数而不是包级变量(AGENTS §10:禁包级全局变量)。
func dynamicMarkers() []string {
return []string{
`id="app"`, `id="root"`, `id="__next"`, `id="__nuxt"`,
`__NEXT_DATA__`, `window.__nuxt__`, `data-reactroot`, `ng-version`,
}
}
// containsAnyFold 报告 haystack 是否包含 needles 里的任一项(大小写不敏感)。
func containsAnyFold(haystack string, needles []string) bool {
low := strings.ToLower(haystack)
for _, n := range needles {
if strings.Contains(low, strings.ToLower(n)) {
return true
}
}
return false
}
// truncateBodyText 按**字节**上限截断渲染出来的正文,且不切断一个 UTF-8 字符
// (与直连那条档同一个口径:max 是字节预算;max ≤ 0 视为无上限)。
func truncateBodyText(s string, max int64) (string, bool) {
if max <= 0 || int64(len(s)) <= max {
return s, false
}
cut := s[:max]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return cut, true
}
// errText 把 error 收成「原样文本 / 空串」(空 error 收成空串,便于直接进结果体)。
func errText(err error) string {
if err == nil {
return ""
}
return err.Error()
}