// routing.go:能力路由(标签制,design §4.4)+ read 降级链(plan-final §3)。 // // 路由表(intent + region + render → adapter 名): // // search + domestic → searxng-cn // search + overseas → searxng-global // read + render=none → trafilatura // read + render=light → lightpanda // read + render=full → headless-shell // // 降级链(read):trafilatura 空正文(empty_extract 类)→ lightpanda(render=light) // → headless-shell(仅特权/保真需求)→ 全败 blocked/upstream + warnings,不静默降质。 // 适配器 Health().ok=false → 从路由摘除走降级(design §4.5 条款 2)。 // overseas 任务:经 proxymanager 选出口;proxymanager 不可达 → fail-closed 排队 // 等待(O3:回队+告警,不降级直连)。 package scheduler import ( "context" "strings" "time" "onesvm.com/onesvm/browser-server/internal/contract" ) // 路由目标常量。 const ( adapterSearxCN = "searxng-cn" adapterSearxGlobal = "searxng-global" adapterTraf = "trafilatura" adapterPanda = "lightpanda" adapterShell = "headless-shell" ) // routeTarget 单步路由决策。 type routeTarget struct { adapter string minRender string } // routeSearch 搜索路由(region 决定实例;无降级——引擎不可用即 upstream 重试)。 func routeSearch(region string) string { if region == contract.RegionOverseas { return adapterSearxGlobal } return adapterSearxCN } // routeRead 首选 read 适配器:render=none → trafilatura;light → panda;full → shell。 // 意图判定依据 ReadInput.Formats / Extract(首版 read 默认 markdown → none); // formats 含 html/screenshot 或显式 render 需求走升级。 func routeRead(job contract.JobEnvelope) string { if job.Read == nil { return adapterTraf } for _, f := range job.Read.Formats { switch f { case "html", "screenshot": return adapterShell // 特权 formats 需要完整 DOM } } return adapterTraf } // routeResult 单次执行结论。 type routeResult struct { raw *contract.RawResult errBody *contract.ErrBody adapter string // 最终成功的适配器名 proxy string // provenance.proxy_exit warnings []string tookMs int64 } // executeWithRouting 能力路由 + 降级链执行(runJob 内核)。 func (c *Core) executeWithRouting(ctx context.Context, job contract.JobEnvelope) (*contract.RawResult, *contract.ErrBody, routeResult) { t0 := time.Now() switch job.Intent { case "search": return c.execSearch(ctx, job, t0) case "read": return c.execReadChain(ctx, job, t0) default: return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: "未知意图 " + job.Intent}, routeResult{tookMs: ms(t0)} } } // execSearch 搜索执行(无降级链;overseas 需 proxymanager 可达)。 func (c *Core) execSearch(ctx context.Context, job contract.JobEnvelope, t0 time.Time) (*contract.RawResult, *contract.ErrBody, routeResult) { region := contract.RegionDomestic if job.Search != nil { region = job.Search.Region } name := routeSearch(region) // overseas:proxymanager 不可达 → fail-closed 排队等待(O3 回队+告警)。 if region == contract.RegionOverseas { if !c.proxyOK.Load() { return nil, &contract.ErrBody{Code: contract.CodeUnavailable, RetryAfterS: retryPtr(10), Message: "proxymanager 不可达,overseas 任务排队等待(fail-closed,不降级直连)"}, routeResult{tookMs: ms(t0)} } } a, ok := c.reg.Get(name) if !ok || !a.Health().OK { return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: "搜索适配器不可用: " + name}, routeResult{tookMs: ms(t0)} } proxy := "direct" if region == contract.RegionOverseas { pe, err := c.proxy.ExitForDomain(ctx, jobSearchURL(job)) if err != nil { return nil, &contract.ErrBody{Code: contract.CodeUnavailable, RetryAfterS: retryPtr(10), Message: "出口查询失败(fail-closed): " + err.Error()}, routeResult{tookMs: ms(t0)} } proxy = pe.Proxy } raw, eb := a.Execute(ctx, job) return raw, eb, routeResult{adapter: name, proxy: proxyExitLabel(region, proxy), tookMs: ms(t0)} } // execReadChain read 降级链:trafilatura → lightpanda → headless-shell。 // 每步失败原因进 warnings;全败 blocked/upstream(不静默降质)。 func (c *Core) execReadChain(ctx context.Context, job contract.JobEnvelope, t0 time.Time) (*contract.RawResult, *contract.ErrBody, routeResult) { primary := routeRead(job) // 渲染档需求(formats html/screenshot)直接进 shell 单步(特权路径不走 A/B)。 if primary == adapterShell { return c.execOne(ctx, job, adapterShell, "direct", t0) } // 链:A(trafilatura) → B(lightpanda) → C(headless-shell)。 chain := []string{adapterTraf, adapterPanda, adapterShell} // 从需求档位起走(render=light 意图直接从 B 起)。 if primary == adapterPanda { chain = []string{adapterPanda, adapterShell} } else if primary == adapterShell { chain = []string{adapterShell} } proxy := "direct" if job.Read != nil && job.Read.Region == contract.RegionOverseas { if !c.proxyOK.Load() { return nil, &contract.ErrBody{Code: contract.CodeUnavailable, RetryAfterS: retryPtr(10), Message: "proxymanager 不可达,overseas read 排队等待(fail-closed)"}, routeResult{tookMs: ms(t0)} } pe, err := c.proxy.ExitForDomain(ctx, job.Read.URL) if err != nil { return nil, &contract.ErrBody{Code: contract.CodeUnavailable, RetryAfterS: retryPtr(10), Message: "出口查询失败(fail-closed): " + err.Error()}, routeResult{tookMs: ms(t0)} } proxy = pe.Proxy } var warnings []string var lastErr *contract.ErrBody for _, name := range chain { a, ok := c.reg.Get(name) if !ok || !a.Health().OK { warnings = append(warnings, "adapter_unhealthy:"+name) continue } raw, eb := a.Execute(ctx, job) if eb == nil { return raw, nil, routeResult{adapter: name, proxy: proxyExitLabel(job.Read.Region, proxy), warnings: warnings, tookMs: ms(t0)} } lastErr = eb warnings = append(warnings, name+" 失败("+eb.Code+"): "+eb.Message) // denied(合规拦截)不降级——换引擎同样被拦,直接终态。 if eb.Code == contract.CodeDenied { return nil, eb, routeResult{warnings: warnings, tookMs: ms(t0)} } } if lastErr == nil { lastErr = &contract.ErrBody{Code: contract.CodeUpstream, Message: "read 降级链全部不可用"} } return nil, lastErr, routeResult{warnings: warnings, tookMs: ms(t0)} } // execOne 单适配器执行(shell 直达路径用)。 func (c *Core) execOne(ctx context.Context, job contract.JobEnvelope, name, proxy string, t0 time.Time) (*contract.RawResult, *contract.ErrBody, routeResult) { a, ok := c.reg.Get(name) if !ok || !a.Health().OK { return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: "适配器不可用: " + name}, routeResult{tookMs: ms(t0)} } raw, eb := a.Execute(ctx, job) return raw, eb, routeResult{adapter: name, proxy: proxy, tookMs: ms(t0)} } // proxyExitLabel proxy_exit 字段归一("direct"|"pool:"|"none")。 func proxyExitLabel(region, proxy string) string { if region != contract.RegionOverseas { return "direct" } if proxy == "" { return "none" } return proxy } // jobSearchURL 搜索任务无目标 URL(域名路由键给空——proxymanager 按默认池)。 func jobSearchURL(job contract.JobEnvelope) string { if job.Read != nil { return job.Read.URL } return "" } // ms 起始到现在的毫秒。 func ms(t time.Time) int64 { return time.Since(t).Milliseconds() } // isBlockedLikeUpstream 判定 upstream 消息里的 empty_extract 类(trafilatura 空正文)。 func isEmptyExtract(msg string) bool { return strings.Contains(msg, "empty_extract") } // retryPtr int → *int(RetryAfterS 可选字段辅助)。 func retryPtr(n int) *int { return &n }