mirror of
https://github.com/skyle1995/NetworkAuth.git
synced 2026-05-25 02:24:05 +08:00
528 lines
15 KiB
Go
528 lines
15 KiB
Go
package request
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/flate"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/andybalholm/brotli"
|
|
"github.com/go-resty/resty/v2"
|
|
req "github.com/imroc/req/v3"
|
|
"github.com/skycheung803/go-bypasser"
|
|
)
|
|
|
|
type RestyClient struct {
|
|
client *resty.Client
|
|
reqClient *req.Client
|
|
ctx context.Context
|
|
baseURL string
|
|
defaultHeaders map[string]string
|
|
proxyStr string
|
|
timeout time.Duration
|
|
}
|
|
|
|
func (request *RestyClient) Resty() *resty.Client {
|
|
return request.client
|
|
}
|
|
|
|
// NewClient 创建一个基于 go-bypasser(req/v3) 的客户端。
|
|
// 对外继续保留 Resty 风格接口,但底层请求不再走 resty.Transport。
|
|
func NewClient(baseURL string, proxyStr string, persistCookies bool, timeout int) *RestyClient {
|
|
if timeout <= 0 {
|
|
timeout = 60
|
|
}
|
|
timeoutDuration := time.Duration(timeout) * time.Second
|
|
|
|
defaultHeaders := map[string]string{
|
|
"accept": "*/*",
|
|
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
|
"connection": "keep-alive",
|
|
"pragma": "no-cache",
|
|
"priority": "u=1,i",
|
|
"sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"",
|
|
"sec-ch-ua-mobile": "?0",
|
|
"sec-ch-ua-platform": "\"macOS\"",
|
|
"sec-fetch-dest": "empty",
|
|
"sec-fetch-mode": "cors",
|
|
"sec-fetch-site": "same-origin",
|
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
|
}
|
|
|
|
stateClient := resty.New().
|
|
SetTimeout(timeoutDuration).
|
|
SetHeaders(defaultHeaders)
|
|
if baseURL != "" {
|
|
stateClient.SetBaseURL(baseURL)
|
|
}
|
|
|
|
var sharedJar http.CookieJar
|
|
if persistCookies {
|
|
jar, _ := cookiejar.New(nil)
|
|
sharedJar = jar
|
|
stateClient.SetCookieJar(sharedJar)
|
|
}
|
|
|
|
baseReqClient := mustNewReqClient(proxyStr, timeoutDuration, baseURL, defaultHeaders, sharedJar)
|
|
|
|
return &RestyClient{
|
|
client: stateClient,
|
|
reqClient: baseReqClient,
|
|
ctx: context.Background(),
|
|
baseURL: baseURL,
|
|
defaultHeaders: defaultHeaders,
|
|
proxyStr: proxyStr,
|
|
timeout: timeoutDuration,
|
|
}
|
|
}
|
|
|
|
func (request *RestyClient) WithContext(ctx context.Context) *RestyClient {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
return &RestyClient{
|
|
client: request.client,
|
|
reqClient: request.reqClient,
|
|
ctx: ctx,
|
|
baseURL: request.baseURL,
|
|
defaultHeaders: request.defaultHeaders,
|
|
proxyStr: request.proxyStr,
|
|
timeout: request.timeout,
|
|
}
|
|
}
|
|
|
|
// SetPersistentHeader 设置持久化 Header。
|
|
// 除 Cookie 外,其余 Header 会同步到 req 客户端的 common headers。
|
|
func (request *RestyClient) SetPersistentHeader(key string, value string) {
|
|
if request.defaultHeaders == nil {
|
|
request.defaultHeaders = make(map[string]string)
|
|
}
|
|
lowerKey := strings.ToLower(key)
|
|
request.defaultHeaders[lowerKey] = value
|
|
if request.client != nil {
|
|
request.client.SetHeader(key, value)
|
|
}
|
|
if request.reqClient != nil && lowerKey != "cookie" {
|
|
request.reqClient.SetCommonHeader(key, value)
|
|
}
|
|
}
|
|
|
|
func mustNewReqClient(proxyStr string, timeout time.Duration, baseURL string, defaultHeaders map[string]string, jar http.CookieJar) *req.Client {
|
|
opts := []bypasser.BypasserOption{
|
|
bypasser.WithInsecureSkipVerify(true),
|
|
}
|
|
if proxyStr != "" {
|
|
opts = append(opts, bypasser.WithProxy(proxyStr))
|
|
}
|
|
|
|
bypass, err := bypasser.NewBypasser(opts...)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
rt, ok := bypass.Transport.(*bypasser.StandardRoundTripper)
|
|
if !ok || rt.Client == nil {
|
|
panic("go-bypasser did not return a StandardRoundTripper client")
|
|
}
|
|
|
|
client := rt.Client
|
|
client.SetTimeout(timeout)
|
|
client.SetRedirectPolicy(req.DefaultRedirectPolicy())
|
|
if baseURL != "" {
|
|
client.SetBaseURL(baseURL)
|
|
}
|
|
for k, v := range defaultHeaders {
|
|
if strings.ToLower(k) == "cookie" {
|
|
continue
|
|
}
|
|
client.SetCommonHeader(k, v)
|
|
}
|
|
if jar != nil {
|
|
client.SetCookieJar(jar)
|
|
}
|
|
return client
|
|
}
|
|
|
|
func (request *RestyClient) newRequestClient(redirectCount int) *req.Client {
|
|
client := request.reqClient.Clone()
|
|
if request.baseURL != "" {
|
|
client.SetBaseURL(request.baseURL)
|
|
}
|
|
client.SetTimeout(request.timeout)
|
|
|
|
switch {
|
|
case redirectCount == 0:
|
|
client.SetRedirectPolicy(req.NoRedirectPolicy())
|
|
case redirectCount > 0:
|
|
client.SetRedirectPolicy(req.MaxRedirectPolicy(redirectCount))
|
|
default:
|
|
client.SetRedirectPolicy(req.DefaultRedirectPolicy())
|
|
}
|
|
|
|
return client
|
|
}
|
|
|
|
func (request *RestyClient) resolveRequestURL(path string) *url.URL {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
|
|
parsedURL, err := url.Parse(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if parsedURL.IsAbs() {
|
|
return parsedURL
|
|
}
|
|
if request.baseURL == "" {
|
|
return parsedURL
|
|
}
|
|
|
|
baseURL, err := url.Parse(request.baseURL)
|
|
if err != nil {
|
|
return parsedURL
|
|
}
|
|
return baseURL.ResolveReference(parsedURL)
|
|
}
|
|
|
|
func cloneCookie(cookie *http.Cookie) *http.Cookie {
|
|
if cookie == nil {
|
|
return nil
|
|
}
|
|
copied := *cookie
|
|
return &copied
|
|
}
|
|
|
|
func isSafeHTTPCookieValue(value string) bool {
|
|
if value == "" {
|
|
return true
|
|
}
|
|
for _, r := range value {
|
|
if r < 0x21 || r > 0x7e {
|
|
return false
|
|
}
|
|
switch r {
|
|
case '"', ';', '\\', ',':
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseRawCookieHeader(raw string) []*http.Cookie {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
var cookies []*http.Cookie
|
|
for _, part := range strings.Split(raw, ";") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
name, value, ok := strings.Cut(part, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
value = strings.TrimSpace(value)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
cookies = append(cookies, &http.Cookie{Name: name, Value: value})
|
|
}
|
|
return cookies
|
|
}
|
|
|
|
func buildCookieHeader(cookies []*http.Cookie) string {
|
|
if len(cookies) == 0 {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(cookies))
|
|
for _, cookie := range cookies {
|
|
if cookie == nil || cookie.Name == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s=%s", cookie.Name, cookie.Value))
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
// decodeCompressedBody 按响应头解压正文,兼容项目里依赖明文 HTML/JSON 的解析逻辑。
|
|
func decodeCompressedBody(body []byte, contentEncoding string) ([]byte, error) {
|
|
encoding := strings.ToLower(strings.TrimSpace(contentEncoding))
|
|
switch {
|
|
case encoding == "", encoding == "identity":
|
|
return body, nil
|
|
case strings.Contains(encoding, "gzip"):
|
|
reader, err := gzip.NewReader(bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer reader.Close()
|
|
return io.ReadAll(reader)
|
|
case strings.Contains(encoding, "deflate"):
|
|
reader := flate.NewReader(bytes.NewReader(body))
|
|
defer reader.Close()
|
|
return io.ReadAll(reader)
|
|
case strings.Contains(encoding, "br"):
|
|
reader := brotli.NewReader(bytes.NewReader(body))
|
|
return io.ReadAll(reader)
|
|
default:
|
|
return body, nil
|
|
}
|
|
}
|
|
|
|
func (request *RestyClient) prepareCookies(path string, requestCookies []*http.Cookie) ([]*http.Cookie, string) {
|
|
cookieMap := make(map[string]*http.Cookie)
|
|
order := make([]string, 0)
|
|
rawCookieNames := make(map[string]struct{})
|
|
|
|
appendCookie := func(cookie *http.Cookie) {
|
|
if cookie == nil || cookie.Name == "" {
|
|
return
|
|
}
|
|
if _, exists := cookieMap[cookie.Name]; !exists {
|
|
order = append(order, cookie.Name)
|
|
}
|
|
cloned := cloneCookie(cookie)
|
|
cookieMap[cookie.Name] = cloned
|
|
if cloned != nil && !isSafeHTTPCookieValue(cloned.Value) {
|
|
rawCookieNames[cloned.Name] = struct{}{}
|
|
}
|
|
}
|
|
|
|
parsedURL := request.resolveRequestURL(path)
|
|
if request.client != nil && request.client.GetClient() != nil && request.client.GetClient().Jar != nil && parsedURL != nil {
|
|
for _, cookie := range request.client.GetClient().Jar.Cookies(parsedURL) {
|
|
appendCookie(cookie)
|
|
}
|
|
}
|
|
if request.client != nil {
|
|
for _, cookie := range request.client.Cookies {
|
|
appendCookie(cookie)
|
|
}
|
|
}
|
|
for _, cookie := range requestCookies {
|
|
appendCookie(cookie)
|
|
}
|
|
|
|
rawCookies := parseRawCookieHeader(request.defaultHeaders["cookie"])
|
|
for _, cookie := range rawCookies {
|
|
if cookie == nil || cookie.Name == "" {
|
|
continue
|
|
}
|
|
rawCookieNames[cookie.Name] = struct{}{}
|
|
if _, exists := cookieMap[cookie.Name]; !exists {
|
|
order = append(order, cookie.Name)
|
|
}
|
|
cookieMap[cookie.Name] = cookie
|
|
}
|
|
|
|
mergedCookies := make([]*http.Cookie, 0, len(order))
|
|
for _, name := range order {
|
|
if cookie := cookieMap[name]; cookie != nil {
|
|
mergedCookies = append(mergedCookies, cloneCookie(cookie))
|
|
}
|
|
}
|
|
|
|
if len(rawCookieNames) == 0 {
|
|
return mergedCookies, ""
|
|
}
|
|
return mergedCookies, buildCookieHeader(mergedCookies)
|
|
}
|
|
|
|
func (request *RestyClient) buildReqRequest(client *req.Client, path string, headers map[string]string, cookies []*http.Cookie) *req.Request {
|
|
r := client.R().SetContext(request.ctx)
|
|
if len(headers) > 0 {
|
|
r.SetHeaders(headers)
|
|
}
|
|
|
|
mergedCookies, rawCookieHeader := request.prepareCookies(path, cookies)
|
|
if rawCookieHeader != "" {
|
|
r.SetHeader("Cookie", rawCookieHeader)
|
|
} else if len(mergedCookies) > 0 {
|
|
r.SetCookies(mergedCookies...)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (request *RestyClient) adaptReqResponse(path string, method string, data any, headers map[string]string, cookies []*http.Cookie, resp *req.Response) (*resty.Response, error) {
|
|
if resp == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
body, err := resp.ToBytes()
|
|
if err != nil && resp.Response == nil {
|
|
return nil, err
|
|
}
|
|
|
|
rawResponse := resp.Response
|
|
if rawResponse != nil {
|
|
decodedBody, decodeErr := decodeCompressedBody(body, rawResponse.Header.Get("Content-Encoding"))
|
|
if decodeErr != nil {
|
|
return nil, decodeErr
|
|
}
|
|
body = decodedBody
|
|
rawResponse.Body = io.NopCloser(bytes.NewReader(body))
|
|
rawResponse.Header.Del("Content-Encoding")
|
|
rawResponse.ContentLength = int64(len(body))
|
|
}
|
|
|
|
restyReq := request.client.R()
|
|
restyReq.Method = method
|
|
restyReq.URL = path
|
|
restyReq.Body = data
|
|
restyReq.Header = make(http.Header)
|
|
for k, v := range headers {
|
|
restyReq.Header.Set(k, v)
|
|
}
|
|
mergedCookies, rawCookieHeader := request.prepareCookies(path, cookies)
|
|
if rawCookieHeader != "" {
|
|
restyReq.Header.Set("Cookie", rawCookieHeader)
|
|
} else {
|
|
restyReq.Cookies = mergedCookies
|
|
}
|
|
|
|
restyResp := &resty.Response{
|
|
Request: restyReq,
|
|
RawResponse: rawResponse,
|
|
}
|
|
restyResp.SetBody(body)
|
|
return restyResp, err
|
|
}
|
|
|
|
func (request *RestyClient) decodeResponse(resp *resty.Response, result any) error {
|
|
if resp == nil || result == nil {
|
|
return nil
|
|
}
|
|
body := resp.Body()
|
|
if len(body) == 0 {
|
|
return nil
|
|
}
|
|
ct := strings.ToLower(resp.Header().Get("Content-Type"))
|
|
if strings.Contains(ct, "application/json") || json.Valid(body) {
|
|
if err := json.Unmarshal(body, result); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (request *RestyClient) execute(method string, path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
|
|
client := request.newRequestClient(redirectCount)
|
|
|
|
doRequest := func(extraHeaders map[string]string) (*resty.Response, error) {
|
|
r := request.buildReqRequest(client, path, extraHeaders, cookies)
|
|
if data != nil {
|
|
r.SetBody(data)
|
|
}
|
|
|
|
var (
|
|
resp *req.Response
|
|
err error
|
|
)
|
|
switch method {
|
|
case http.MethodGet:
|
|
resp, err = r.Get(path)
|
|
case http.MethodPost:
|
|
resp, err = r.Post(path)
|
|
case http.MethodPut:
|
|
resp, err = r.Put(path)
|
|
case http.MethodPatch:
|
|
resp, err = r.Patch(path)
|
|
case http.MethodDelete:
|
|
resp, err = r.Delete(path)
|
|
case http.MethodHead:
|
|
resp, err = r.Head(path)
|
|
case http.MethodOptions:
|
|
resp, err = r.Options(path)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported method: %s", method)
|
|
}
|
|
|
|
restyResp, adaptErr := request.adaptReqResponse(path, method, data, extraHeaders, cookies, resp)
|
|
if err != nil && errors.Is(err, http.ErrUseLastResponse) {
|
|
err = nil
|
|
}
|
|
if adaptErr != nil && err == nil {
|
|
err = adaptErr
|
|
}
|
|
return restyResp, err
|
|
}
|
|
|
|
resp, err := doRequest(headers)
|
|
if err == nil && resp != nil && strings.Contains(strings.ToLower(resp.Header().Get("Content-Encoding")), "zstd") {
|
|
err = fmt.Errorf("zstd body requires identity fallback")
|
|
}
|
|
if err == nil {
|
|
if decodeErr := request.decodeResponse(resp, result); decodeErr != nil {
|
|
return nil, decodeErr
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "gzip") || strings.Contains(errStr, "magic number mismatch") || strings.Contains(errStr, "zstd") || strings.Contains(errStr, "brotli") || strings.Contains(errStr, "flate") {
|
|
h2 := map[string]string{}
|
|
for k, v := range headers {
|
|
if strings.ToLower(k) != "accept-encoding" {
|
|
h2[k] = v
|
|
}
|
|
}
|
|
h2["Accept-Encoding"] = "identity"
|
|
|
|
resp2, err2 := doRequest(h2)
|
|
if err2 == nil {
|
|
if decodeErr := request.decodeResponse(resp2, result); decodeErr != nil {
|
|
return nil, decodeErr
|
|
}
|
|
return resp2, nil
|
|
}
|
|
if resp2 != nil {
|
|
return resp2, err2
|
|
}
|
|
}
|
|
|
|
if resp != nil {
|
|
return resp, err
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
func (request *RestyClient) RestyGet(path string, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
|
|
return request.execute(http.MethodGet, path, nil, result, headers, cookies, redirectCount)
|
|
}
|
|
|
|
func (request *RestyClient) RestyPost(path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
|
|
return request.execute(http.MethodPost, path, data, result, headers, cookies, redirectCount)
|
|
}
|
|
|
|
func (request *RestyClient) RestyPut(path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
|
|
return request.execute(http.MethodPut, path, data, result, headers, cookies, redirectCount)
|
|
}
|
|
|
|
func (request *RestyClient) RestyPatch(path string, data any, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
|
|
return request.execute(http.MethodPatch, path, data, result, headers, cookies, redirectCount)
|
|
}
|
|
|
|
func (request *RestyClient) RestyDelete(path string, result any, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
|
|
return request.execute(http.MethodDelete, path, nil, result, headers, cookies, redirectCount)
|
|
}
|
|
|
|
func (request *RestyClient) RestyHead(path string, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
|
|
return request.execute(http.MethodHead, path, nil, nil, headers, cookies, redirectCount)
|
|
}
|
|
|
|
func (request *RestyClient) RestyOptions(path string, headers map[string]string, cookies []*http.Cookie, redirectCount int) (*resty.Response, error) {
|
|
return request.execute(http.MethodOptions, path, nil, nil, headers, cookies, redirectCount)
|
|
}
|