公开页抓取改为 Chrome 136 自洽身份 + 每节点 SQLite 养罐,Trafilatura 走 curl_cffi;MCP/README/接手说明与 09-02 现网复测对齐,避免消费方继续抄过期的站点三分表。 Co-authored-by: Cursor <cursoragent@cursor.com>
496 lines
17 KiB
Go
496 lines
17 KiB
Go
// Package contract 定义本项目唯一的公共契约面:统一信封、任务信封、
|
||
// 原始结果、拓展坞五方法接口与错误码。
|
||
//
|
||
// 字段权威:docs/mcp-usage-20260901.md §2(响应字段承诺)与
|
||
// docs/design-arch-20260901.md §3.2/§3.3/§3.4。任何字段增删必须先改文档再改此处。
|
||
//
|
||
// 序列化纪律(mcp-usage §2 字段纪律):
|
||
// - 空数组序列化为 [] 而非 null;
|
||
// - 时间字段一律 Asia/Shanghai +08:00(RFC3339 带偏移)。
|
||
package contract
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"onesvm.com/onesvm/browser-server/internal/config"
|
||
)
|
||
|
||
// ---------- 时间序列化 ----------
|
||
|
||
// Time 统一时间类型:JSON 序列化恒为东八区 RFC3339(+08:00)。
|
||
type Time struct{ time.Time }
|
||
|
||
// NewTime 把任意 time.Time 归一到东八区。
|
||
func NewTime(t time.Time) Time { return Time{t.In(config.TZ)} }
|
||
|
||
// NowTime 返回东八区当前时间的 Time。
|
||
func NowTime() Time { return NewTime(config.Now()) }
|
||
|
||
// MarshalJSON 强制 +08:00 格式(RFC3339 带固定时区)。
|
||
func (t Time) MarshalJSON() ([]byte, error) {
|
||
if t.IsZero() {
|
||
return []byte("null"), nil
|
||
}
|
||
return []byte(`"` + t.In(config.TZ).Format(time.RFC3339) + `"`), nil
|
||
}
|
||
|
||
// UnmarshalJSON 解析任意合法 RFC3339 时间并归一到东八区。
|
||
func (t *Time) UnmarshalJSON(b []byte) error {
|
||
s := strings.Trim(string(b), `"`)
|
||
if s == "" || s == "null" {
|
||
t.Time = time.Time{}
|
||
return nil
|
||
}
|
||
parsed, err := time.Parse(time.RFC3339, s)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
t.Time = parsed.In(config.TZ)
|
||
return nil
|
||
}
|
||
|
||
// ---------- 错误码(design-arch §3.4 / mcp-usage §3) ----------
|
||
|
||
// 错误码常量,与 mcp-usage §3 表一一对应。
|
||
const (
|
||
CodeRateLimited = "rate_limited" // 429
|
||
CodeQuota = "quota" // 402
|
||
CodeTimeout = "timeout" // 504 或 200 信封
|
||
CodeUpstream = "upstream" // 502 或 200 信封
|
||
CodeBlocked = "blocked" // 200 信封(目标站拦截/超限)
|
||
CodeDenied = "denied" // 403(合规拦截)
|
||
CodeExtractFailed = "extract_failed" // 200 信封(结构化抽取失败)
|
||
CodeUnavailable = "unavailable" // 503(队列满/系统压力)
|
||
CodeUnauthorized = "unauthorized" // 401(key 缺失/无效)
|
||
)
|
||
|
||
// ErrBody 信封 error 字段(design-arch §3.3)。
|
||
type ErrBody struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
RetryAfterS *int `json:"retry_after_s,omitempty"`
|
||
}
|
||
|
||
// HTTPStatus 返回错误码对应的 HTTP 状态映射(mcp-usage §3 表)。
|
||
// 200 信封类(blocked/extract_failed/timeout/upstream)返回 200,
|
||
// 由调用方决定走 HTTP 错误还是 200+信封 error;本函数给默认 HTTP 面。
|
||
func (e ErrBody) HTTPStatus() int {
|
||
switch e.Code {
|
||
case CodeRateLimited:
|
||
return http.StatusTooManyRequests // 429
|
||
case CodeQuota:
|
||
return http.StatusPaymentRequired // 402
|
||
case CodeDenied:
|
||
return http.StatusForbidden // 403
|
||
case CodeUnauthorized:
|
||
return http.StatusUnauthorized // 401
|
||
case CodeUnavailable:
|
||
return http.StatusServiceUnavailable // 503
|
||
case CodeTimeout:
|
||
return http.StatusGatewayTimeout // 504
|
||
case CodeUpstream:
|
||
return http.StatusBadGateway // 502
|
||
case CodeBlocked, CodeExtractFailed:
|
||
return http.StatusOK // 200 信封:有信封无数据
|
||
default:
|
||
return http.StatusInternalServerError
|
||
}
|
||
}
|
||
|
||
// Retryable 判断错误码是否属瞬时错误可重试(design-arch §4.2:仅 timeout/upstream)。
|
||
func Retryable(code string) bool {
|
||
return code == CodeTimeout || code == CodeUpstream
|
||
}
|
||
|
||
// ---------- 统一信封(mcp-usage §2 响应形状) ----------
|
||
|
||
// Usage 用量块。
|
||
type Usage struct {
|
||
Credits int `json:"credits"`
|
||
Engine string `json:"engine"`
|
||
TokensEstimate int `json:"tokens_estimate"`
|
||
}
|
||
|
||
// Provenance 溯源块(一等公民,design-arch §3.3)。
|
||
type Provenance struct {
|
||
URL string `json:"url,omitempty"`
|
||
FinalURL string `json:"final_url,omitempty"`
|
||
RetrievedAt Time `json:"retrieved_at"`
|
||
Adapter string `json:"adapter"`
|
||
ProxyExit string `json:"proxy_exit"` // direct | pool:<name> | none
|
||
Cached bool `json:"cached"`
|
||
}
|
||
|
||
// SearchResult kind=search 的单条结果(mcp-usage §2.1)。
|
||
type SearchResult struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
URL string `json:"url"`
|
||
Content string `json:"content"` // ≤800 字符 query 相关片段,非全文
|
||
Score float64 `json:"score"` // 0..1
|
||
Engine string `json:"engine"`
|
||
PublishedAt *Time `json:"published_at,omitempty"`
|
||
}
|
||
|
||
// SearchPayload kind=search 数据块。results 空时为 [](MarshalJSON 锁死)。
|
||
type SearchPayload struct {
|
||
Query string `json:"query"`
|
||
Answer *string `json:"answer"` // 恒 null:本服务不做 LLM 答案合成
|
||
Results []SearchResult `json:"results"`
|
||
}
|
||
|
||
// ReadMetadata kind=read 的 metadata 块。
|
||
type ReadMetadata struct {
|
||
StatusCode int `json:"status_code"`
|
||
ContentType string `json:"content_type"`
|
||
Language string `json:"language"`
|
||
RetrievedAt Time `json:"retrieved_at"`
|
||
}
|
||
|
||
// ReadPayload kind=read 数据块(mcp-usage §2.2)。
|
||
type ReadPayload struct {
|
||
URL string `json:"url"`
|
||
FinalURL string `json:"final_url"`
|
||
Title string `json:"title"`
|
||
Description *string `json:"description"`
|
||
Markdown string `json:"markdown"`
|
||
Truncated bool `json:"truncated"`
|
||
CharCount int `json:"char_count"`
|
||
Metadata ReadMetadata `json:"metadata"`
|
||
Links []string `json:"links"`
|
||
Images []string `json:"images"`
|
||
HTML *string `json:"html"` // 特权 formats 点名才非 null
|
||
ScreenshotURL *string `json:"screenshot_url"` // 只给可过期 URL,禁 base64
|
||
Extracted map[string]any `json:"extracted"`
|
||
Warnings []string `json:"warnings"`
|
||
}
|
||
|
||
// EnsureEmptySlice 把 nil 切片归一为空切片(信封纪律:[] 而非 null)。
|
||
// 在构造 payload 后调用;单测有 golden JSON 锁死。
|
||
func (p *SearchPayload) EnsureEmptySlice() {
|
||
if p.Results == nil {
|
||
p.Results = []SearchResult{}
|
||
}
|
||
}
|
||
|
||
// EnsureEmptySlice 归一 read 侧数组字段。
|
||
func (p *ReadPayload) EnsureEmptySlice() {
|
||
if p.Links == nil {
|
||
p.Links = []string{}
|
||
}
|
||
if p.Images == nil {
|
||
p.Images = []string{}
|
||
}
|
||
if p.Warnings == nil {
|
||
p.Warnings = []string{}
|
||
}
|
||
}
|
||
|
||
// Envelope 三类 kind 共用顶层信封。
|
||
// Data 为 *SearchPayload / *ReadPayload / extract 旁路 map(kind=extract,首版未启用)。
|
||
type Envelope struct {
|
||
OK bool `json:"ok"`
|
||
Kind string `json:"kind"` // search | read | extract
|
||
RequestID string `json:"request_id"`
|
||
TookMs int64 `json:"took_ms"`
|
||
Data any `json:"-"`
|
||
Usage Usage `json:"usage"`
|
||
Provenance Provenance `json:"provenance"`
|
||
Error *ErrBody `json:"error"`
|
||
}
|
||
|
||
// envelopeWire 信封线格式:search/read 全字段集并列,kind 裁剪块负责互斥。
|
||
// 平铺字段一律无 omitempty:nil 指针/nil 切片输出显式 null,空切片输出 []。
|
||
type envelopeWire struct {
|
||
OK bool `json:"ok"`
|
||
Kind string `json:"kind"`
|
||
RequestID string `json:"request_id"`
|
||
TookMs int64 `json:"took_ms"`
|
||
Usage Usage `json:"usage"`
|
||
Provenance Provenance `json:"provenance"`
|
||
Error *ErrBody `json:"error"`
|
||
// search 平铺字段(answer 恒 null、results 恒 [])
|
||
Query string `json:"query"`
|
||
Answer *string `json:"answer"`
|
||
Results []SearchResult `json:"results"`
|
||
// read 平铺字段(links/images/warnings 恒 [];html/screenshot_url/extracted 恒存在)
|
||
URL *string `json:"url"`
|
||
FinalURL *string `json:"final_url"`
|
||
Title *string `json:"title"`
|
||
Description *string `json:"description"`
|
||
Markdown *string `json:"markdown"`
|
||
Truncated *bool `json:"truncated"`
|
||
CharCount *int `json:"char_count"`
|
||
Metadata *ReadMetadata `json:"metadata"`
|
||
Links []string `json:"links"`
|
||
Images []string `json:"images"`
|
||
HTML *string `json:"html"`
|
||
ScreenshotURL *string `json:"screenshot_url"`
|
||
Extracted map[string]any `json:"extracted"`
|
||
Warnings []string `json:"warnings"`
|
||
}
|
||
|
||
// envelopeTop 信封顶层公共字段(search/read wire 内嵌)。
|
||
type envelopeTop struct {
|
||
OK bool `json:"ok"`
|
||
Kind string `json:"kind"`
|
||
RequestID string `json:"request_id"`
|
||
TookMs int64 `json:"took_ms"`
|
||
Usage Usage `json:"usage"`
|
||
Provenance Provenance `json:"provenance"`
|
||
Error *ErrBody `json:"error"`
|
||
}
|
||
|
||
// searchWire kind=search 线格式(仅 search 字段)。
|
||
type searchWire struct {
|
||
envelopeTop
|
||
Query string `json:"query"`
|
||
Answer *string `json:"answer"`
|
||
Results []SearchResult `json:"results"`
|
||
}
|
||
|
||
// readWire kind=read 线格式(仅 read 字段)。
|
||
type readWire struct {
|
||
envelopeTop
|
||
URL *string `json:"url"`
|
||
FinalURL *string `json:"final_url"`
|
||
Title *string `json:"title"`
|
||
Description *string `json:"description"`
|
||
Markdown *string `json:"markdown"`
|
||
Truncated *bool `json:"truncated"`
|
||
CharCount *int `json:"char_count"`
|
||
Metadata *ReadMetadata `json:"metadata"`
|
||
Links []string `json:"links"`
|
||
Images []string `json:"images"`
|
||
HTML *string `json:"html"`
|
||
ScreenshotURL *string `json:"screenshot_url"`
|
||
Extracted map[string]any `json:"extracted"`
|
||
Warnings []string `json:"warnings"`
|
||
}
|
||
|
||
// MarshalJSON 平铺信封:search/read 各出独立 wire 形状(字段互斥、无串味)。
|
||
// 空数组纪律在组装处执行:nil → [](Results/Links/Images/Warnings)。
|
||
func (e Envelope) MarshalJSON() ([]byte, error) {
|
||
top := envelopeTop{
|
||
OK: e.OK,
|
||
Kind: e.Kind,
|
||
RequestID: e.RequestID,
|
||
TookMs: e.TookMs,
|
||
Usage: e.Usage,
|
||
Provenance: e.Provenance,
|
||
Error: e.Error,
|
||
}
|
||
switch d := e.Data.(type) {
|
||
case *SearchPayload:
|
||
w := searchWire{envelopeTop: top}
|
||
if d != nil {
|
||
w.Query = d.Query
|
||
w.Answer = d.Answer // 恒 null(不做 LLM answer)
|
||
w.Results = d.Results
|
||
}
|
||
if w.Results == nil {
|
||
w.Results = []SearchResult{}
|
||
}
|
||
return jsonMarshal(w)
|
||
case *ReadPayload:
|
||
w := readWire{envelopeTop: top}
|
||
if d != nil {
|
||
u, fu, ti, md := d.URL, d.FinalURL, d.Title, d.Markdown
|
||
tr, cc := d.Truncated, d.CharCount
|
||
w.URL, w.FinalURL, w.Title, w.Markdown = &u, &fu, &ti, &md
|
||
w.Truncated, w.CharCount = &tr, &cc
|
||
w.Description = d.Description // 可空字段透传指针
|
||
w.Metadata = &d.Metadata
|
||
w.HTML = d.HTML
|
||
w.ScreenshotURL = d.ScreenshotURL
|
||
w.Extracted = d.Extracted
|
||
w.Links, w.Images, w.Warnings = d.Links, d.Images, d.Warnings
|
||
}
|
||
if w.Links == nil {
|
||
w.Links = []string{}
|
||
}
|
||
if w.Images == nil {
|
||
w.Images = []string{}
|
||
}
|
||
if w.Warnings == nil {
|
||
w.Warnings = []string{}
|
||
}
|
||
return jsonMarshal(w)
|
||
default:
|
||
// extract 旁路或未知 kind:仅顶层
|
||
return jsonMarshal(top)
|
||
}
|
||
}
|
||
|
||
// ---------- 输入与任务信封 ----------
|
||
|
||
// Region 出口区域(design-arch §4.4 标签)。
|
||
const (
|
||
RegionDomestic = "domestic"
|
||
RegionOverseas = "overseas"
|
||
)
|
||
|
||
// ValidRegion 校验 region 值;空串给 domestic 默认。
|
||
func ValidRegion(r string) (string, error) {
|
||
switch r {
|
||
case "":
|
||
return RegionDomestic, nil
|
||
case RegionDomestic, RegionOverseas:
|
||
return r, nil
|
||
default:
|
||
return "", fmt.Errorf("region 必须为 %s|%s,得 %q", RegionDomestic, RegionOverseas, r)
|
||
}
|
||
}
|
||
|
||
// SearchInput 搜索输入(mcp-usage §2.1 参数表)。
|
||
type SearchInput struct {
|
||
Query string `json:"query"`
|
||
Region string `json:"region"`
|
||
MaxResults int `json:"max_results"`
|
||
TimeRange *string `json:"time_range,omitempty"` // day|week|month|year
|
||
Lang *string `json:"lang,omitempty"`
|
||
}
|
||
|
||
// ReadInput 精读输入(mcp-usage §2.2 参数表)。
|
||
type ReadInput struct {
|
||
URL string `json:"url"`
|
||
Formats []string `json:"formats"` // markdown 默认;links/images;html/screenshot 特权
|
||
MaxChars int `json:"max_chars"`
|
||
Extract *ExtractSpec `json:"extract,omitempty"` // 特权 scope
|
||
Region string `json:"region"`
|
||
}
|
||
|
||
// ExtractSpec 结构化抽取旁路(特权)。
|
||
type ExtractSpec struct {
|
||
Schema map[string]any `json:"schema"`
|
||
Prompt string `json:"prompt,omitempty"`
|
||
}
|
||
|
||
// DefaultFormats 默认产出格式。
|
||
func DefaultFormats() []string { return []string{"markdown"} }
|
||
|
||
// JobEnvelope 队列任务信封(design-arch §3.5;scheduler 落 WAL 的 payload 形状)。
|
||
type JobEnvelope struct {
|
||
ID string `json:"id"`
|
||
RequestID string `json:"request_id"`
|
||
Intent string `json:"intent"` // search | read
|
||
Search *SearchInput `json:"search,omitempty"`
|
||
Read *ReadInput `json:"read,omitempty"`
|
||
KeyID string `json:"key_id"`
|
||
ConsumerID string `json:"consumer_id"`
|
||
Priority int `json:"priority"`
|
||
SubmittedAt Time `json:"submitted_at"` // 东八区
|
||
Status string `json:"status"`
|
||
Session *SessionAttach `json:"-"` // Execute 注入;禁止落 jobs.payload / MCP
|
||
}
|
||
|
||
// 任务状态。
|
||
const (
|
||
JobQueued = "queued"
|
||
JobRunning = "running"
|
||
JobDone = "done"
|
||
JobFailed = "failed"
|
||
JobDead = "dead"
|
||
)
|
||
|
||
// RawResult 适配器原生输出(原生结构不出适配器,design-arch §3.2)。
|
||
type RawResult struct {
|
||
Title string `json:"title"`
|
||
Text string `json:"text"`
|
||
Markdown string `json:"markdown"`
|
||
HTML string `json:"html"`
|
||
FinalURL string `json:"final_url"`
|
||
StatusCode int `json:"status_code"`
|
||
Headers map[string]string `json:"headers"`
|
||
Engine string `json:"engine"`
|
||
Extra map[string]any `json:"extra"` // 引擎私有附加数据(score 位次、published_at 等)
|
||
SetCookies []Cookie `json:"-"` // 内部回写罐;禁止进信封
|
||
Poisoned bool `json:"-"` // 验证页/403;调度器整域丢罐
|
||
}
|
||
|
||
// Render 渲染能力分级(design-arch §4.4)。
|
||
const (
|
||
RenderNone = "none"
|
||
RenderLight = "light"
|
||
RenderFull = "full"
|
||
)
|
||
|
||
// Caps 适配器能力声明。
|
||
type Caps struct {
|
||
Intents []string `json:"intents"` // [search, read]
|
||
Render string `json:"render"` // none | light | full
|
||
Regions []string `json:"regions"` // [domestic, overseas]
|
||
MaxConcurrent int `json:"max_concurrent"`
|
||
ProxyRequired bool `json:"proxy_required"`
|
||
Formats []string `json:"formats"`
|
||
}
|
||
|
||
// Match 判断 Caps 是否满足任务需求(能力路由标签匹配,W2 派发用)。
|
||
// needRegion 非空时必须在 Regions 内;render 需求 none 总可满足,
|
||
// light 要求 ≥light,full 仅 full。
|
||
func (c Caps) Match(intent, region, minRender string) error {
|
||
found := false
|
||
for _, i := range c.Intents {
|
||
if i == intent {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return fmt.Errorf("adapter 不支持意图 %s", intent)
|
||
}
|
||
if region != "" && region != RegionDomestic && region != RegionOverseas {
|
||
return fmt.Errorf("非法 region %q", region)
|
||
}
|
||
if region == RegionOverseas && !c.supportsRegion(region) {
|
||
return fmt.Errorf("adapter 不支持 region=overseas")
|
||
}
|
||
if !renderGE(c.Render, minRender) {
|
||
return fmt.Errorf("adapter 渲染能力 %s < 需求 %s", c.Render, minRender)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c Caps) supportsRegion(r string) bool {
|
||
for _, x := range c.Regions {
|
||
if x == r {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// renderGE 渲染分级比较:a 是否 ≥ b。
|
||
func renderGE(a, b string) bool {
|
||
rank := map[string]int{RenderNone: 0, RenderLight: 1, RenderFull: 2}
|
||
return rank[a] >= rank[b]
|
||
}
|
||
|
||
// ---------- Dock 五方法接口(design-arch §3.2) ----------
|
||
|
||
// Health 适配器健康上报(research/04 §4.6 强制项)。
|
||
type Health struct {
|
||
OK bool `json:"ok"`
|
||
RSSBytes uint64 `json:"rss_bytes"`
|
||
StartupMs int64 `json:"startup_ms"`
|
||
SlotsFree int `json:"slots_free"`
|
||
Details string `json:"details,omitempty"`
|
||
}
|
||
|
||
// DockAdapter 拓展坞五方法。W2–W4 适配器按此实现并注册进 scheduler 注册表。
|
||
type DockAdapter interface {
|
||
// Init 初始化:拉进程/连远端/预热 HTTP 客户端。
|
||
Init(ctx context.Context) error
|
||
// Health 上报内存与启动耗时(协议强制)。
|
||
Health() Health
|
||
// Execute 执行任务;原生结构不出适配器,只产 RawResult。
|
||
Execute(ctx context.Context, job JobEnvelope) (*RawResult, *ErrBody)
|
||
// Teardown 优雅停机:停新活、归还槽位、杀进程回收。
|
||
Teardown(ctx context.Context) error
|
||
// Capabilities 能力标签(能力路由依据)。
|
||
Capabilities() Caps
|
||
}
|