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 }