Files
AskaEth c2e413ec38
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(io): web-fetch 直连→浏览器渲染降级档 + fetchedBy 如实标注(Z5 机制)
- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
  两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
   开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
2026-10-07 09:34:47 +08:00

290 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}