onesvm-browser-server/server/internal/contract/contract.go
chii 9b689b2476 feat: 落地节点指纹与 Cookie 罐,并按现网能力更新消费/接手文档
公开页抓取改为 Chrome 136 自洽身份 + 每节点 SQLite 养罐,Trafilatura 走 curl_cffi;MCP/README/接手说明与 09-02 现网复测对齐,避免消费方继续抄过期的站点三分表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:04:25 +08:00

496 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}