Files
Cyrene_v2/internal/capability/browser/render.go
T
AskaEth 378faf212e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 取正文能力 + web-fetch 浏览器降级档接线(解封 Z5/B5)
Z5(c2e413e)把 web-fetch 的降级链与注入点做完了,但「取正文」入口缺失 ⇒
组合根拿不到 BodyRenderer、这一档实际关着(当时如实登记)。本片补上:

- internal/capability/browser/render.go:新增 Manager.RenderText(ctx,url)
  (导出的进程内能力)—— 先过 GuardURL(与全仓同一份 SSRF 判定源)、复用
  withSession/session、等 readyState=complete 之后才取 body.innerText;
  渲染失败 = 分类错误,渲染成功但正文为空 = 空串+无错(不冒充成功/失败)。
- cmd/cyrene/wire_body_fallback.go:适配器 + 挂载点,把 Manager 适配成
  io.BodyRenderer 并用 WithBrowserFallback 挂上;web_runtime 里把浏览器
  接线**上移到 io 工具集之前**(顺序颠倒会静默关死这一档)。
- plugins/tools/browser/tool.go:新增 Tool.Manager(),供组合根取驱动句柄。

开关口径(裁决,依据 design/28 §1 M5 / §7 判据 4 / §8 B5):**有可用浏览器
就挂,没有就不挂**,不新增开关 ——
① 设计把 M5/B5 写成要落地生效的降级档,不是永远关着的空壳;
② 设计 §8 的可调项清单里没有这条降级档的开关,`.env` 已冻结 ⇒ 自造开关属
   「自行发挥」;
③ io 库层「不传 = 默认关」的语义一字未改,本片只把组合根从「永远关」改成
   「没来源就关」,并留带来源的 WARN(reason=no-usable-browser)。
⚠️ 若日后要「有浏览器也需显式开启」,得先给设计定开关落点,代码侧一行改回。

验收(主会话复跑 + 亲自变异):go build ./... + 三包 vet 绿;browser/io 两包
测试绿(cmd/cyrene 由子代理跑过全绿);我用 -overlay 把 GuardURL 的判定对象
换成常量 URL(绕过被拒目标)⇒ TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess
变红,证明判定确实**在起任何进程之前**跑。

⚠️ 生产端到端未验:按红线没重启后端、没起真 Chrome ⇒「真页面 → browser 取到
正文」这条链只有假 CDP + 组合根接线级证据。
⚠️ design/28 有 6 处表述与代码现状冲突(归属 web-fetch vs web-search、extract
动作归属、B5 排期、状态过期等),已登记待我同步文档。
2026-10-07 09:59:56 +08:00

84 lines
4.1 KiB
Go
Raw 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 browser
import (
"context"
"unicode/utf8"
)
// bodyTextExpr 取**渲染完成后**页面的可见正文:`document.body.innerText`。
//
// 为什么不是 `documentElement.innerHTML` / `outerHTML`(design/28 §6 坑 4):那是
// **源码**不是正文 —— 动态页的源码里只有骨架与脚本,模型拿到等于没拿到。innerText
// 是浏览器按布局算出来的可见文本(display:none 的内容不算),这才是「渲染后」的读数。
//
// body 可能为 null(about:blank / 空白文档):那种情况如实交空串,由**上层**判空,
// 本层不把它当错误(见 RenderText 的口径)。
const bodyTextExpr = `(() => { const b = document.body; return b ? b.innerText : ""; })()`
// RenderedText 是一次「渲染取正文」的结论。
type RenderedText struct {
// Text 是渲染后页面的可见正文(纯文本,已去标签)。
// **空串是合法读数**:它表示「渲染走通了,但这个页面确实没有可见正文」,不是失败
// —— 是不是成功由上层按它的口径判(io 的降级档把空正文按 `ErrEmptyRenderedBody` 处理)。
Text string `json:"text"`
// Source 是会话来源描述(本机有头 / 已连的 CDP 端点),如实标注、不含凭据。
Source string `json:"source"`
// URL / Title 是页面**自报**的最终地址与标题(可能因重定向与请求的不同)。
URL string `json:"url"`
Title string `json:"title"`
}
// RenderText 导航到 rawURL,等页面就绪后取回可见正文(design/28 §1 M5 / §8 B5 的
// 「浏览器渲染取正文」能力面;消费方是 web-fetch 的降级档,见 plugins/tools/io)。
//
// 三条口径,与 io.BodyRenderer 的红线一一对应:
//
// - **过同一份 SSRF 判定**:走既有的 `GuardURL`(它就是 `io.ValidateRequestURL` 的
// 门面,design/28 §4-1:判定源只有一份),与本包 Open 同一条闸;
// - **不新造第二套 CDP 客户端**:与 Open / Screenshot / Fingerprint 同一条路线
// (`withSession` → `session` → 隔离世界求值,不发 `Runtime.enable`);
// - **渲染完成后才取正文**:等待条件的显式落点是 `session.navigate` → `waitReady`
// (轮询 `document.readyState` 直到 `complete`,见 session.go);就绪之后才求
// `body.innerText`。
//
// 「渲染失败」与「渲染成功但正文为空」在返回值上是**两件事**:前者是 error
// (`ErrBlockedURL` / `ErrUnavailable` / `ErrNavigate` / `ErrEvaluate` / `ErrCDP` 分类),
// 后者是 `Text == ""` 且 `err == nil`。既不把空正文当失败,也不拿空文本冒充成功。
func (m *Manager) RenderText(ctx context.Context, rawURL string) (RenderedText, error) {
if _, err := GuardURL(ctx, rawURL, m.allowed); err != nil {
m.log.Warn("browser text render blocked by URL guard", "reason", ReasonBlockedURL, "error", err)
return RenderedText{}, err
}
out := RenderedText{Source: m.Source()}
err := m.withSession(ctx, func(s *session) error {
state, err := s.navigate(ctx, rawURL)
if err != nil {
return err
}
text, err := s.bodyText(ctx)
if err != nil {
return err
}
out.URL, out.Title, out.Text = state.URL, state.Title, text
return nil
})
if err != nil {
return RenderedText{}, err
}
// 只记来源与规模,**不记 URL / 标题 / 正文**:页面内容可能含凭据,日志不该成为
// 它的第二个副本(AGENTS §10)。没有字符数是「渲染成功但正文为空」唯一的排查线索,
// 所以留着它(0 = 空正文,不是错误)。
m.log.Debug("browser text rendered", "source", out.Source, "chars", utf8.RuneCountInString(out.Text))
return out, nil
}
// bodyText 在**隔离世界**里取一次 `document.body.innerText`(见 bodyTextExpr)。
// 它是 session 的一次普通求值 —— 复用既有的上下文与按值取回路径,不另开会话。
func (s *session) bodyText(ctx context.Context) (string, error) {
var text string
if err := s.evaluate(ctx, bodyTextExpr, &text); err != nil {
return "", err
}
return text, nil
}