mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 15:49:49 +00:00
347 lines
8.7 KiB
Go
347 lines
8.7 KiB
Go
package httpclient
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/imroc/req/v3"
|
|
)
|
|
|
|
type ImpersonationMode string
|
|
|
|
const (
|
|
ImpersonationNative ImpersonationMode = "native"
|
|
ImpersonationFixed ImpersonationMode = "fixed"
|
|
ImpersonationAuto ImpersonationMode = "auto"
|
|
)
|
|
|
|
type Browser string
|
|
|
|
const (
|
|
BrowserChrome Browser = "chrome"
|
|
BrowserFirefox Browser = "firefox"
|
|
BrowserSafari Browser = "safari"
|
|
)
|
|
|
|
type Options struct {
|
|
Client ClientOptions
|
|
Transport TransportOptions
|
|
Impersonation ImpersonationOptions
|
|
}
|
|
|
|
type ClientOptions struct {
|
|
Jar http.CookieJar
|
|
CheckRedirect func(req *http.Request, via []*http.Request) error
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type TransportOptions struct {
|
|
Proxy func(*http.Request) (*url.URL, error)
|
|
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
|
TLSClientConfig *tls.Config
|
|
TLSHandshakeTimeout time.Duration
|
|
}
|
|
|
|
type ImpersonationOptions struct {
|
|
Mode ImpersonationMode
|
|
Browser Browser
|
|
Session *ImpersonationSession
|
|
}
|
|
|
|
type ImpersonationSession struct {
|
|
mu sync.RWMutex
|
|
selections map[string]impersonationSelection
|
|
}
|
|
|
|
type impersonationSelection struct {
|
|
browser Browser
|
|
fallbackDisabled bool
|
|
}
|
|
|
|
func NewImpersonationSession() *ImpersonationSession {
|
|
return &ImpersonationSession{
|
|
selections: make(map[string]impersonationSelection),
|
|
}
|
|
}
|
|
|
|
func (s *ImpersonationSession) load(url string) (impersonationSelection, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
selection, ok := s.selections[url]
|
|
return selection, ok
|
|
}
|
|
|
|
func (s *ImpersonationSession) store(url string, browser Browser) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.selections[url] = impersonationSelection{browser: browser}
|
|
}
|
|
|
|
func (s *ImpersonationSession) disableFallback(url string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.selections[url] = impersonationSelection{fallbackDisabled: true}
|
|
}
|
|
|
|
func (s *ImpersonationSession) Clear() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
clear(s.selections)
|
|
}
|
|
|
|
func NewClient(options Options) (*http.Client, error) {
|
|
var transport http.RoundTripper
|
|
switch options.Impersonation.Mode {
|
|
case "", ImpersonationNative:
|
|
transport = newNativeTransport(options.Transport)
|
|
case ImpersonationFixed:
|
|
browserTransport, err := newBrowserTransport(options.Transport, options.Impersonation.Browser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
transport = browserTransport
|
|
case ImpersonationAuto:
|
|
browserTransports := make(map[Browser]http.RoundTripper, 3)
|
|
for _, browser := range []Browser{BrowserChrome, BrowserFirefox, BrowserSafari} {
|
|
browserTransport, err := newBrowserTransport(options.Transport, browser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
browserTransports[browser] = browserTransport
|
|
}
|
|
transport = &adaptiveTransport{
|
|
native: newNativeTransport(options.Transport),
|
|
browsers: browserTransports,
|
|
jar: options.Client.Jar,
|
|
session: options.Impersonation.Session,
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported impersonation mode %q", options.Impersonation.Mode)
|
|
}
|
|
|
|
return &http.Client{
|
|
Transport: transport,
|
|
Jar: options.Client.Jar,
|
|
CheckRedirect: options.Client.CheckRedirect,
|
|
Timeout: options.Client.Timeout,
|
|
}, nil
|
|
}
|
|
|
|
func newNativeTransport(options TransportOptions) *http.Transport {
|
|
var tlsConfig *tls.Config
|
|
if options.TLSClientConfig != nil {
|
|
tlsConfig = options.TLSClientConfig.Clone()
|
|
}
|
|
return &http.Transport{
|
|
Proxy: options.Proxy,
|
|
DialContext: options.DialContext,
|
|
TLSClientConfig: tlsConfig,
|
|
TLSHandshakeTimeout: options.TLSHandshakeTimeout,
|
|
ForceAttemptHTTP2: true,
|
|
}
|
|
}
|
|
|
|
func newBrowserTransport(options TransportOptions, browser Browser) (http.RoundTripper, error) {
|
|
client := req.NewClient()
|
|
client.SetProxy(options.Proxy)
|
|
if options.DialContext != nil {
|
|
client.SetDial(options.DialContext)
|
|
}
|
|
client.SetTLSHandshakeTimeout(options.TLSHandshakeTimeout)
|
|
if options.TLSClientConfig != nil {
|
|
tlsConfig := options.TLSClientConfig.Clone()
|
|
if len(tlsConfig.NextProtos) == 0 {
|
|
tlsConfig.NextProtos = []string{"h2", "http/1.1"}
|
|
}
|
|
client.SetTLSClientConfig(tlsConfig)
|
|
}
|
|
|
|
switch browser {
|
|
case BrowserChrome:
|
|
client.ImpersonateChrome()
|
|
case BrowserFirefox:
|
|
client.ImpersonateFirefox()
|
|
case BrowserSafari:
|
|
client.ImpersonateSafari()
|
|
default:
|
|
return nil, fmt.Errorf("unsupported browser %q", browser)
|
|
}
|
|
|
|
return &defaultHeaderTransport{
|
|
next: client.GetClient().Transport,
|
|
defaults: client.Headers.Clone(),
|
|
}, nil
|
|
}
|
|
|
|
type defaultHeaderTransport struct {
|
|
next http.RoundTripper
|
|
defaults http.Header
|
|
}
|
|
|
|
func (t *defaultHeaderTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
request = request.Clone(request.Context())
|
|
request.Header = request.Header.Clone()
|
|
for key, values := range t.defaults {
|
|
if len(request.Header.Values(key)) == 0 {
|
|
request.Header[key] = append([]string(nil), values...)
|
|
}
|
|
}
|
|
return t.next.RoundTrip(request)
|
|
}
|
|
|
|
func (t *defaultHeaderTransport) CloseIdleConnections() {
|
|
closeIdleConnections(t.next)
|
|
}
|
|
|
|
type adaptiveTransport struct {
|
|
native http.RoundTripper
|
|
browsers map[Browser]http.RoundTripper
|
|
jar http.CookieJar
|
|
session *ImpersonationSession
|
|
}
|
|
|
|
func (t *adaptiveTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
cacheKey := requestCacheKey(request.URL)
|
|
if t.session != nil {
|
|
if selected, ok := t.session.load(cacheKey); ok {
|
|
if selected.fallbackDisabled {
|
|
return t.native.RoundTrip(request)
|
|
}
|
|
return t.browsers[selected.browser].RoundTrip(request)
|
|
}
|
|
}
|
|
|
|
response, err := t.native.RoundTrip(request)
|
|
if !shouldFallback(response, err) {
|
|
return response, err
|
|
}
|
|
|
|
retry, ok := cloneRequestForRetry(request)
|
|
if !ok {
|
|
return response, err
|
|
}
|
|
if response != nil {
|
|
applyResponseCookies(t.jar, request, retry, response)
|
|
_ = response.Body.Close()
|
|
}
|
|
|
|
browser := browserFromUserAgent(request.UserAgent())
|
|
response, err = t.browsers[browser].RoundTrip(retry)
|
|
if t.session != nil {
|
|
if isSuccessfulFallback(response, err) {
|
|
t.session.store(cacheKey, browser)
|
|
} else if err == nil && response != nil {
|
|
t.session.disableFallback(cacheKey)
|
|
}
|
|
}
|
|
return response, err
|
|
}
|
|
|
|
func requestCacheKey(requestURL *url.URL) string {
|
|
cacheURL := *requestURL
|
|
cacheURL.Fragment = ""
|
|
cacheURL.RawFragment = ""
|
|
return cacheURL.String()
|
|
}
|
|
|
|
func applyResponseCookies(jar http.CookieJar, request, retry *http.Request, response *http.Response) {
|
|
if jar == nil {
|
|
return
|
|
}
|
|
jar.SetCookies(request.URL, response.Cookies())
|
|
jarCookies := jar.Cookies(request.URL)
|
|
jarCookieNames := make(map[string]struct{}, len(jarCookies))
|
|
for _, cookie := range jarCookies {
|
|
jarCookieNames[cookie.Name] = struct{}{}
|
|
}
|
|
existingCookies := retry.Cookies()
|
|
retry.Header.Del("Cookie")
|
|
for _, cookie := range existingCookies {
|
|
if _, replaced := jarCookieNames[cookie.Name]; !replaced {
|
|
retry.AddCookie(cookie)
|
|
}
|
|
}
|
|
for _, cookie := range jarCookies {
|
|
retry.AddCookie(cookie)
|
|
}
|
|
}
|
|
|
|
func (t *adaptiveTransport) CloseIdleConnections() {
|
|
closeIdleConnections(t.native)
|
|
for _, transport := range t.browsers {
|
|
closeIdleConnections(transport)
|
|
}
|
|
}
|
|
|
|
func closeIdleConnections(transport http.RoundTripper) {
|
|
if closer, ok := transport.(interface{ CloseIdleConnections() }); ok {
|
|
closer.CloseIdleConnections()
|
|
}
|
|
}
|
|
|
|
func shouldFallback(response *http.Response, err error) bool {
|
|
if err != nil {
|
|
return true
|
|
}
|
|
if response == nil {
|
|
return false
|
|
}
|
|
return response.StatusCode == http.StatusForbidden ||
|
|
strings.EqualFold(response.Header.Get("Cf-Mitigated"), "challenge")
|
|
}
|
|
|
|
func isSuccessfulFallback(response *http.Response, err error) bool {
|
|
return err == nil && response != nil &&
|
|
response.StatusCode >= http.StatusOK &&
|
|
response.StatusCode < http.StatusBadRequest
|
|
}
|
|
|
|
func cloneRequestForRetry(request *http.Request) (*http.Request, bool) {
|
|
if !isIdempotent(request) {
|
|
return nil, false
|
|
}
|
|
retry := request.Clone(request.Context())
|
|
if request.Body == nil || request.Body == http.NoBody {
|
|
return retry, true
|
|
}
|
|
if request.GetBody == nil {
|
|
return nil, false
|
|
}
|
|
body, err := request.GetBody()
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
retry.Body = body
|
|
return retry, true
|
|
}
|
|
|
|
func isIdempotent(request *http.Request) bool {
|
|
switch request.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
|
return true
|
|
}
|
|
return request.Header.Get("Idempotency-Key") != "" ||
|
|
request.Header.Get("X-Idempotency-Key") != ""
|
|
}
|
|
|
|
func browserFromUserAgent(userAgent string) Browser {
|
|
userAgent = strings.ToLower(userAgent)
|
|
switch {
|
|
case strings.Contains(userAgent, "firefox"):
|
|
return BrowserFirefox
|
|
case strings.Contains(userAgent, "safari") &&
|
|
!strings.Contains(userAgent, "chrome") &&
|
|
!strings.Contains(userAgent, "chromium"):
|
|
return BrowserSafari
|
|
default:
|
|
return BrowserChrome
|
|
}
|
|
}
|