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

191 lines
5.8 KiB
Go
Raw Permalink 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 httpx 共享 HTTP 客户端:超时/体积/类型守卫 + 重定向每跳策略重验。
//
// 复用声明:体积/类型守卫移植自 bench/trafilatura-http/app.py
// (MAX_DOWNLOAD_BYTES=5MB、Content-Type 白名单思路)。
// 本包只做策略守卫下载,不冒充浏览器(P0:TLS 与 UA 必须一致)。
package httpx
import (
"context"
"fmt"
"io"
"mime"
"net/http"
"strings"
"time"
)
// UA 本客户端诚实身份。页面抓取走 Trafilatura/curl_cffi 或 CDP,不经本包。
// 禁止再写 Chrome/* —— Go crypto/tls 与浏览器 JA3 不一致。
const UA = "onesvm-browser-server-httpx/1.0"
// 超时与体积默认(design §5.4 体积/类型行;bench app.py 同值)。
const (
DialTimeout = 10 * time.Second
TotalTimeout = 20 * time.Second
MaxBytes = 5 * 1024 * 1024 // 5MB 默认下载上限
)
// allowedContentTypes Content-Type 白名单(mcp-usage §4.4:html/xml/json/pdf/text)。
var allowedContentTypes = map[string]bool{
"text/html": true, "application/xhtml+xml": true, "text/xml": true,
"application/xml": true, "application/json": true, "text/json": true,
"application/pdf": true, "text/plain": true,
"application/xhtml": true, "text/markdown": true,
}
// Client 共享 HTTP 客户端(带策略守卫)。
type Client struct {
hc *http.Client
guard GuardAdapter
maxBytes int64
}
// New 构造客户端。guard 为 nil 时不做重定向重验(仅限单测环回)。
func New(guard GuardAdapter, maxBytes int64) *Client {
if maxBytes <= 0 {
maxBytes = MaxBytes
}
c := &Client{guard: guard, maxBytes: maxBytes}
dialer := &netDialer{timeout: DialTimeout}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: DialTimeout,
ResponseHeaderTimeout: TotalTimeout,
MaxIdleConns: 64,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 60 * time.Second,
}
c.hc = &http.Client{
Timeout: TotalTimeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("httpx: 重定向超 5 跳")
}
// 每跳重验(design §5.4:每次重定向重验)
if c.guard != nil {
if err := c.guard.RedirectCheck(req.Context(), req.URL.String()); err != nil {
return err
}
}
return nil
},
}
return c
}
// Get 执行 GET:守卫首跳 → 下载限流 → 类型白名单校验。
// 返回 body 与最终 URL;守卫拒绝返回 denied 类错误。
func (c *Client) Get(ctx context.Context, url string) (*Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("httpx: 请求构造失败: %w", err)
}
req.Header.Set("User-Agent", UA)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
resp, err := c.hc.Do(req)
if err != nil {
// 重定向守卫拒绝与网络错误分开:调用方按 message 归类。
return nil, classifyErr(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return &Response{StatusCode: resp.StatusCode, FinalURL: resp.Request.URL.String(),
ContentType: resp.Header.Get("Content-Type"), Headers: headersOf(resp)}, nil
}
// 类型守卫
ct := resp.Header.Get("Content-Type")
if !allowedType(ct) {
return nil, &BlockedError{Reason: fmt.Sprintf("Content-Type 不在白名单: %s", ct)}
}
// 体积守卫:MaxBytesReader 硬限
limited := io.LimitReader(resp.Body, c.maxBytes+1)
body, err := io.ReadAll(limited)
if err != nil {
return nil, classifyErr(err)
}
if int64(len(body)) > c.maxBytes {
return nil, &BlockedError{Reason: fmt.Sprintf("下载超 %d 字节上限", c.maxBytes)}
}
return &Response{
StatusCode: resp.StatusCode,
FinalURL: resp.Request.URL.String(),
ContentType: ct,
Headers: headersOf(resp),
Body: body,
}, nil
}
// Response 抓取结果。
type Response struct {
StatusCode int
FinalURL string
ContentType string
Headers map[string]string
Body []byte
}
// BlockedError 体积/类型守卫拒绝(error.code=blocked)。
type BlockedError struct{ Reason string }
// Error 实现 error。
func (e *BlockedError) Error() string { return "blocked: " + e.Reason }
// DeniedError 重定向守卫拒绝(error.code=denied,合规拦截)。
type DeniedError struct{ Reason string }
// Error 实现 error。
func (e *DeniedError) Error() string { return "denied: " + e.Reason }
// classifyErr 网络错误归类(timeout / upstream)。
func classifyErr(err error) error {
msg := err.Error()
if strings.Contains(msg, "context deadline exceeded") || strings.Contains(msg, "Client.Timeout") {
return &TimeoutError{Reason: msg}
}
return fmt.Errorf("httpx: %w", err)
}
// TimeoutError 超时。
type TimeoutError struct{ Reason string }
// Error 实现 error。
func (e *TimeoutError) Error() string { return "timeout: " + e.Reason }
// allowedType Content-Type 白名单判定。
func allowedType(ct string) bool {
if ct == "" {
return true // 无头由嗅探兜底(保守放行,体积守卫兜底)
}
mt, _, err := mime.ParseMediaType(ct)
if err != nil {
mt = strings.TrimSpace(strings.Split(ct, ";")[0])
}
mt = strings.ToLower(strings.TrimSpace(mt))
if allowedContentTypes[mt] {
return true
}
// text/* 全放(text/csv 等长尾)
return strings.HasPrefix(mt, "text/")
}
// headersOf 复制响应头(限 64 项防异常头洪泛)。
func headersOf(resp *http.Response) map[string]string {
out := make(map[string]string, 8)
n := 0
for k, v := range resp.Header {
if n >= 64 {
break
}
out[strings.ToLower(k)] = strings.Join(v, ", ")
n++
}
return out
}
// GuardAdapter 重定向守卫适配接口(policy.Engine 满足;测试可 stub)。
type GuardAdapter interface {
RedirectCheck(ctx context.Context, url string) error
}