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 排期、状态过期等),已登记待我同步文档。
This commit is contained in:
1 parent
25aa50e532
commit
378faf212e
7 files changed
+685
-31
No files matched your search
@@ -0,0 +1,121 @@
|
||||
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"
|
||||
|
||||
"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})
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
// RenderBody 实现 iotools.BodyRenderer。
|
||||
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")
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住切片 Z5 解封的那条接线(design/28 §1 M5 / §8 B5):把浏览器能力面的
|
||||
// 「渲染取正文」适配成 web-fetch 的降级档,并在组合根决定**挂不挂**。
|
||||
//
|
||||
// 三件事必须有测试拦(它们删掉之后运行期都没有症状):
|
||||
//
|
||||
// 1. 能力面里**没有**可用浏览器 ⇒ 这一档**不挂**,且如实留一条带来源的原因;
|
||||
// 2. 能力面里**有**可用浏览器 ⇒ 这一档真的被挂上、被调用(判据是浏览器那一档的
|
||||
// 真实遭遇出现在返回体里,而不是「某个函数被调过」);
|
||||
// 3. 适配器在「取正文时已经没有可用浏览器」时如实报错,**不返回空串冒充成功**。
|
||||
//
|
||||
// 另外两条是接线形状的钉子(照 `TestWireRuntime_CallsWireBrowser` 的先例):挂载点不能
|
||||
// 从生产组装里消失,装配顺序不能把浏览器排在 io 工具集之后。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// webFetchToolName 是 web-fetch 在注册面里的名字(io 包里的字面量,这里按同一份契约引用)。
|
||||
const webFetchToolName = "web-fetch"
|
||||
|
||||
// serveSkeletonPage 起一个「直连能成功、但只有前端框架挂载点」的页面:它是这条降级档的
|
||||
// 入口条件之一(直连成功却 `dynamicSuspect`)。
|
||||
func serveSkeletonPage(t *testing.T) *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")
|
||||
_, _ = w.Write([]byte(`<html><body><div id="app"></div><script src="/x.js"></script></body></html>`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// endpointHost 是白名单条目的写法:host:port(判定源会剥端口比对)。
|
||||
func endpointHost(srv *httptest.Server) string { return strings.TrimPrefix(srv.URL, "http://") }
|
||||
|
||||
// fallbackFetchTool 造一把**走挂载点**的 web-fetch:选项里带 `bodyFallbackOption()`,
|
||||
// 测试服务放进 SSRF 白名单(否则直连那一档先被守卫拒掉,降级档根本不会被触发)。
|
||||
//
|
||||
// 选项走**生产路径**:`a.ioToolOptions()`(挂载点就在里面),而不是只把 `bodyFallbackOption()`
|
||||
// 单独拿出来 —— 这样两个用例证明的就是「启动期真正交出去的那份选项」。
|
||||
func fallbackFetchTool(t *testing.T, a *app, srv *httptest.Server) sdk.Tool {
|
||||
t.Helper()
|
||||
opts := append(a.ioToolOptions(),
|
||||
iotools.WithAllowedHosts(endpointHost(srv)), iotools.WithHTTPClient(srv.Client()))
|
||||
set, err := iotools.New(a.cfg, opts...)
|
||||
if err != nil {
|
||||
t.Fatalf("iotools.New: %v", err)
|
||||
}
|
||||
for _, tool := range set {
|
||||
if tool.Name() == webFetchToolName {
|
||||
return tool
|
||||
}
|
||||
}
|
||||
t.Fatalf("工具集里没有 %s", webFetchToolName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// execFetch 跑一次 web-fetch 并把它返回的 map 取出来。
|
||||
func execFetch(t *testing.T, tool sdk.Tool, rawURL string) map[string]any {
|
||||
t.Helper()
|
||||
got, err := tool.Execute(context.Background(), map[string]any{"url": rawURL})
|
||||
if err != nil {
|
||||
t.Fatalf("web-fetch Execute: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("web-fetch 返回的不是对象: %#v", got)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// newWiredBrowserManager 造一个**报告可用**的浏览器管理器:探测结论说「本机有 Chrome」,
|
||||
// 但可执行文件故意不存在 —— `Available()` 为 true(于是降级档会被挂上),而任何一次真实
|
||||
// 会话都会以 ErrLaunch 如实失败。
|
||||
func newWiredBrowserManager(t *testing.T, cfg *config.Config, allowedHost string) *browsercap.Manager {
|
||||
t.Helper()
|
||||
mgr := browsercap.NewManager(browsercap.Options{
|
||||
Availability: browsercfg.Availability{
|
||||
Usable: true, Source: browsercfg.SourceLocalChrome,
|
||||
Executable: "definitely-not-a-browser.exe", Detail: "假来源(本用例不真起浏览器)",
|
||||
},
|
||||
Config: browsercfg.Defaults(cfg.DataDir),
|
||||
DataDir: cfg.DataDir,
|
||||
AllowedHosts: []string{allowedHost},
|
||||
})
|
||||
t.Cleanup(func() { _ = mgr.Close() })
|
||||
return mgr
|
||||
}
|
||||
|
||||
// ① 没有可用浏览器 ⇒ 这一档不挂:直连的结论照实交出来,并如实说明「没有浏览器第二眼」;
|
||||
// 日志里留下分类原因码与来源标签。
|
||||
func TestBodyFallback_NoBrowserMeansTierNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out) // 注册面里没有 browser 工具 = 这台机器没有可用来源
|
||||
srv := serveSkeletonPage(t)
|
||||
|
||||
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
|
||||
|
||||
if m["fetchedBy"] != iotools.FetchedByDirect {
|
||||
t.Errorf("fetchedBy = %v, want %q(浏览器没取到,来源仍是直连)", m["fetchedBy"], iotools.FetchedByDirect)
|
||||
}
|
||||
if m["dynamicSuspect"] != true {
|
||||
t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"])
|
||||
}
|
||||
be, _ := m["browserError"].(string)
|
||||
if !strings.Contains(be, "browser fallback disabled") {
|
||||
t.Errorf("这一档没挂时必须如实说明(browserError = %q)", be)
|
||||
}
|
||||
if !strings.Contains(out.String(), "reason="+browserFallbackReasonNoSource) {
|
||||
t.Errorf("日志里必须留下分类原因码 reason=%s(实得:%s)", browserFallbackReasonNoSource, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "source=cmd/cyrene/wire_io_tools") {
|
||||
t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ② 有可用浏览器 ⇒ 这一档真的挂上并被调用:浏览器那一档的**真实遭遇**(这里是不存在的
|
||||
// 可执行文件 ⇒ ErrLaunch)出现在返回体里,而不是被吞掉。
|
||||
func TestBodyFallback_AvailableBrowserIsWiredAndHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
srv := serveSkeletonPage(t)
|
||||
if err := a.caps.RegisterTool(browsertools.New(newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
|
||||
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
|
||||
|
||||
be, _ := m["browserError"].(string)
|
||||
if !strings.Contains(be, "启动本机浏览器失败") {
|
||||
t.Errorf("浏览器这一档被挂上后就该被真调用,并把失败如实写进结果;browserError = %q", be)
|
||||
}
|
||||
if m["fetchedBy"] != iotools.FetchedByDirect {
|
||||
t.Errorf("fetchedBy = %v, want %q(浏览器没取到正文)", m["fetchedBy"], iotools.FetchedByDirect)
|
||||
}
|
||||
if !strings.Contains(out.String(), "web-fetch browser fallback wired") {
|
||||
t.Errorf("挂上时应有带来源的 INFO:%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 适配器在「取正文时已经没有可用浏览器」时如实报错,不返回空串冒充成功。
|
||||
func TestBrowserBodyRenderer_NoUsableBrowserIsNotSilentSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
withUnavailable := registry.New()
|
||||
if err := withUnavailable.RegisterTool(browsertools.New(
|
||||
newUnavailableBrowserManager(t, cfg))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
caps *registry.Registry
|
||||
}{
|
||||
{"注册面为 nil", nil},
|
||||
{"注册面里没有 browser", registry.New()},
|
||||
{"工具在但驱动不可用", withUnavailable},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := &browserBodyRenderer{caps: tc.caps}
|
||||
got, err := r.RenderBody(context.Background(), "https://example.com/")
|
||||
if err == nil {
|
||||
t.Fatalf("没有可用浏览器时必须报错(实得 %+v)", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no usable browser") {
|
||||
t.Errorf("err = %v,应说清是「这一档没有来源」", err)
|
||||
}
|
||||
if got.Text != "" || got.Source != "" {
|
||||
t.Errorf("失败时不该交出半个结论: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newUnavailableBrowserManager 造一个「探测不到来源」的管理器(Available() == false)。
|
||||
func newUnavailableBrowserManager(t *testing.T, cfg *config.Config) *browsercap.Manager {
|
||||
t.Helper()
|
||||
mgr := browsercap.NewManager(browsercap.Options{
|
||||
Availability: browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"},
|
||||
Config: browsercfg.Defaults(cfg.DataDir),
|
||||
DataDir: cfg.DataDir,
|
||||
})
|
||||
t.Cleanup(func() { _ = mgr.Close() })
|
||||
return mgr
|
||||
}
|
||||
|
||||
// ④ 适配器跟的是**注册面里的当前那一把**,不是装配时的那一个:面板保存 browser 配置会
|
||||
// 用 `ReplaceTool` 换掉工具与它背后的管理器,抓着旧指针会让这条降级档在一次保存之后变成
|
||||
// 一具尸体(而且只在「取正文时」才暴露)。
|
||||
func TestBrowserBodyRenderer_FollowsRegistryReplacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
if err := a.caps.RegisterTool(browsertools.New(
|
||||
newWiredBrowserManager(t, cfg, "example.com"))); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
r := &browserBodyRenderer{caps: a.caps}
|
||||
|
||||
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
|
||||
strings.Contains(err.Error(), "no usable browser") {
|
||||
t.Fatalf("替换前应当用**可用的**那一把(真去渲染,因假 exe 而失败);实得 %v", err)
|
||||
}
|
||||
// 面板保存 → 换掉注册面里的那一把(新的管理器不可用)。
|
||||
if err := a.caps.ReplaceTool(browsertools.New(newUnavailableBrowserManager(t, cfg))); err != nil {
|
||||
t.Fatalf("替换 browser 工具: %v", err)
|
||||
}
|
||||
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
|
||||
!strings.Contains(err.Error(), "no usable browser") {
|
||||
t.Fatalf("替换后适配器必须改口成「没有可用来源」,实得 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 挂载点不能从生产组装里消失:`ioToolOptions()` 是启动期唯一把降级档交给 io 包的地方,
|
||||
// 它被删掉之后编译照过、启动照起,只有模型那边少一次「浏览器第二眼」。
|
||||
func TestIOToolOptions_IncludesBodyFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSource(t, "wire_io_tools.go")
|
||||
if !strings.Contains(src, "\t\ta.bodyFallbackOption(),") {
|
||||
t.Fatal("wire_io_tools.go 里找不到 `a.bodyFallbackOption()` —— 浏览器降级档不会被挂上" +
|
||||
"(J5 在 wire_body_fallback.go,挂载点必须留在生产组装里)")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 装配顺序:浏览器必须在 io 工具集**之前**装配。降级档的挂载条件读的就是注册面,
|
||||
// 反过来写会让它永远读到「没有浏览器」⇒ 静默关死(没有任何运行期症状)。
|
||||
func TestWireRuntime_BrowserWiredBeforeIoTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSource(t, "wire_runtime.go")
|
||||
browse := strings.Index(src, "\ta.wireBrowser()")
|
||||
tools := strings.Index(src, "ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...")
|
||||
if browse < 0 || tools < 0 {
|
||||
t.Fatalf("wire_runtime.go 里找不到装配点(browser=%d, ioTools=%d)", browse, tools)
|
||||
}
|
||||
if browse > tools {
|
||||
t.Fatal("wire_runtime.go 把浏览器装配排在了 io 工具集之后 —— web-fetch 的浏览器降级档" +
|
||||
"会静默关死(挂载条件读的是注册面)")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ package main
|
||||
|
||||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
|
||||
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
|
||||
// 以及 design/28 §8 B5 的**浏览器降级档开关**(`bodyFallbackOption`,当前 = 关)。
|
||||
// 以及 design/28 §8 B5 的**浏览器降级档挂载**(`bodyFallbackOption()`,实现与口径在
|
||||
// `wire_body_fallback.go` —— 挂载条件 = 能力面里确实有可用浏览器,详见那个文件)。
|
||||
//
|
||||
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
||||
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||||
@@ -91,34 +92,14 @@ func (a *app) ioToolOptions() []iotools.Option {
|
||||
opts := []iotools.Option{
|
||||
iotools.WithKnowledgeRetriever(a.retriever),
|
||||
iotools.WithLogger(a.log),
|
||||
// B5 的降级档开关(当前 = **关**,理由见 bodyFallbackOption 的文档)。
|
||||
// B5 的挂载点:有可用浏览器就挂上浏览器渲染取正文,没有就不挂(口径与实现
|
||||
// 都在 wire_body_fallback.go)。挂载条件必须在**浏览器装配之后**求值 ——
|
||||
// 这就是 wireRuntime 里 `a.wireBrowser()` 排在 io 工具集之前的原因。
|
||||
a.bodyFallbackOption(),
|
||||
}
|
||||
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
|
||||
}
|
||||
|
||||
// bodyFallbackOption 是 design/28 §8 B5 的**开关点**:web-fetch 取正文时,直连失败 /
|
||||
// 判定为动态页要不要改走**浏览器渲染**(`iotools.WithBrowserFallback`,默认关)。
|
||||
//
|
||||
// **当前结论 = 关**(本函数返回的就是「不注入渲染器」)。理由在能力面上,不在这个开关上:
|
||||
// `internal/capability/browser` 目前只导出 `Open`(导航,回报 url/title/readyState)、
|
||||
// `Screenshot`、`Fingerprint` —— **没有**任何「取页面正文」的入口,`plugins/tools/browser`
|
||||
// 的工具动作也只有 `open`/`screenshot`/`info`。没有渲染器可注入,这一档打开也只可能
|
||||
// 如实失败 —— 那不是「降级档」,是给每次取正文加一条注定失败的噪声。
|
||||
//
|
||||
// 打开它要等能力面先补一个正文抽取入口(形如
|
||||
// `Manager.RenderText(ctx, url) (RenderedText, error)`,内部走既有的隔离世界求值 +
|
||||
// 同一份 `io.ValidateRequestURL` 判定源),届时把这里换成注入该适配器即可(一行)。
|
||||
// 在此之前**不假装可用** —— 与 web-search「拿不到后端就不注册」同一口径
|
||||
// (design/28 §7 判据 1、§7 判据 4 的诚实标注)。
|
||||
//
|
||||
// 为什么不在这里现场探测浏览器:`ioToolOptions()` 是**只读装配**,探测的副作用
|
||||
// (起进程 / 连 CDP 端点)不属于「取一份 io 选项」这件事 —— 口径见 `wire_browser.go`
|
||||
// 顶部第 3 条,且 `wire_browser_test.go` 钉住它不许在这里探测。
|
||||
func (a *app) bodyFallbackOption() iotools.Option {
|
||||
return iotools.WithBrowserFallback(nil)
|
||||
}
|
||||
|
||||
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
|
||||
// 重建工具,见 websearch_reload.go)。
|
||||
//
|
||||
|
||||
@@ -164,8 +164,19 @@ func (a *app) wireRuntime() error {
|
||||
if err := basic.Register(a.caps); err != nil {
|
||||
return fmt.Errorf("register basic tools: %w", err)
|
||||
}
|
||||
// io 工具集的选项(含 web-search 的端点与超时裁决)在 wire_io_tools.go 一处组装:
|
||||
// 端点不可用时它交出 WithoutSearch(),使 web-search **不进注册面**(WS1-A)。
|
||||
// 浏览器工具(design/28 B1 接线,切片 V5;挂点由 X3 归位到这里):
|
||||
// 与 vision / transcode 同族 —— 可选增强,不可用就**不注册**(模型看不见这个
|
||||
// 工具,而不是看得见、一调就报错),失败不阻塞启动;装配细节与六条口径都在
|
||||
// wire_browser.go 一处。它的关闭挂点在 a.close(wire_services.go),成对。
|
||||
//
|
||||
// ⚠️ 它必须排在 io 工具集**之前**(切片 Z5 的解封):io 的 web-fetch 降级档
|
||||
// (design/28 §1 M5 / §8 B5)按「能力面里到底有没有可用浏览器」决定挂不挂,
|
||||
// 而那条判据就是在这里被写进注册面的(`bodyFallbackOption()`,见
|
||||
// wire_body_fallback.go)。装配顺序即这条判定的输入,颠倒过来降级档会静默关死。
|
||||
a.wireBrowser()
|
||||
// io 工具集的选项(含 web-search 的端点与超时裁决、web-fetch 的浏览器降级档)
|
||||
// 在 wire_io_tools.go 一处组装:端点不可用时它交出 WithoutSearch(),使 web-search
|
||||
// **不进注册面**(WS1-A)。
|
||||
ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...)
|
||||
if ioErr != nil {
|
||||
return fmt.Errorf("build io tools: %w", ioErr)
|
||||
@@ -177,11 +188,6 @@ func (a *app) wireRuntime() error {
|
||||
}
|
||||
a.wireVision()
|
||||
a.wireTranscode()
|
||||
a.wireBrowser()
|
||||
// 浏览器工具(design/28 B1 接线,切片 V5;挂点由 X3 归位到这里):
|
||||
// 与 vision / transcode 同族 —— 可选增强,不可用就**不注册**(模型看不见这个
|
||||
// 工具,而不是看得见、一调就报错),失败不阻塞启动;装配细节与六条口径都在
|
||||
// wire_browser.go 一处。它的关闭挂点在 a.close(wire_services.go),成对。
|
||||
|
||||
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
|
||||
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
)
|
||||
|
||||
// renderTestManager 造一个「导航目标已过白名单」的管理器:白名单里有 example.com,
|
||||
// 于是 RenderText 的 SSRF 判定走白名单短路(不依赖外网 DNS,离线可跑)。
|
||||
func renderTestManager(t *testing.T, avail browsercfg.Availability) *Manager {
|
||||
t.Helper()
|
||||
m := NewManager(Options{
|
||||
Availability: avail,
|
||||
Config: browsercfg.Defaults(t.TempDir()),
|
||||
DataDir: t.TempDir(),
|
||||
AllowedHosts: []string{"example.com"},
|
||||
})
|
||||
t.Cleanup(func() { _ = m.Close() })
|
||||
return m
|
||||
}
|
||||
|
||||
// okBrowserCDP 是「导航得通、正文取得回」的假 CDP:它按方法名应答,并把页面自报的
|
||||
// state(url/title/readyState)与正文 text 分别交给 pageState 与 innerText 两次求值。
|
||||
// 两个求值靠表达式区分(一个读 readyState,一个读 body.innerText)。
|
||||
func okBrowserCDP(t *testing.T, text string) *fakeCDP {
|
||||
t.Helper()
|
||||
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
|
||||
switch method {
|
||||
case "Page.enable", "Page.navigate":
|
||||
return map[string]any{}, nil
|
||||
case "Page.getFrameTree":
|
||||
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
|
||||
case "Page.createIsolatedWorld":
|
||||
return map[string]any{"executionContextId": 1}, nil
|
||||
case "Runtime.evaluate":
|
||||
expr, _ := params["expression"].(string)
|
||||
if strings.Contains(expr, "readyState") {
|
||||
return map[string]any{"result": map[string]any{"value": map[string]any{
|
||||
"url": "https://example.com/final", "title": "示例页", "readyState": "complete",
|
||||
}}}, nil
|
||||
}
|
||||
if !strings.Contains(expr, "innerText") {
|
||||
return nil, &cdpError{Code: -32601, Message: "unexpected expression " + expr}
|
||||
}
|
||||
return map[string]any{"result": map[string]any{"value": text}}, nil
|
||||
}
|
||||
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
|
||||
})
|
||||
}
|
||||
|
||||
// cdpEndpointAvail 把假 CDP 的地址包装成「已配 CDP 端点」的探测结论(走 remoteCDP,
|
||||
// 不起任何本机进程)。
|
||||
func cdpEndpointAvail(f *fakeCDP) browsercfg.Availability {
|
||||
return browsercfg.Availability{
|
||||
Usable: true, Source: browsercfg.SourceCDP, Endpoint: f.base, Detail: "假 CDP 端点 " + f.base,
|
||||
}
|
||||
}
|
||||
|
||||
// ① 渲染成功 ⇒ 取回渲染后的可见正文(innerText,不是 HTML 源码),并如实带出来源与
|
||||
// 页面自报的地址/标题。
|
||||
func TestManager_RenderTextReturnsVisibleText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := okBrowserCDP(t, "渲染后的正文:Hello 世界")
|
||||
m := renderTestManager(t, cdpEndpointAvail(f))
|
||||
|
||||
got, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderText: %v", err)
|
||||
}
|
||||
if got.Text != "渲染后的正文:Hello 世界" {
|
||||
t.Errorf("Text = %q", got.Text)
|
||||
}
|
||||
if got.URL != "https://example.com/final" || got.Title != "示例页" {
|
||||
t.Errorf("URL/Title 应取页面自报值,实得 %q / %q", got.URL, got.Title)
|
||||
}
|
||||
if !strings.Contains(got.Source, "假 CDP 端点") {
|
||||
t.Errorf("Source = %q,必须如实标注来源", got.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 渲染**成功但正文为空** ⇒ 空串 + nil error:它是读数不是失败,由上层按自己的口径
|
||||
// 判(io 的降级档把空正文按 ErrEmptyRenderedBody 处理)。这里钉住本层不许把它变成 error,
|
||||
// 也不许拿空串冒充「取到了失败」。
|
||||
func TestManager_RenderTextEmptyBodyIsNotAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := okBrowserCDP(t, " \n ")
|
||||
m := renderTestManager(t, cdpEndpointAvail(f))
|
||||
|
||||
got, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if err != nil {
|
||||
t.Fatalf("正文为空不该是错误(它是读数),实得 %v", err)
|
||||
}
|
||||
if strings.TrimSpace(got.Text) != "" {
|
||||
t.Errorf("Text = %q, want 空/空白", got.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 导航失败(CDP 的 errorText)⇒ 如实报错,**不返回空串冒充成功**:判据是
|
||||
// errors.Is(err, ErrNavigate) 且正文那一次求值根本没发生过。
|
||||
func TestManager_RenderTextNavigateFailureIsHonestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
|
||||
switch method {
|
||||
case "Page.enable":
|
||||
return map[string]any{}, nil
|
||||
case "Page.navigate":
|
||||
return map[string]any{"errorText": "net::ERR_NAME_NOT_RESOLVED"}, nil
|
||||
}
|
||||
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
|
||||
})
|
||||
m := renderTestManager(t, cdpEndpointAvail(f))
|
||||
|
||||
got, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if err == nil {
|
||||
t.Fatal("导航失败必须报错,不许返回空正文冒充成功")
|
||||
}
|
||||
if !errors.Is(err, ErrNavigate) {
|
||||
t.Errorf("err = %v, want ErrNavigate", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ERR_NAME_NOT_RESOLVED") {
|
||||
t.Errorf("错误里应带上 CDP 的原始原因: %v", err)
|
||||
}
|
||||
if got.Text != "" || got.Source != "" {
|
||||
t.Errorf("失败时不该交出半个结论: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 页面求值异常(正文那一次)⇒ 归类为 ErrEvaluate,同样不许冒充「取到了空正文」。
|
||||
func TestManager_RenderTextEvaluateFailureIsHonestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
|
||||
switch method {
|
||||
case "Page.enable", "Page.navigate":
|
||||
return map[string]any{}, nil
|
||||
case "Page.getFrameTree":
|
||||
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
|
||||
case "Page.createIsolatedWorld":
|
||||
return map[string]any{"executionContextId": 1}, nil
|
||||
case "Runtime.evaluate":
|
||||
expr, _ := params["expression"].(string)
|
||||
if strings.Contains(expr, "readyState") {
|
||||
return map[string]any{"result": map[string]any{"value": map[string]any{
|
||||
"url": "https://example.com/", "title": "t", "readyState": "complete",
|
||||
}}}, nil
|
||||
}
|
||||
return map[string]any{"exceptionDetails": map[string]any{"text": "TypeError: body is gone"}}, nil
|
||||
}
|
||||
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
|
||||
})
|
||||
m := renderTestManager(t, cdpEndpointAvail(f))
|
||||
|
||||
if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrEvaluate) {
|
||||
t.Fatalf("err = %v, want ErrEvaluate", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ SSRF 守卫在**起浏览器之前**跑:云元数据地址被拒,且没有任何 CDP 请求发生
|
||||
// (假 CDP 只被 /json/version 碰过的话就不是这条路径 —— 这里直接用不可达的可执行文件,
|
||||
// 若顺序颠倒会拿到启动错误而不是守卫错误)。
|
||||
func TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := renderTestManager(t, browsercfg.Availability{
|
||||
Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe",
|
||||
})
|
||||
|
||||
_, err := m.RenderText(context.Background(), "http://169.254.169.254/latest/meta-data/")
|
||||
if !errors.Is(err, ErrBlockedURL) {
|
||||
t.Fatalf("err = %v, want ErrBlockedURL", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "安全守卫") {
|
||||
t.Errorf("错误应来自 URL 守卫: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 没有可用来源时不假装可用:ErrUnavailable(组合根据此**不挂**这条降级档)。
|
||||
func TestManager_RenderTextUnavailableDoesNotPretend(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := renderTestManager(t, browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"})
|
||||
|
||||
if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,23 @@ func (t *Tool) State() browsercap.StateSnapshot {
|
||||
return src.State()
|
||||
}
|
||||
|
||||
// Manager 返回驱动层的浏览器管理器(`*browsercap.Manager`;驱动不是它时返回 nil)。
|
||||
//
|
||||
// 它存在只为一件事:design/28 §1 M5 / §8 B5 的 web-fetch 降级档 —— 组合根要拿**当前**
|
||||
// 这把工具的驱动去渲染取正文(`Manager.RenderText`),所以这里给出驱动句柄,而不是让
|
||||
// 工具面再多一个动作(模型不需要 `extract` 这个动作名,那件事由 web-fetch 自动做)。
|
||||
//
|
||||
// 返回具体类型而不是 Driver:取正文是驱动层的能力、不在工具的三个动作里,也不该加进
|
||||
// Driver 接口(那会逼每个假驱动实现一个它们不需要的方法)。单测夹具的假驱动不是
|
||||
// `*browsercap.Manager` ⇒ 返回 nil,调用方据此如实报「没有可用来源」。
|
||||
func (t *Tool) Manager() *browsercap.Manager {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
mgr, _ := t.mgr.(*browsercap.Manager)
|
||||
return mgr
|
||||
}
|
||||
|
||||
// Name 实现 sdk.Tool。
|
||||
func (t *Tool) Name() string { return ToolName }
|
||||
|
||||
|
||||
Reference in new issue
Block a user