feat(io): web-fetch 直连→浏览器渲染降级档 + fetchedBy 如实标注(Z5 机制)
- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
This commit is contained in:
1 parent
20f82c9679
commit
c2e413ec38
6 files changed
+687
-41
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBodyRenderer 是 BodyRenderer 的测试替身:它**记录**自己被调了几次、拿到的
|
||||
// URL 是什么 —— 「直连成功时绝不碰浏览器」「被拒的目标不换路」这类要求只能靠
|
||||
// calls 计数钉住,光看返回体看不出来。
|
||||
type fakeBodyRenderer struct {
|
||||
body RenderedBody
|
||||
err error
|
||||
calls int
|
||||
urls []string
|
||||
}
|
||||
|
||||
func (f *fakeBodyRenderer) RenderBody(_ context.Context, rawURL string) (RenderedBody, error) {
|
||||
f.calls++
|
||||
f.urls = append(f.urls, rawURL)
|
||||
if f.err != nil {
|
||||
return RenderedBody{}, f.err
|
||||
}
|
||||
return f.body, nil
|
||||
}
|
||||
|
||||
// serveHTML 起一个恒返回这段 HTML 的测试服务(Content-Type 为 text/html)。
|
||||
func serveHTML(t *testing.T, doc string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, doc)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// serveStatus 起一个恒返回指定状态码的测试服务(用于造「直连失败」)。
|
||||
func serveStatus(t *testing.T, code int) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", code)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// normalDoc 是一篇「正文够长、不像动态页」的普通页面(> dynamicTextMinChars 字)。
|
||||
func normalDoc() string {
|
||||
return `<html><body><h1>Hello</h1><p>` + strings.Repeat("正文句子。", 60) + `</p></body></html>`
|
||||
}
|
||||
|
||||
// skeletonDoc 是一个只有前端框架挂载点、没有任何可见正文的**骨架页**。
|
||||
func skeletonDoc() string {
|
||||
return `<html><head><script>window.__NUXT__={}</script></head>` +
|
||||
`<body><div id="app"></div><script src="/x.js"></script></body></html>`
|
||||
}
|
||||
|
||||
// rendererTool 造一把带渲染器的 web-fetch(可选注入),并把服务主机放进白名单。
|
||||
func rendererTool(t *testing.T, srv *httptest.Server, r BodyRenderer) *webFetchTool {
|
||||
t.Helper()
|
||||
opts := []Option{WithAllowedHosts(serverHost(srv)), WithHTTPClient(srv.Client())}
|
||||
if r != nil {
|
||||
opts = append(opts, WithBrowserFallback(r))
|
||||
}
|
||||
return webFetchToolForTest(t, opts...)
|
||||
}
|
||||
|
||||
// ① 直连成功 + 不像动态页 ⇒ **绝不**走浏览器(calls 必为 0),且标注 direct。
|
||||
func TestFetchBodyChain_DirectSuccessNeverTouchesBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveHTML(t, normalDoc())
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: "不该被用到", Source: "fake"}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["fetchedBy"] != FetchedByDirect {
|
||||
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByDirect)
|
||||
}
|
||||
if r.calls != 0 {
|
||||
t.Errorf("直连成功却起了浏览器:calls = %d, urls = %v", r.calls, r.urls)
|
||||
}
|
||||
if !strings.Contains(m["text"].(string), "Hello") {
|
||||
t.Errorf("text 不是直连的正文: %q", m["text"])
|
||||
}
|
||||
// 没有发生过的事,不许出现在结果里(空字段一律不写)。
|
||||
for _, k := range []string{"directError", "browserSource", "browserError", "dynamicSuspect"} {
|
||||
if _, exists := m[k]; exists {
|
||||
t.Errorf("结果里不该有 %q(本次没有发生)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ② 直连失败 + 浏览器可用 ⇒ 走浏览器、标注 browser,并把直连的失败照实留痕。
|
||||
func TestFetchBodyChain_BrowserRendersAfterDirectFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: "浏览器渲染出来的正文", Source: "本机有头 Chrome"}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["fetchedBy"] != FetchedByBrowser {
|
||||
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByBrowser)
|
||||
}
|
||||
if m["text"] != "浏览器渲染出来的正文" {
|
||||
t.Errorf("text = %q", m["text"])
|
||||
}
|
||||
if m["browserSource"] != "本机有头 Chrome" {
|
||||
t.Errorf("browserSource = %v(来源必须如实带出)", m["browserSource"])
|
||||
}
|
||||
if de, _ := m["directError"].(string); !strings.Contains(de, "status 500") {
|
||||
t.Errorf("directError = %q,必须留下直连为什么没成", m["directError"])
|
||||
}
|
||||
// 渲染器拿到的必须是**原始 URL**(它自己要再过一遍 SSRF 判定)。
|
||||
if r.calls != 1 || len(r.urls) != 1 || r.urls[0] != srv.URL {
|
||||
t.Errorf("渲染器入参错: calls = %d, urls = %v, want [%s]", r.calls, r.urls, srv.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 浏览器不可用(这一档没打开)+ 直连失败 ⇒ 两条都照实报,绝不只报一条、绝不冒充成功。
|
||||
func TestFetchBodyChain_BrowserUnavailableReportsBothFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
wf := rendererTool(t, srv, nil) // 不注入渲染器 = 默认关
|
||||
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err == nil {
|
||||
t.Fatal("两条档都没成,必须有错误,不许冒充成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 500") {
|
||||
t.Errorf("错误里丢了直连的真实失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), reasonNoBrowserRenderer) {
|
||||
t.Errorf("错误里没说清浏览器这一档为什么没走: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 浏览器这一档走了但失败 + 直连也失败 ⇒ 两条失败都出现在错误里(谁也别替谁背锅)。
|
||||
func TestFetchBodyChain_BrowserRenderFailureKeepsBothFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
r := &fakeBodyRenderer{err: errors.New("CDP 连不上")}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err == nil {
|
||||
t.Fatal("两条档都没成,必须有错误")
|
||||
}
|
||||
for _, want := range []string{"status 500", "CDP 连不上"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误里缺 %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 直连成功但**像动态页** + 浏览器关着 ⇒ 交出直连正文,但把「页面可疑 + 浏览器
|
||||
// 这一档为什么没走」如实标出来(不掩盖「本可以核一遍」)。
|
||||
func TestFetchBodyChain_DynamicPageWithoutRendererIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveHTML(t, skeletonDoc())
|
||||
wf := rendererTool(t, srv, nil) // 不注入渲染器 = 这一档关着
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["fetchedBy"] != FetchedByDirect {
|
||||
t.Errorf("fetchedBy = %v, want %q(浏览器没走成,来源仍是直连)", m["fetchedBy"], FetchedByDirect)
|
||||
}
|
||||
if m["dynamicSuspect"] != true {
|
||||
t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"])
|
||||
}
|
||||
if m["browserError"] != reasonNoBrowserRenderer {
|
||||
t.Errorf("browserError = %v,必须说明这一档为什么没走", m["browserError"])
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 直连成功但像动态页 + 浏览器可用 ⇒ 改用浏览器渲染,并同时标注「页面可疑」。
|
||||
func TestFetchBodyChain_DynamicPageUsesBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveHTML(t, skeletonDoc())
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: "渲染后的正文", Source: "本机有头 Chrome"}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["fetchedBy"] != FetchedByBrowser {
|
||||
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByBrowser)
|
||||
}
|
||||
if m["text"] != "渲染后的正文" {
|
||||
t.Errorf("text = %q", m["text"])
|
||||
}
|
||||
if m["dynamicSuspect"] != true {
|
||||
t.Errorf("dynamicSuspect = %v,判定为动态页这件事必须留痕", m["dynamicSuspect"])
|
||||
}
|
||||
if _, exists := m["directError"]; exists {
|
||||
t.Errorf("直连其实成功了,不该有 directError: %v", m["directError"])
|
||||
}
|
||||
if r.calls != 1 {
|
||||
t.Errorf("渲染器调用次数 = %d, want 1", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 被 SSRF 判定拒绝的目标**不换路**:降级档绝不能变成绕过判定的通道。
|
||||
func TestFetchBodyChain_SSRFDeniedNeverFallsBackToBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: "不该被用到", Source: "fake"}}
|
||||
wf := webFetchToolForTest(t, WithBrowserFallback(r))
|
||||
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": "http://localhost:9/"})
|
||||
if !errors.Is(err, ErrSSRF) {
|
||||
t.Fatalf("期望 ErrSSRF,实得 %v", err)
|
||||
}
|
||||
if r.calls != 0 {
|
||||
t.Errorf("被拒的目标居然起了浏览器(calls = %d)—— 那是绕过判定,不是降级", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑧ 渲染成功但一个字都没取到 ⇒ 按失败处理(空正文不许冒充「取到了」)。
|
||||
func TestFetchBodyChain_EmptyRenderedBodyIsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: " \n "}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if !errors.Is(err, ErrEmptyRenderedBody) {
|
||||
t.Fatalf("期望 ErrEmptyRenderedBody,实得 %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 500") {
|
||||
t.Errorf("错误里还得有直连的真实失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑨ 渲染出来的正文同样受字节上限约束,且截断不切断 UTF-8 字符。
|
||||
func TestFetchBodyChain_RenderedTextRespectsMaxBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: strings.Repeat("正", 50), Source: "fake"}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL, "max_bytes": 10})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if len(m["text"].(string)) != 9 {
|
||||
t.Errorf("按 10 字节截断后应为 3 个汉字 = 9 字节,实得 %d", len(m["text"].(string)))
|
||||
}
|
||||
if m["truncated"] != true {
|
||||
t.Errorf("truncated = %v, want true", m["truncated"])
|
||||
}
|
||||
}
|
||||
|
||||
// ⑩ 开关默认关:不传选项、或显式传 nil,渲染器都是 nil(= 这一档关着)。
|
||||
func TestWithBrowserFallback_DefaultsAndNilAreOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
if wf := webFetchToolForTest(t); wf.renderer != nil {
|
||||
t.Error("默认(不传选项)必须是关:renderer 应为 nil")
|
||||
}
|
||||
wf := webFetchToolForTest(t, WithAllowedHosts(serverHost(srv)), WithHTTPClient(srv.Client()),
|
||||
WithBrowserFallback(nil))
|
||||
if wf.renderer != nil {
|
||||
t.Fatal("WithBrowserFallback(nil) 必须是关")
|
||||
}
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err == nil || !strings.Contains(err.Error(), reasonNoBrowserRenderer) {
|
||||
t.Fatalf("关着的时候直连失败必须如实报「这一档没打开」: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑪ 动态页判定的三条边界:非 HTML 不算、正文够长不算、没有框架标记不算。
|
||||
func TestLooksDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
mt string
|
||||
text string
|
||||
rawHTML string
|
||||
want bool
|
||||
}{
|
||||
{"骨架页 + 框架标记", "text/html", "", skeletonDoc(), true},
|
||||
{"骨架页但没有任何框架标记", "text/html", "", `<html><body></body></html>`, false},
|
||||
{"正文够长", "text/html", strings.Repeat("字", dynamicTextMinChars), skeletonDoc(), false},
|
||||
{"非 HTML(纯文本)", "text/plain", "", `id="app"`, false},
|
||||
{"非 HTML(JSON)", "application/json", "", `{"mount":"id=\"app\"","x":"id=\"root\""}`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := looksDynamic(tc.mt, tc.text, tc.rawHTML); got != tc.want {
|
||||
t.Errorf("%s: looksDynamic = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,12 @@
|
||||
// configuration (the model only supplies the query), hence their hosts are trusted
|
||||
// by this tool alone: a loopback fallback endpoint stays reachable without widening
|
||||
// the shared http/web-fetch allowlist. API keys never reach logs, results or prompts.
|
||||
// - web-fetch: size- and content-type-limited readable text extraction.
|
||||
// - web-fetch: size- and content-type-limited readable text extraction. 取正文走
|
||||
// **降级链**(design/28 §1 M5 / §8 B5):直连 fetch →(失败 / 判定为动态页)
|
||||
// ⇒ 浏览器渲染取正文,返回体用 `fetchedBy`(direct / browser)**如实标注实际
|
||||
// 走了哪条**,两条档的失败原文分别留在 directError / browserError 里 —— 绝不静默
|
||||
// 替换、绝不把失败说成成功。这一档**默认关**(WithBrowserFallback);被 SSRF
|
||||
// 判定拒绝的目标**不换路**(换条路访问同一目标是绕过判定,不是降级)。
|
||||
// - knowledge: RAG lookup over the runtime memory retriever; a clear message
|
||||
// when no knowledge base is configured.
|
||||
//
|
||||
|
||||
@@ -67,6 +67,10 @@ type tools struct {
|
||||
// searchMaxResults 是 web-search 自己的条数上限(0 = 沿用 maxResults)。
|
||||
// 与 WithMaxResults 分开,是因为后者会一起改到 knowledge 工具的条数。
|
||||
searchMaxResults int
|
||||
|
||||
// bodyRenderer 是「浏览器渲染取正文」这一档(design/28 §1 M5 / §8 B5)。
|
||||
// nil = **这一档关着**(默认),见 WithBrowserFallback。
|
||||
bodyRenderer BodyRenderer
|
||||
}
|
||||
|
||||
// SearchProviderSpec 是组合根给 web-search 的**一家** provider 的接线(一家一行)。
|
||||
@@ -177,6 +181,24 @@ func WithSearchMaxResults(n int) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithBrowserFallback 打开 design/28 §1 M5 / §8 B5 的降级档:web-fetch **直连取正文**
|
||||
// 失败、或直连拿到的页面被判成动态页时,改由**浏览器渲染**取正文,并在返回体里如实
|
||||
// 标注实际走了哪条(`fetchedBy` = direct / browser)。
|
||||
//
|
||||
// **默认关**:不传这个选项就是关,传 nil 也是关。两条理由 ——
|
||||
//
|
||||
// 1. M5 是**降级档**不是主路径:浏览器渲染要起会话(必要时起进程),比直连慢一个量级;
|
||||
// 绝大多数站点直连足够。它是「直连不行时的补一手」,不该挂在默认路径上。
|
||||
// 2. 浏览器是否可用取决于宿主(本机有没有 Chrome/Chromium、有没有配 CDP 端点),
|
||||
// 拿不到就不该把一条注定走不通的档挂上去 —— 与 web-search「拿不到后端就不注册」
|
||||
// 同一个诚实口径。
|
||||
//
|
||||
// 传入的 renderer 必须自己满足 BodyRenderer 的红线(只出站、复用同一份 SSRF 判定源、
|
||||
// 渲染完成后才取正文)。这一档无论开关,**都不会**把被 SSRF 拒绝的目标换条路再去访问。
|
||||
func WithBrowserFallback(r BodyRenderer) Option {
|
||||
return func(t *tools) { t.bodyRenderer = r }
|
||||
}
|
||||
|
||||
// WithKnowledgeRetriever wires the knowledge tool to a runtime memory retriever.
|
||||
// WithKnowledgeRetriever(nil) makes knowledge report "no knowledge base".
|
||||
func WithKnowledgeRetriever(r KnowledgeRetriever) Option {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -21,6 +20,9 @@ type webFetchTool struct {
|
||||
allowedHosts []string
|
||||
maxBytes int64
|
||||
log *slog.Logger
|
||||
// renderer 是「浏览器渲染取正文」这一档(design/28 §1 M5 / §8 B5)。
|
||||
// nil = 关着(默认,见 WithBrowserFallback)。
|
||||
renderer BodyRenderer
|
||||
}
|
||||
|
||||
func newWebFetchTool(t *tools) *webFetchTool {
|
||||
@@ -29,6 +31,7 @@ func newWebFetchTool(t *tools) *webFetchTool {
|
||||
allowedHosts: t.allowedHosts,
|
||||
maxBytes: t.maxFetchBytes,
|
||||
log: t.log,
|
||||
renderer: t.bodyRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +56,10 @@ func (w *webFetchTool) Parameters() (map[string]any, error) {
|
||||
}
|
||||
|
||||
// Execute implements sdk.Tool.
|
||||
//
|
||||
// 取正文走**降级链**(design/28 §1 M5 / §8 B5):直连 fetch →(失败 / 判定为动态页)
|
||||
// ⇒ 浏览器渲染。结果里的 `fetchedBy` 是**如实标注**(direct / browser);两条档的
|
||||
// 失败原文各自留在 directError / browserError 里,绝不静默替换、绝不掩盖。
|
||||
func (w *webFetchTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
@@ -62,48 +69,35 @@ func (w *webFetchTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return nil, fmt.Errorf("io: web-fetch: url is required")
|
||||
}
|
||||
if _, err := validateRequestURL(ctx, rawURL, w.allowedHosts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w.client == nil {
|
||||
return nil, fmt.Errorf("io: web-fetch: no underlying client")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: web-fetch: build request: %w", err)
|
||||
}
|
||||
resp, err := w.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: web-fetch: do: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("io: web-fetch: url returned status %d", resp.StatusCode)
|
||||
}
|
||||
mt := "text/plain"
|
||||
if rawCT := resp.Header.Get("Content-Type"); rawCT != "" {
|
||||
var err error
|
||||
mt, _, err = mime.ParseMediaType(rawCT)
|
||||
if err != nil || !isTextContentType(mt) {
|
||||
return nil, fmt.Errorf("io: web-fetch: unexpected content type %q", rawCT)
|
||||
}
|
||||
}
|
||||
max := w.maxBytes
|
||||
if v, ok := argInt(m, "max_bytes"); ok && v > 0 {
|
||||
max = int64(v)
|
||||
}
|
||||
data, truncated, err := readLimited(resp.Body, max)
|
||||
body, err := w.fetchBody(ctx, rawURL, max)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: web-fetch: read: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
text := string(data)
|
||||
if isHTML(mt) {
|
||||
text = htmlToText(text)
|
||||
}
|
||||
return map[string]any{
|
||||
out := map[string]any{
|
||||
"url": rawURL,
|
||||
"content_type": mt,
|
||||
"text": text,
|
||||
"truncated": truncated,
|
||||
}, nil
|
||||
"content_type": body.contentType,
|
||||
"text": body.text,
|
||||
"truncated": body.truncated,
|
||||
// fetchedBy:正文**实际**来自哪条路。它是 M5 的硬要求(不许静默替换)——
|
||||
// 见到 browser 就意味着直连失败过、或被判成了动态页。
|
||||
"fetchedBy": body.fetchedBy,
|
||||
}
|
||||
// 以下字段只在**确有其事**时出现(有内容的失败/来源才写,不塞空串)。
|
||||
if body.directError != "" {
|
||||
out["directError"] = body.directError
|
||||
}
|
||||
if body.browserSource != "" {
|
||||
out["browserSource"] = body.browserSource
|
||||
}
|
||||
if body.browserError != "" {
|
||||
out["browserError"] = body.browserError
|
||||
}
|
||||
if body.dynamicSuspect {
|
||||
out["dynamicSuspect"] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in new issue
Block a user