package main // 本文件是 design/28 §1 M5 / §8 B5「浏览器渲染取正文」降级档的**组合根挂载点** —— // 切片 Z5 把降级链做好了、但生产接不通(能力面没有取正文入口 ⇒ 没东西可注入 ⇒ // 这一档实际关着),本文件就是那次收口留下的缺口的解封。 // // 六条口径: // // 1. **挂载条件 = 能力面里确实有可用浏览器**(注册面里那把 browser 工具的驱动 // `Available() == true`)。这条判据来自 design/28 §7 判据 1 的诚实口径 // 「都没有 ⇒ 工具不注册(不假装可用)」:拿不到来源就不该把一条注定走不通的档挂上。 // 2. **不新增开关**。设计**没有**为这条降级档定义配置项(§8 的配置面清单是「端点、 // 有头/无头、视口、超时、并发、截图上限」,§8 末的配置面约束又写死「`.env` 已冻结、 // 可调项只落配置文件 + 面板」)⇒ 自造一个 `bodyFallback: bool` 字段属于 AGENTS §1 的 // 「自行发挥」。挂载条件因此由能力面自身给出,而不是第二处开关。 // 3. **io 包的「默认关」口径没有被改写**:`iotools.WithBrowserFallback` 不传 / 传 nil // 仍然是关(`plugins/tools/io` 的库默认)。本文件只把「确实有来源」的那一种情形 // 打开 —— 从「永远关」变成「没来源就关」,这正是 design/28 §2 把 M5 列进推荐组合、 // §8 把 B5 列为实施项的意思。 // 4. **每次调用现取管理器**:理由见 browserBodyRenderer 的文档(面板保存会换掉那一把 // 工具与它背后的管理器)。 // 5. **只读装配**:本文件不发请求、不起进程、不注册工具 —— `ioToolOptions()` 是只读 // 装配,探测的副作用归 `wireBrowser()`(`wire_browser_test.go` 钉住不许在这里探测)。 // 6. **失败如实**:挂不上只记一条**带来源**的 WARN(原因码 + 这一档对模型面的实际含义), // 不阻断启动 —— 浏览器是可选增强(design/28 §8 逐片落地状态)。 import ( "context" "errors" "fmt" "log/slog" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" ) // 挂载点的文案常量(进日志与结果,不美化)。 const ( // browserFallbackReasonNoSource 是「这一档没挂上」的原因码:能力面里没有可用浏览器。 // 分类原因码而不是「失败」两个字,照 web-search「拿不到后端就不注册」的口径。 browserFallbackReasonNoSource = "no-usable-browser" // browserFallbackTrigger 写清这一档在什么条件下被走(进启动日志,免得「它到底管什么」 // 只能靠读代码)。 browserFallbackTrigger = "direct fetch failed or the page was judged dynamic" // browserFallbackOffEffect 写清「没挂」对模型面的**实际**含义:不是说结果会假, // 而是说这一档没有第二次机会(直连的真实遭遇仍然照实报出来)。 browserFallbackOffEffect = "web-fetch keeps the direct path only; the real outcome is still reported (dynamicSuspect / directError), there is just no browser second look" ) // bodyFallbackOption 是 design/28 §8 B5 的**挂载点**,落地本文件顶部的六条口径。 // // 两种去处都只有一个出口,口径不会在分支里漂移: // - 没有可用浏览器 ⇒ 不挂(`WithBrowserFallback(nil)`)+ 一条带来源的 WARN,说明原因; // - 有可用浏览器 ⇒ 挂上适配器,并记一条 INFO 说明这一档什么时候会被走。 func (a *app) bodyFallbackOption() iotools.Option { log := logger.WithSource(a.log, ioLogSource) mgr := browserManagerOf(a.caps) if mgr == nil || !mgr.Available() { log.Warn("web-fetch browser fallback not wired", "reason", browserFallbackReasonNoSource, "effect", browserFallbackOffEffect) return iotools.WithBrowserFallback(nil) } log.Info("web-fetch browser fallback wired", "trigger", browserFallbackTrigger) return iotools.WithBrowserFallback(&browserBodyRenderer{caps: a.caps, log: log}) } // browserFallbackReasonBudget 是「这一档**没有发起**」的原因码:调用方这一轮的预算已经 // 耗尽。它与 `no-usable-browser` 是两件事(一个是「没时间了」,一个是「没有浏览器」), // 混成一句会把人带偏(真机就出现过:窗口上是浏览器的抱怨,日志里像浏览器坏了)。 const browserFallbackReasonBudget = "budget-exhausted" // browserFallbackBudgetNote 写清这一刻的**实际**含义:浏览器没坏、这一档没被尝试。 // 它进错误文本(顺 web-fetch 的 browserError 面出来),因此必须一眼可读、不含猜测。 const browserFallbackBudgetNote = "本轮工具预算已耗尽(调用方上下文已结束)⇒ 未尝试浏览器渲染,这不是浏览器故障" // browserBodyRenderer 把浏览器能力面(`*browsercap.Manager.RenderText`)适配成 io 包的 // `iotools.BodyRenderer` —— 这是「进程内接口 + 依赖注入」的适配器,不引入第二个浏览器栈、 // 不新开任何监听端口(AGENTS §9)。 // // **为什么每次调用现取管理器**:面板保存 browser 配置会重建那把工具并换掉它背后的管理器 // (`wire_browser_panel.go` 的热重载 + `ReplaceTool`),装配时抓住的旧指针会在一次保存之后 // 变成一具尸体 —— 那条降级档会静默退化成「每次都如实失败」,而这是最难查的一类失效。 // 从**注册面**取当前那一把,拿到的就是正在跑的那一个。 // // 红线(与 `iotools.BodyRenderer` 的注释同源):只做对外连接;SSRF 判定由浏览器侧用 // **同一份** `io.ValidateRequestURL` 自己再过一遍(本适配器不替它免检,原始 URL 原样交过去); // 正文在渲染完成后才取(由 `Manager.RenderText` 保证)。 // // 空正文**不在这里**判:`RenderText` 的「渲染成功但正文为空」是读数不是错误,而 io 的 // 降级档有自己的口径(`ErrEmptyRenderedBody`)—— 本适配器原样交出去,不替上层下结论。 type browserBodyRenderer struct { caps *registry.Registry // log 是挂载点的日志(已带来源)。可为 nil(单测直接构造):那就不记,不做替代语义。 log *slog.Logger } // RenderBody 实现 iotools.BodyRenderer。 // // **预算口径**(design/28 §1 M5「降级档」+ §7 判据 4「如实标注」+ §6 坑 5「失败要如实报」, // 设计没有为这一档规定独立预算 ⇒ 取最小的诚实口径):ctx 已经结束(这一轮的工具预算耗尽 / // 会话被取消)时**不发起**,如实报「预算耗尽 ⇒ 未尝试」,而**不是**报一个看起来像 // 「浏览器坏了」的错误。三条理由: // // - 这一档要冷启动真浏览器(秒级),启动所需的预算此刻已经不存在; // - 工具派发层在 ctx 结束时就要把结果丢掉(`internal/runtime/tool` 的 callTool 在 // ctx.Done 与结果之间二选一,且 ctx 结束时返回 ctx.Err())⇒ 硬起一个浏览器只会留下 // 一个被立刻杀掉的窗口,**结果没人读**(真机踩过:窗口上跳出浏览器的抱怨); // - 没试过就不能报成「试了失败」:`fetchedBy` / `browserError` 的语义(见 // `plugins/tools/io/bodyfetch.go`)一字不动,本适配器只交「没发起」这个事实。 // // ctx 还活着时**不动**它的预算(不另造一个第二预算):浏览器会话可能是热的(同一次 // 会话里刚渲染过),渲染只要一两秒;真有取消,浏览器侧会把它如实分类成「预算耗尽」 // (见 `internal/capability/browser` 的分类标记),不会再冒充「浏览器坏了」。 func (r *browserBodyRenderer) RenderBody(ctx context.Context, rawURL string) (iotools.RenderedBody, error) { mgr := browserManagerOf(r.caps) if mgr == nil || !mgr.Available() { // 诚实降级:此时是「这一档没有来源」,**不是**「取到了空正文」。 return iotools.RenderedBody{}, errors.New("web-fetch browser fallback: no usable browser available") } if err := ctx.Err(); err != nil { if r.log != nil { r.log.Info("web-fetch browser fallback not attempted", "reason", browserFallbackReasonBudget, "error", err) } return iotools.RenderedBody{}, fmt.Errorf("web-fetch browser fallback: %s: %s: %w", browserFallbackReasonBudget, browserFallbackBudgetNote, err) } rendered, err := mgr.RenderText(ctx, rawURL) if err != nil { return iotools.RenderedBody{}, fmt.Errorf("browser render: %w", err) } return iotools.RenderedBody{Text: rendered.Text, Source: rendered.Source}, nil } // browserManagerOf 从能力面取回**当前**的浏览器管理器;没有可用来源(没注册 / 注册的 // 不是这把工具 / 驱动不是本机的管理器,例如单测夹具)时返回 nil。 // // 它**只读**注册面:不发请求、不起进程、不注册工具 —— 本文件被 `ioToolOptions()` 调用, // 那是一条只读装配路径(口径 5)。 func browserManagerOf(caps *registry.Registry) *browser.Manager { if caps == nil { return nil } entry, err := caps.Get(browsertools.ToolName) if err != nil { return nil } tool, ok := entry.Value.(*browsertools.Tool) if !ok { return nil } return tool.Manager() }