onesvm-browser-server/server/internal/dock/trafilatura.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

210 lines
6.9 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.

// trafilatura.go:Trafilatura HTTP 适配器(read / render=none)。
//
// 复用声明:POST /v1/read {url, max_chars} 契约与响应形状
// {ok, title, markdown, char_count, truncated, url, error, fail_class}
// 逐字段对齐 bench/trafilatura-http/app.py(同一代码构建的引擎服务)。
// 响应样本以 bench/trafilatura-http/samples/t2-1.excerpt.json(成功)与
// t2-cross-govcn.excerpt.json(empty_extract)为准(T1 契约:mock=真实形状)。
// 空正文(empty_extract)不在此降级——触发上层(scheduler)降级链升级 lightpanda。
package dock
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"onesvm.com/onesvm/browser-server/internal/contract"
)
// trafMaxConcurrent 引擎侧 semaphore=8(app.py SEM=8,复用声明)。
const trafMaxConcurrent = 8
// TrafilaturaAdapter trafilatura 精读适配器。
type TrafilaturaAdapter struct {
baseURL string
sem chan struct{}
hc *http.Client
mu sync.Mutex
healthy bool
lastErr string
}
// NewTrafilatura 构造。baseURL 默认 http://trafilatura:8080。
func NewTrafilatura(baseURL string) *TrafilaturaAdapter {
return &TrafilaturaAdapter{
baseURL: baseURL,
sem: make(chan struct{}, trafMaxConcurrent),
hc: &http.Client{Timeout: 30 * time.Second},
healthy: true,
}
}
// Capabilities read / none / 双区域(代理由引擎侧 httpx proxy env 承担)。
func (t *TrafilaturaAdapter) Capabilities() contract.Caps {
return contract.Caps{
Intents: []string{"read"},
Render: contract.RenderNone,
Regions: []string{contract.RegionDomestic, contract.RegionOverseas},
MaxConcurrent: trafMaxConcurrent,
ProxyRequired: false, // 引擎侧环境变量配代理,适配器无需经 ProxyManager
Formats: []string{"markdown"},
}
}
// Init 探活 GET /health(app.py:/health 返回 {ok:true,service:...})。
func (t *TrafilaturaAdapter) Init(ctx context.Context) error {
hc := &http.Client{Timeout: 5 * time.Second}
resp, err := hc.Get(t.baseURL + "/health")
if err != nil {
t.markHealth(false, err.Error())
return nil
}
defer resp.Body.Close()
t.markHealth(resp.StatusCode == http.StatusOK, fmt.Sprintf("health=%d", resp.StatusCode))
return nil
}
func (t *TrafilaturaAdapter) markHealth(ok bool, msg string) {
t.mu.Lock()
t.healthy = ok
t.lastErr = msg
t.mu.Unlock()
}
// Health 健康上报(RSS 0:远端引擎进程不可读,同 searxng 注明)。
func (t *TrafilaturaAdapter) Health() contract.Health {
t.mu.Lock()
ok, msg := t.healthy, t.lastErr
t.mu.Unlock()
return contract.Health{OK: ok, RSSBytes: 0, StartupMs: 0,
SlotsFree: trafMaxConcurrent - len(t.sem), Details: msg}
}
// Teardown 无本地进程。
func (t *TrafilaturaAdapter) Teardown(_ context.Context) error { return nil }
// trafRequest /v1/read 请求体(app.py do_POST 契约)。
type trafRequest struct {
URL string `json:"url"`
MaxChars int `json:"max_chars"`
Impersonate string `json:"impersonate,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Cookies []contract.Cookie `json:"cookies,omitempty"`
}
// trafResponse /v1/read 响应体(app.py _extract 返回形状)。
type trafResponse struct {
OK bool `json:"ok"`
Title string `json:"title"`
Markdown string `json:"markdown"`
CharCount *int `json:"char_count"`
Truncated *bool `json:"truncated"`
URL string `json:"url"`
Error *string `json:"error"`
FailClass *string `json:"fail_class"`
SetCookies []contract.Cookie `json:"set_cookies"`
StatusCode int `json:"status_code"`
Poisoned bool `json:"poisoned"`
}
// Execute 精读执行。
func (t *TrafilaturaAdapter) Execute(ctx context.Context, job contract.JobEnvelope) (*contract.RawResult, *contract.ErrBody) {
if job.Read == nil {
return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: "read 任务缺 ReadInput"}
}
in := job.Read
maxChars := in.MaxChars
if maxChars <= 0 {
maxChars = 20000
}
trReq := trafRequest{URL: in.URL, MaxChars: maxChars}
if job.Session != nil {
trReq.Impersonate = job.Session.Impersonate
trReq.Headers = job.Session.Headers
trReq.Cookies = job.Session.Cookies
}
body, err := json.Marshal(trReq)
if err != nil {
return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: err.Error()}
}
select {
case t.sem <- struct{}{}:
defer func() { <-t.sem }()
case <-ctx.Done():
return nil, &contract.ErrBody{Code: contract.CodeTimeout, Message: "trafilatura 槽位等待取消"}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.baseURL+"/v1/read", bytes.NewReader(body))
if err != nil {
return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: err.Error()}
}
req.Header.Set("Content-Type", "application/json")
resp, err := t.hc.Do(req)
if err != nil {
// 单次超时不当整实例不健康(healthWatch 只认 /health)。
return nil, &contract.ErrBody{Code: errCodeOf(err), Message: "trafilatura 请求失败: " + err.Error()}
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return nil, &contract.ErrBody{Code: errCodeOf(err), Message: "trafilatura 读响应: " + err.Error()}
}
var tr trafResponse
if err := json.Unmarshal(raw, &tr); err != nil {
return nil, &contract.ErrBody{Code: contract.CodeUpstream,
Message: fmt.Sprintf("trafilatura 响应解码(HTTP %d): %v", resp.StatusCode, err)}
}
t.markHealth(true, "")
if !tr.OK {
failClass := ""
if tr.FailClass != nil {
failClass = *tr.FailClass
}
errMsg := "empty_extract"
if tr.Error != nil {
errMsg = *tr.Error
}
code := contract.CodeUpstream
if failClass == "blocked" || tr.Poisoned {
code = contract.CodeBlocked
return &contract.RawResult{SetCookies: tr.SetCookies, Poisoned: true, StatusCode: tr.StatusCode, Engine: "trafilatura"},
&contract.ErrBody{Code: code,
Message: fmt.Sprintf("trafilatura 拦截(fail_class=%s): %s", failClass, errMsg)}
}
return nil, &contract.ErrBody{Code: code,
Message: fmt.Sprintf("trafilatura 空正文(fail_class=%s): %s", failClass, errMsg)}
}
md := tr.Markdown
truncated := false
charCount := len([]rune(md))
if tr.CharCount != nil {
charCount = *tr.CharCount
}
if tr.Truncated != nil {
truncated = *tr.Truncated
}
status := resp.StatusCode
if tr.StatusCode > 0 {
status = tr.StatusCode
}
return &contract.RawResult{
Title: tr.Title,
Markdown: md,
FinalURL: firstNonEmpty(tr.URL, in.URL),
StatusCode: status,
Engine: "trafilatura",
Extra: map[string]any{
"char_count": charCount,
"truncated": truncated,
},
SetCookies: tr.SetCookies,
Poisoned: tr.Poisoned,
}, nil
}
// Name 适配器展示名(registry 键)。
func (t *TrafilaturaAdapter) Name() string { return "trafilatura" }