- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
290 lines
12 KiB
Go
290 lines
12 KiB
Go
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()
|
||
}
|