公开页抓取改为 Chrome 136 自洽身份 + 每节点 SQLite 养罐,Trafilatura 走 curl_cffi;MCP/README/接手说明与 09-02 现网复测对齐,避免消费方继续抄过期的站点三分表。 Co-authored-by: Cursor <cursoragent@cursor.com>
191 lines
5.8 KiB
Go
191 lines
5.8 KiB
Go
// 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
|
||
}
|