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 排期、状态过期等),已登记待我同步文档。
84 lines
4.1 KiB
Go
84 lines
4.1 KiB
Go
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
|
||
}
|