由 Codex (gpt-5.6-sol) 实施,Claude 审核。 internal/huohanhan/auth.go - 六步登录:取 CID/CST 作 Basic 认证 → getCltConf 取 clientId → 下载验证码 → OCR 识别 → 提交登录 → app-version/info 在线验证 - OCR 响应递归查找 4~8 位验证码,优先 text/result/data/content/captcha/code - 错误分支:invalid_verify_code 换图重试(上限可配,默认 3); invalid_credentials 与 disabled_credentials 直接报中文错误不重试 - 认证复用改用 SQLite kv 表 + sync.Mutex,不照搬 Python 版的 Redis - GetValidAuth 按「内存 → SQLite 在线验证 → 重新登录」顺序取认证 internal/huohanhan/client.go - 统一请求方法,自动带 Authorization 与 cookies - 认证失败时清状态、重登、最多重试一次;超时、500、普通业务错误不重试 app.go - 新增 TestHuohanhanLogin,供设置页「测试连接」调用 测试全部使用 httptest 假服务,不需要真实账号即可运行。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
656 lines
19 KiB
Go
656 lines
19 KiB
Go
// Package huohanhan 提供货憨憨 ERP 的登录和统一 HTTP 客户端。
|
||
//
|
||
// 设计约定(改代码前请先读):
|
||
//
|
||
// - 账号和密码只从 config 传入,token 与 cookies 只保存在 SQLite kv 表;
|
||
// 不要增加 JSON 状态文件,也不要把任何凭据写进日志。
|
||
// - 登录验证码可以更换后重试;账号密码错误、账号禁用和其它登录错误
|
||
// 都不能重试,避免无意义请求触发服务端风控。
|
||
// - AuthManager 只用进程内 mutex 串行登录。本项目是单机 GUI,不能把
|
||
// Python 参考实现里的 Redis 和分布式锁搬进来。
|
||
package huohanhan
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/textproto"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"cmsp/internal/config"
|
||
"cmsp/internal/logx"
|
||
"cmsp/internal/store"
|
||
)
|
||
|
||
const (
|
||
authStateKey = "huohanhan.auth"
|
||
defaultRequestTimeout = 30 * time.Second
|
||
defaultCaptchaAttempts = 3
|
||
defaultAuthExpirySkew = 5 * time.Minute
|
||
maximumResponseBodyBytes = 8 << 20
|
||
)
|
||
|
||
var (
|
||
cidPattern = regexp.MustCompile(`["']?CID["']?\s*:\s*["']([^"']+)["']`)
|
||
cstPattern = regexp.MustCompile(`["']?CST["']?\s*:\s*["']([^"']+)["']`)
|
||
captchaPattern = regexp.MustCompile(`^[A-Za-z0-9]{4,8}$`)
|
||
)
|
||
|
||
// AuthOptions 是登录流程中需要调整的运行参数。
|
||
//
|
||
// 零值会使用安全默认值。HTTPClient 和 Now 主要供离线测试注入;
|
||
// MaxCaptchaAttempts 让上层配置接入后无需修改登录逻辑。
|
||
type AuthOptions struct {
|
||
HTTPClient *http.Client
|
||
RequestTimeout time.Duration
|
||
MaxCaptchaAttempts int
|
||
ExpirySkew time.Duration
|
||
Now func() time.Time
|
||
}
|
||
|
||
// AuthState 是一次登录后需要复用的完整认证状态。
|
||
//
|
||
// 该结构会序列化到 SQLite,字段可能含敏感内容,禁止整体写入日志。
|
||
type AuthState struct {
|
||
AccessToken string `json:"access_token"`
|
||
TokenType string `json:"token_type"`
|
||
ExpiresIn int64 `json:"expires_in"`
|
||
LoginTime int64 `json:"login_time"`
|
||
Cookies map[string]string `json:"cookies"`
|
||
}
|
||
|
||
// AuthorizationValue 返回业务请求使用的 Authorization 值。
|
||
func (s AuthState) AuthorizationValue() string {
|
||
tokenType := strings.TrimSpace(s.TokenType)
|
||
if tokenType == "" {
|
||
tokenType = "Bearer"
|
||
}
|
||
return tokenType + " " + s.AccessToken
|
||
}
|
||
|
||
// Expired 判断 token 是否已经过期或进入安全提前量。
|
||
func (s AuthState) Expired(now time.Time, skew time.Duration) bool {
|
||
if s.ExpiresIn <= 0 || s.LoginTime <= 0 {
|
||
return true
|
||
}
|
||
expiresAt := time.UnixMilli(s.LoginTime).Add(time.Duration(s.ExpiresIn) * time.Second)
|
||
return !now.Add(skew).Before(expiresAt)
|
||
}
|
||
|
||
// AuthManager 串行管理内存与 SQLite 中的认证状态。
|
||
type AuthManager struct {
|
||
cfg config.HuohanhanConfig
|
||
db *store.Store
|
||
log *logx.Logger
|
||
httpClient *http.Client
|
||
attempts int
|
||
expirySkew time.Duration
|
||
now func() time.Time
|
||
|
||
mu sync.Mutex
|
||
current *AuthState
|
||
}
|
||
|
||
// NewAuthManager 创建认证管理器。opts 的零值会补成默认配置。
|
||
func NewAuthManager(cfg config.HuohanhanConfig, db *store.Store, logger *logx.Logger, opts AuthOptions) *AuthManager {
|
||
timeout := opts.RequestTimeout
|
||
if timeout <= 0 {
|
||
timeout = defaultRequestTimeout
|
||
}
|
||
client := opts.HTTPClient
|
||
if client == nil {
|
||
client = &http.Client{Timeout: timeout}
|
||
}
|
||
attempts := opts.MaxCaptchaAttempts
|
||
if attempts <= 0 {
|
||
attempts = defaultCaptchaAttempts
|
||
}
|
||
skew := opts.ExpirySkew
|
||
if skew <= 0 {
|
||
skew = defaultAuthExpirySkew
|
||
}
|
||
now := opts.Now
|
||
if now == nil {
|
||
now = time.Now
|
||
}
|
||
if logger == nil {
|
||
logger = logx.New(1000)
|
||
}
|
||
return &AuthManager{
|
||
cfg: cfg,
|
||
db: db,
|
||
log: logger,
|
||
httpClient: client,
|
||
attempts: attempts,
|
||
expirySkew: skew,
|
||
now: now,
|
||
}
|
||
}
|
||
|
||
// GetValidAuth 按“内存、SQLite、重新登录”的顺序取得有效认证。
|
||
//
|
||
// SQLite 中的状态在进程首次使用时必须在线验证,不能只相信本地时间。
|
||
func (m *AuthManager) GetValidAuth(ctx context.Context) (AuthState, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
|
||
if m.current != nil && !m.current.Expired(m.now(), m.expirySkew) {
|
||
return cloneAuthState(*m.current), nil
|
||
}
|
||
|
||
stored, found, err := m.load()
|
||
if err != nil {
|
||
return AuthState{}, err
|
||
}
|
||
if found {
|
||
valid, validateErr := m.validate(ctx, stored)
|
||
if validateErr != nil {
|
||
return AuthState{}, validateErr
|
||
}
|
||
if valid {
|
||
m.current = &stored
|
||
m.log.Info("已复用本机保存的货憨憨认证状态")
|
||
return cloneAuthState(stored), nil
|
||
}
|
||
if err := m.clearLocked(); err != nil {
|
||
return AuthState{}, err
|
||
}
|
||
}
|
||
|
||
return m.loginAndSaveLocked(ctx)
|
||
}
|
||
|
||
// ForceLogin 忽略旧状态并重新登录,供测试连接和认证失败恢复使用。
|
||
func (m *AuthManager) ForceLogin(ctx context.Context) (AuthState, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
// 显式测试连接或服务端已拒绝认证时,不能让失败的重新登录继续留下旧 token。
|
||
if err := m.clearLocked(); err != nil {
|
||
return AuthState{}, err
|
||
}
|
||
return m.loginAndSaveLocked(ctx)
|
||
}
|
||
|
||
// Invalidate 清除内存与 SQLite 中已被服务端拒绝的认证状态。
|
||
func (m *AuthManager) Invalidate() error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
return m.clearLocked()
|
||
}
|
||
|
||
func (m *AuthManager) loginAndSaveLocked(ctx context.Context) (AuthState, error) {
|
||
state, err := m.login(ctx)
|
||
if err != nil {
|
||
return AuthState{}, err
|
||
}
|
||
if err := m.save(state); err != nil {
|
||
return AuthState{}, err
|
||
}
|
||
m.current = &state
|
||
m.log.Success("货憨憨登录成功")
|
||
return cloneAuthState(state), nil
|
||
}
|
||
|
||
func (m *AuthManager) login(ctx context.Context) (AuthState, error) {
|
||
if m.db == nil {
|
||
return AuthState{}, fmt.Errorf("本地数据库未就绪")
|
||
}
|
||
baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(m.cfg.BaseURL), "/"))
|
||
if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" {
|
||
return AuthState{}, fmt.Errorf("货憨憨网址不正确")
|
||
}
|
||
if strings.TrimSpace(m.cfg.Account) == "" || m.cfg.Password == "" {
|
||
return AuthState{}, fmt.Errorf("请先填写货憨憨账号和密码")
|
||
}
|
||
if strings.TrimSpace(m.cfg.OCRURL) == "" {
|
||
return AuthState{}, fmt.Errorf("OCR 识别服务地址不能为空")
|
||
}
|
||
|
||
jar, err := cookiejar.New(nil)
|
||
if err != nil {
|
||
return AuthState{}, fmt.Errorf("创建登录会话失败:%w", err)
|
||
}
|
||
session := *m.httpClient
|
||
session.Jar = jar
|
||
|
||
cid, cst, clientID, err := m.loadLoginParameters(ctx, &session, baseURL)
|
||
if err != nil {
|
||
return AuthState{}, err
|
||
}
|
||
|
||
for attempt := 1; attempt <= m.attempts; attempt++ {
|
||
m.log.Info("正在识别货憨憨登录验证码,第 %d/%d 次", attempt, m.attempts)
|
||
captchaKey, image, err := m.downloadCaptcha(ctx, &session, baseURL)
|
||
if err != nil {
|
||
return AuthState{}, err
|
||
}
|
||
captchaCode, err := m.recognizeCaptcha(ctx, image)
|
||
if err != nil {
|
||
return AuthState{}, err
|
||
}
|
||
|
||
state, code, message, err := m.submitLogin(
|
||
ctx, &session, baseURL, cid, cst, clientID, captchaCode, captchaKey,
|
||
)
|
||
if err != nil {
|
||
return AuthState{}, err
|
||
}
|
||
if state.AccessToken != "" {
|
||
valid, validateErr := m.validateWithClient(ctx, &session, baseURL, state)
|
||
if validateErr != nil {
|
||
return AuthState{}, validateErr
|
||
}
|
||
if !valid {
|
||
return AuthState{}, fmt.Errorf("登录成功,但服务端未通过认证校验")
|
||
}
|
||
return state, nil
|
||
}
|
||
|
||
switch code {
|
||
case "invalid_verify_code":
|
||
m.log.Warn("验证码不正确,准备更换验证码")
|
||
continue
|
||
case "invalid_credentials":
|
||
return AuthState{}, fmt.Errorf("账号或密码错误")
|
||
case "disabled_credentials":
|
||
return AuthState{}, fmt.Errorf("账号已被禁用")
|
||
default:
|
||
if strings.TrimSpace(code) == "" {
|
||
code = "未知错误"
|
||
}
|
||
if strings.TrimSpace(message) == "" {
|
||
message = "登录失败"
|
||
}
|
||
return AuthState{}, fmt.Errorf("登录失败:%s,%s", code, message)
|
||
}
|
||
}
|
||
return AuthState{}, fmt.Errorf("连续多次验证码识别失败,请稍后重试")
|
||
}
|
||
|
||
func (m *AuthManager) loadLoginParameters(ctx context.Context, client *http.Client, baseURL *url.URL) (string, string, string, error) {
|
||
page, err := m.do(ctx, client, http.MethodGet, baseURL.String()+"/login", nil, "", "", "")
|
||
if err != nil {
|
||
return "", "", "", fmt.Errorf("读取货憨憨登录页失败:%w", err)
|
||
}
|
||
cid := findRuntimeValue(page, cidPattern)
|
||
if cid == "" {
|
||
return "", "", "", fmt.Errorf("登录页中没有找到 CID,网页可能已经改版")
|
||
}
|
||
cst := findRuntimeValue(page, cstPattern)
|
||
if cst == "" {
|
||
return "", "", "", fmt.Errorf("登录页中没有找到 CST,网页可能已经改版")
|
||
}
|
||
|
||
form := url.Values{"domain": {baseURL.Hostname()}}
|
||
body, err := m.do(ctx, client, http.MethodPost, apiURL(baseURL, "butler/client/getCltConf"),
|
||
[]byte(form.Encode()), "application/x-www-form-urlencoded", "", "")
|
||
if err != nil {
|
||
return "", "", "", fmt.Errorf("读取货憨憨网站配置失败:%w", err)
|
||
}
|
||
var payload struct {
|
||
ID json.RawMessage `json:"id"`
|
||
}
|
||
if err := json.Unmarshal(body, &payload); err != nil {
|
||
return "", "", "", fmt.Errorf("货憨憨网站配置返回的不是有效 JSON")
|
||
}
|
||
clientID := rawString(payload.ID)
|
||
if clientID == "" {
|
||
return "", "", "", fmt.Errorf("货憨憨网站配置中没有 clientId")
|
||
}
|
||
return cid, cst, clientID, nil
|
||
}
|
||
|
||
func (m *AuthManager) downloadCaptcha(ctx context.Context, client *http.Client, baseURL *url.URL) (string, []byte, error) {
|
||
key, err := newUUID()
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("生成验证码标识失败:%w", err)
|
||
}
|
||
endpoint := apiURL(baseURL, "butler/vrify/kaptcha") + "?" + url.Values{"kaptchaKey": {key}}.Encode()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("创建验证码请求失败:%w", err)
|
||
}
|
||
applyCommonHeaders(req)
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("下载验证码失败:%w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return "", nil, fmt.Errorf("下载验证码失败:HTTP %d", resp.StatusCode)
|
||
}
|
||
if !strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "image") {
|
||
return "", nil, fmt.Errorf("验证码接口没有返回图片")
|
||
}
|
||
image, err := readBody(resp.Body)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("读取验证码图片失败:%w", err)
|
||
}
|
||
return key, image, nil
|
||
}
|
||
|
||
func (m *AuthManager) recognizeCaptcha(ctx context.Context, image []byte) (string, error) {
|
||
var body bytes.Buffer
|
||
writer := multipart.NewWriter(&body)
|
||
header := make(textproto.MIMEHeader)
|
||
header.Set("Content-Disposition", `form-data; name="file"; filename="captcha.jpg"`)
|
||
header.Set("Content-Type", "image/jpeg")
|
||
part, err := writer.CreatePart(header)
|
||
if err != nil {
|
||
return "", fmt.Errorf("准备验证码图片失败:%w", err)
|
||
}
|
||
if _, err := part.Write(image); err != nil {
|
||
return "", fmt.Errorf("准备验证码图片失败:%w", err)
|
||
}
|
||
if err := writer.Close(); err != nil {
|
||
return "", fmt.Errorf("准备验证码图片失败:%w", err)
|
||
}
|
||
|
||
payload, err := m.do(ctx, m.httpClient, http.MethodPost, m.cfg.OCRURL,
|
||
body.Bytes(), writer.FormDataContentType(), "", "")
|
||
if err != nil {
|
||
return "", fmt.Errorf("OCR 识别失败:%w", err)
|
||
}
|
||
var decoded any
|
||
if json.Unmarshal(payload, &decoded) != nil {
|
||
decoded = string(payload)
|
||
}
|
||
code := findCaptchaText(decoded)
|
||
if code == "" {
|
||
return "", fmt.Errorf("OCR 返回成功,但没有找到 4~8 位验证码")
|
||
}
|
||
return code, nil
|
||
}
|
||
|
||
func (m *AuthManager) submitLogin(ctx context.Context, client *http.Client, baseURL *url.URL, cid, cst, clientID, captchaCode, captchaKey string) (AuthState, string, string, error) {
|
||
form := url.Values{
|
||
"username": {m.cfg.Account},
|
||
"password": {m.cfg.Password},
|
||
"clientId": {clientID},
|
||
"kaptchaCode": {captchaCode},
|
||
"kaptchaKey": {captchaKey},
|
||
}
|
||
body, err := m.do(ctx, client, http.MethodPost, apiURL(baseURL, "login"),
|
||
[]byte(form.Encode()), "application/x-www-form-urlencoded", cid, cst)
|
||
if err != nil {
|
||
return AuthState{}, "", "", fmt.Errorf("提交货憨憨登录失败:%w", err)
|
||
}
|
||
var payload map[string]any
|
||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||
decoder.UseNumber()
|
||
if err := decoder.Decode(&payload); err != nil {
|
||
return AuthState{}, "", "", fmt.Errorf("货憨憨登录接口返回的不是有效 JSON")
|
||
}
|
||
token := stringValue(payload["access_token"])
|
||
if token == "" {
|
||
return AuthState{}, stringValue(payload["code"]), stringValue(payload["message"]), nil
|
||
}
|
||
|
||
state := AuthState{
|
||
AccessToken: token,
|
||
TokenType: stringValue(payload["token_type"]),
|
||
ExpiresIn: int64Value(payload["expires_in"]),
|
||
LoginTime: int64Value(payload["login_time"]),
|
||
Cookies: make(map[string]string),
|
||
}
|
||
if state.TokenType == "" {
|
||
state.TokenType = "Bearer"
|
||
}
|
||
if state.LoginTime <= 0 {
|
||
state.LoginTime = m.now().UnixMilli()
|
||
}
|
||
for _, cookie := range client.Jar.Cookies(baseURL) {
|
||
state.Cookies[cookie.Name] = cookie.Value
|
||
}
|
||
return state, "", "", nil
|
||
}
|
||
|
||
func (m *AuthManager) validate(ctx context.Context, state AuthState) (bool, error) {
|
||
baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(m.cfg.BaseURL), "/"))
|
||
if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" {
|
||
return false, fmt.Errorf("货憨憨网址不正确")
|
||
}
|
||
return m.validateWithClient(ctx, m.httpClient, baseURL, state)
|
||
}
|
||
|
||
func (m *AuthManager) validateWithClient(ctx context.Context, client *http.Client, baseURL *url.URL, state AuthState) (bool, error) {
|
||
form := url.Values{"appCode": {"HHH"}}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(baseURL, "butler/app-version/info"), strings.NewReader(form.Encode()))
|
||
if err != nil {
|
||
return false, fmt.Errorf("创建认证校验请求失败:%w", err)
|
||
}
|
||
applyCommonHeaders(req)
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.Header.Set("Authorization", state.AuthorizationValue())
|
||
for name, value := range state.Cookies {
|
||
req.AddCookie(&http.Cookie{Name: name, Value: value})
|
||
}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return false, fmt.Errorf("校验货憨憨认证失败:%w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
return false, nil
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return false, fmt.Errorf("校验货憨憨认证失败:HTTP %d", resp.StatusCode)
|
||
}
|
||
body, err := readBody(resp.Body)
|
||
if err != nil {
|
||
return false, fmt.Errorf("读取认证校验结果失败:%w", err)
|
||
}
|
||
var payload struct {
|
||
AppCode string `json:"appCode"`
|
||
}
|
||
if json.Unmarshal(body, &payload) != nil {
|
||
return false, nil
|
||
}
|
||
return payload.AppCode == "HHH", nil
|
||
}
|
||
|
||
func (m *AuthManager) do(ctx context.Context, client *http.Client, method, endpoint string, body []byte, contentType, basicUser, basicPassword string) ([]byte, error) {
|
||
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
applyCommonHeaders(req)
|
||
if contentType != "" {
|
||
req.Header.Set("Content-Type", contentType)
|
||
}
|
||
if basicUser != "" || basicPassword != "" {
|
||
req.SetBasicAuth(basicUser, basicPassword)
|
||
}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||
}
|
||
return readBody(resp.Body)
|
||
}
|
||
|
||
func (m *AuthManager) save(state AuthState) error {
|
||
raw, err := json.Marshal(state)
|
||
if err != nil {
|
||
return fmt.Errorf("编码货憨憨认证状态失败:%w", err)
|
||
}
|
||
if err := m.db.SetKV(authStateKey, string(raw), m.now().Format(time.RFC3339)); err != nil {
|
||
return fmt.Errorf("保存货憨憨认证状态失败:%w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (m *AuthManager) load() (AuthState, bool, error) {
|
||
if m.db == nil {
|
||
return AuthState{}, false, fmt.Errorf("本地数据库未就绪")
|
||
}
|
||
raw, found, err := m.db.GetKV(authStateKey)
|
||
if err != nil {
|
||
return AuthState{}, false, fmt.Errorf("读取货憨憨认证状态失败:%w", err)
|
||
}
|
||
if !found || strings.TrimSpace(raw) == "" {
|
||
return AuthState{}, false, nil
|
||
}
|
||
var state AuthState
|
||
if err := json.Unmarshal([]byte(raw), &state); err != nil || state.AccessToken == "" {
|
||
m.log.Warn("本机保存的货憨憨认证状态无法读取,将重新登录")
|
||
return AuthState{}, false, nil
|
||
}
|
||
if state.Cookies == nil {
|
||
state.Cookies = make(map[string]string)
|
||
}
|
||
return state, true, nil
|
||
}
|
||
|
||
func (m *AuthManager) clearLocked() error {
|
||
m.current = nil
|
||
if m.db == nil {
|
||
return fmt.Errorf("本地数据库未就绪")
|
||
}
|
||
if err := m.db.SetKV(authStateKey, "", m.now().Format(time.RFC3339)); err != nil {
|
||
return fmt.Errorf("清除货憨憨认证状态失败:%w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func cloneAuthState(state AuthState) AuthState {
|
||
cloned := state
|
||
cloned.Cookies = make(map[string]string, len(state.Cookies))
|
||
for name, value := range state.Cookies {
|
||
cloned.Cookies[name] = value
|
||
}
|
||
return cloned
|
||
}
|
||
|
||
func findRuntimeValue(page []byte, pattern *regexp.Regexp) string {
|
||
match := pattern.FindSubmatch(page)
|
||
if len(match) != 2 {
|
||
return ""
|
||
}
|
||
return string(match[1])
|
||
}
|
||
|
||
func findCaptchaText(value any) string {
|
||
switch typed := value.(type) {
|
||
case string:
|
||
cleaned := strings.Join(strings.Fields(typed), "")
|
||
if captchaPattern.MatchString(cleaned) {
|
||
return cleaned
|
||
}
|
||
case map[string]any:
|
||
for _, key := range []string{"text", "result", "data", "content", "captcha", "code"} {
|
||
if child, ok := typed[key]; ok {
|
||
if result := findCaptchaText(child); result != "" {
|
||
return result
|
||
}
|
||
}
|
||
}
|
||
for key, child := range typed {
|
||
if isPreferredCaptchaKey(key) {
|
||
continue
|
||
}
|
||
if result := findCaptchaText(child); result != "" {
|
||
return result
|
||
}
|
||
}
|
||
case []any:
|
||
for _, child := range typed {
|
||
if result := findCaptchaText(child); result != "" {
|
||
return result
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isPreferredCaptchaKey(key string) bool {
|
||
for _, preferred := range []string{"text", "result", "data", "content", "captcha", "code"} {
|
||
if key == preferred {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func stringValue(value any) string {
|
||
switch typed := value.(type) {
|
||
case string:
|
||
return typed
|
||
case json.Number:
|
||
return typed.String()
|
||
case float64:
|
||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func int64Value(value any) int64 {
|
||
switch typed := value.(type) {
|
||
case json.Number:
|
||
result, _ := typed.Int64()
|
||
return result
|
||
case float64:
|
||
return int64(typed)
|
||
case string:
|
||
result, _ := strconv.ParseInt(typed, 10, 64)
|
||
return result
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func rawString(raw json.RawMessage) string {
|
||
if len(raw) == 0 {
|
||
return ""
|
||
}
|
||
var text string
|
||
if json.Unmarshal(raw, &text) == nil {
|
||
return text
|
||
}
|
||
var number json.Number
|
||
if json.Unmarshal(raw, &number) == nil {
|
||
return number.String()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func apiURL(baseURL *url.URL, path string) string {
|
||
return strings.TrimRight(baseURL.String(), "/") + "/api/" + strings.TrimLeft(path, "/")
|
||
}
|
||
|
||
func applyCommonHeaders(req *http.Request) {
|
||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152")
|
||
}
|
||
|
||
func readBody(reader io.Reader) ([]byte, error) {
|
||
return io.ReadAll(io.LimitReader(reader, maximumResponseBodyBytes))
|
||
}
|
||
|
||
func newUUID() (string, error) {
|
||
var raw [16]byte
|
||
if _, err := rand.Read(raw[:]); err != nil {
|
||
return "", err
|
||
}
|
||
raw[6] = (raw[6] & 0x0f) | 0x40
|
||
raw[8] = (raw[8] & 0x3f) | 0x80
|
||
encoded := hex.EncodeToString(raw[:])
|
||
return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil
|
||
}
|