You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

139 lines
4.5 KiB

// Package ehttp 提供 e-hentai 专用的 HTTP 客户端:基于 utls 伪造 Chrome 的
// TLS ClientHello(HelloChrome_Auto),绕过 e-hentai / Cloudflare edge 的
// fingerprint 检测(否则会直接 SSL_ERROR_SYSCALL RST 掉)。
//
// 与 stdlib net/http 的差异:
// - DialTLSContext 接管 TLS 握手,用 utls.UClient 替换 crypto/tls;
// - ALPN 同时声明 h2 + http/1.1,与真实 Chrome 一致;
// - UA / Accept / Accept-Language 字段需要调用方在请求里加(本 helper
// 不主动设置通用头,保持与 stdlib 一样的「不主动设默认」行为)。
//
// 用法:
//
// hc := ehttp.NewClient(30 * time.Second)
// resp, err := hc.Get(ctx, "https://e-hentai.org/...",
// ehttp.WithReferer("https://e-hentai.org/g/.../"),
// ehttp.WithChromeHeaders(), // UA / Sec-CH-UA / Sec-Fetch-* 全套
// )
package ehttp
import (
"context"
"net"
"net/http"
"time"
tls "github.com/refraction-networking/utls"
)
// Options 给 Get / Do 的额外参数。
type Options struct {
Referer string
// ChromeHeaders 为 true 时,设置 Chrome 134 在 macOS 上的真实请求头集合:
// User-Agent、Accept、Accept-Language、Sec-CH-UA*、Sec-Fetch-*、
// Upgrade-Insecure-Requests。
ChromeHeaders bool
// Accept 覆盖默认 Accept。ChromeHeaders=true 时也会被它覆盖。
Accept string
}
type Option func(*Options)
func WithReferer(r string) Option { return func(o *Options) { o.Referer = r } }
func WithChromeHeaders() Option { return func(o *Options) { o.ChromeHeaders = true } }
func WithAccept(a string) Option { return func(o *Options) { o.Accept = a } }
// Client 是一个带 utls fingerprint 的 http.Client。
type Client struct {
hc *http.Client
timeout time.Duration
}
// NewClient 返回一个带 utls 的 HTTP 客户端。
// timeout 是整个请求的超时;内部 TLS 握手独立超时由 stdlib net.Dialer 控制。
func NewClient(timeout time.Duration) *Client {
stdDialer := &net.Dialer{Timeout: 30 * time.Second}
dialTLS := func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
rawConn, err := stdDialer.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
cfg := &tls.Config{
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
}
tlsConn := tls.UClient(rawConn, cfg, tls.HelloChrome_Auto)
if err := tlsConn.HandshakeContext(ctx); err != nil {
_ = rawConn.Close()
return nil, err
}
return tlsConn, nil
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return stdDialer.DialContext(ctx, network, addr)
},
DialTLSContext: dialTLS,
MaxIdleConns: 16,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 30 * time.Second,
}
return &Client{
hc: &http.Client{
Transport: transport,
Timeout: timeout,
},
timeout: timeout,
}
}
// Do 直接转发到内部的 http.Client,可选地应用 Options。
func (c *Client) Do(req *http.Request, opts ...Option) (*http.Response, error) {
applyOptions(req, opts)
return c.hc.Do(req)
}
// Get 是 GET 的便捷封装。
func (c *Client) Get(ctx context.Context, url string, opts ...Option) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return c.Do(req, opts...)
}
// applyOptions 把 Options 里的字段塞进 req 的 Header。
func applyOptions(req *http.Request, opts []Option) {
if len(opts) == 0 {
return
}
o := Options{}
for _, fn := range opts {
fn(&o)
}
if o.Referer != "" {
req.Header.Set("Referer", o.Referer)
}
if o.ChromeHeaders {
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Sec-CH-UA", `"Not:A-Brand";v="24", "Chromium";v="134"`)
req.Header.Set("Sec-CH-UA-Mobile", "?0")
req.Header.Set("Sec-CH-UA-Platform", `"macOS"`)
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "none")
req.Header.Set("Sec-Fetch-User", "?1")
req.Header.Set("Upgrade-Insecure-Requests", "1")
}
if o.Accept != "" {
req.Header.Set("Accept", o.Accept)
} else if o.ChromeHeaders {
// Chrome 默认 HTML Accept。
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
}
}