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() }