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