onesvm-browser-server/server/internal/httpx/httpx.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

190 lines
5.8 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 httpx 共享 HTTP 客户端:超时/体积/类型守卫 + 重定向每跳策略重验。
//
// 复用声明:UA 与反爬判定参数取自 bench/site-matrix/cdp_fetch.mjs(Chrome 151 UA、
// detectVendor 正则);体积/类型守卫移植自 bench/trafilatura-http/app.py
// (MAX_DOWNLOAD_BYTES=5MB、Content-Type 白名单思路)。
package httpx
import (
"context"
"fmt"
"io"
"mime"
"net/http"
"strings"
"time"
)
// UA 桌面 Chrome 151(与 bench/site-matrix/cdp_fetch.mjs 一致)。
const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.109 Safari/537.36"
// 超时与体积默认(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
}