diff --git a/README.md b/README.md index 18b101e..25a8501 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ dsfree2api -config config.toml -check | `[proxy]` | `url` 主线路、`fallback_urls` 备用线路、`slow_start_seconds` 首事件超时 | | `[proxypool]` | 代理池总开关、健康检查间隔 / 超时 / URL、裸 `ip:port` 默认协议、Xray 路径 / 版本 / 自动下载;`[proxypool.entries.*]` 手动节点(分享链接或端点)、`[proxypool.subscriptions.*]` 订阅自动拉取 | | `[upstream]` | 超时、配置缓存 TTL、会话自动刷新、`cross_site_failover` 跨站切换、`continue_rounds` 工具调用截断续写轮数(0 = 不续写、直接截断补全) | -| `[turnstile]` | **默认 `enabled = false` 且不内置任何求解服务**;`provider` 三选一:`api`(填 `api_url` / `api_key`)、`browser`(填 `browser_path` 指向本机 Chrome/Edge)、`manual`(只用控制台导入的 Cookie),另有 Cookie TTL、重试次数 | +| `[turnstile]` | **默认 `enabled = false` 且不内置任何求解服务**;`provider` 三选一:`api`(填 `api_url` / `api_key`)、`browser`(填 `browser_path` 指向本机 Chrome/Edge)、`manual`(只用控制台导入的 Cookie),另有 Cookie TTL、重试次数、单次求解超时(`timeout_seconds`)与 Cookie 池(`warm_enabled` / `warm_ratio` / `warm_check_seconds`,后台预热,请求零等待) | | `[sites.*]` | 三个站点的 `base_url` / `ajax_url` / `sitekey` / `language` / `proxies`(站点级出口绑定,按顺序粘性选路) | | `[models.*]` | 模型到 `site` + `upstream_id` + `bot_id` / `post_id` 的映射 | diff --git a/cmd/dsfree2api/main.go b/cmd/dsfree2api/main.go index be9b393..3fe0fb0 100644 --- a/cmd/dsfree2api/main.go +++ b/cmd/dsfree2api/main.go @@ -86,6 +86,12 @@ func main() { up := upstream.New(cfg, ts, logger, pool) + // Cookie pool: keep one verified session per site × route ahead of time, + // so API requests never wait for a solve. No-op unless warm_enabled. + warmCtx, warmCancel := context.WithCancel(context.Background()) + defer warmCancel() + ts.StartWarmer(warmCtx, func() []turnstile.WarmTarget { return warmTargets(cfg, up, pool) }) + apiSrv := api.New(cfg, up, met, logs, ts, logger) adminSrv := admin.New(cfg, up, met, logs, ts, logger, pool) @@ -143,6 +149,48 @@ func main() { logger.Info("bye") } +// warmTargets builds the site × route matrix the cookie pool keeps fresh: +// the sticky proxy-pool pick first, then the global primary/fallback lines. +// A site with warm_pool_only skips the global lanes and warms only its bound +// pool routes (request-time failover to the global lanes is unaffected). +func warmTargets(cfg *config.Config, up *upstream.Client, pool *proxypool.Manager) []turnstile.WarmTarget { + routes := up.Routes() + cfg.RLock() + sites := make([]config.Site, 0, len(cfg.Sites)) + for _, st := range cfg.Sites { + if st != nil && st.Enabled { + sites = append(sites, *st) + } + } + cfg.RUnlock() + out := make([]turnstile.WarmTarget, 0, len(routes)*len(sites)) + seen := map[string]bool{} + add := func(site config.Site, proxy string) { + key := site.Code + "|" + proxy + if seen[key] { + return + } + seen[key] = true + out = append(out, turnstile.WarmTarget{Site: site, Proxy: proxy}) + } + for _, site := range sites { + if pool != nil { + if cands := pool.Candidates(site.Code); len(cands) > 0 { + add(site, cands[0].Proxy) + } + } + // warm_pool_only sites keep their cookie pool on the bound pool + // routes; the global lanes stay unwarmed. + if site.WarmPoolOnly { + continue + } + for _, r := range routes { + add(site, r.Proxy) + } + } + return out +} + func countModels(cfg *config.Config) int { cfg.RLock() defer cfg.RUnlock() diff --git a/config.example.toml b/config.example.toml index df7bf61..d44d42c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -2,7 +2,9 @@ # 复制为 config.toml 后按需修改;运行 `dsfree2api -check -config config.toml` 可校验。 # 环境变量优先级高于本文件:HOST PORT LOG_LEVEL API_KEYS PROXY_URL PROXY_FALLBACK_URLS # PROXY_SLOW_START_SECONDS TURNSTILE_ENABLED TURNSTILE_API_KEY TURNSTILE_PROVIDER -# TURNSTILE_BROWSER_PATH ADMIN_ENABLED ADMIN_HOST ADMIN_PORT ADMIN_PASSWORD DATA_DIR +# TURNSTILE_BROWSER_PATH TURNSTILE_TIMEOUT_SECONDS TURNSTILE_WARM_ENABLED +# TURNSTILE_WARM_RATIO TURNSTILE_WARM_CHECK_SECONDS +# ADMIN_ENABLED ADMIN_HOST ADMIN_PORT ADMIN_PASSWORD DATA_DIR [server] host = "0.0.0.0" @@ -87,7 +89,8 @@ continue_rounds = 20 # manual —— 只用控制台手动导入的 Cookie(方式 3) enabled = false provider = "api" -# 方式 1:求解服务 API +# 方式 1:求解服务 API(api_style = "sync" 为默认 CapSolver 形状; +# api_style = "ezsolver" 适配本地 EzSolver 服务,api_key 可留空) # api_url = "https://your-solver.example.com/turnstile/sync" # api_key = "REPLACE_ME" # 方式 2:本地浏览器自动获取(CDP 直连,无需 Playwright / Docker) @@ -98,10 +101,18 @@ provider = "api" # browser_locale = "" # 可选:如 "ja-JP",留空取站点语言 sitekey = "0x4AAAAAADlLZ3ljqZP6cQwq" action = "chat" +# 求解超时:单次求解(浏览器 / 求解服务 API)的最长等待秒数,5–600。 timeout_seconds = 90 cookie_ttl_seconds = 10800 retries = 5 retry_backoff_seconds = 1.5 +# ── Cookie 池(后台预热)───────────────────────────────── +# 开启后,后台按 warm_check_seconds 巡检:Cookie 缺失 / 过期 / +# 剩余 TTL 低于 warm_ratio 时主动重解并写回缓存,请求路径不再等待求解。 +# provider = "manual" 时不预热(手动导入的 Cookie 无法自动续期)。 +warm_enabled = false +warm_ratio = 0.3 # 剩余 TTL 低于该比例(0.05–0.95)时触发预热 +warm_check_seconds = 60 # 后台巡检间隔秒数(5–3600) [runtime] data_dir = "./data" @@ -114,6 +125,8 @@ language = "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7" # 站点级出口绑定(代理池条目名或 "sub:订阅名",按顺序粘性选路); # 留空 = 使用 [proxy] 主/备线路。三个站点可各自独立配置。 # proxies = ["node-a", "sub:home"] +# 只预热代理池线路(不预热 [proxy] 主/备;请求时仍可回落): +# warm_pool_only = true [sites.es] base_url = "https://deepseek.es" diff --git a/internal/admin/actions.go b/internal/admin/actions.go index 199770b..21a39ca 100644 --- a/internal/admin/actions.go +++ b/internal/admin/actions.go @@ -235,12 +235,20 @@ func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) { Challenge string `json:"challenge_action"` Legacy string `json:"ts_action"` CookieTT int `json:"cookie_ttl_seconds"` + APIStyle *string `json:"api_style"` BrowserPath *string `json:"browser_path"` BrowserHeadless *bool `json:"browser_headless"` BrowserUserDataDir *string `json:"browser_user_data_dir"` BrowserTimezone *string `json:"browser_timezone"` BrowserLocale *string `json:"browser_locale"` + + TimeoutSeconds *int `json:"timeout_seconds"` + Retries *int `json:"retries"` + RetryBackoffSeconds *float64 `json:"retry_backoff_seconds"` + WarmEnabled *bool `json:"warm_enabled"` + WarmRatio *float64 `json:"warm_ratio"` + WarmCheckSeconds *int `json:"warm_check_seconds"` } _ = json.Unmarshal(body, &p) // Build the candidate config first: a rejected save must not leave @@ -277,9 +285,30 @@ func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) { if act := firstNonEmpty(p.Challenge, p.Legacy); act != "" { tc.Action = act } + if p.APIStyle != nil { + tc.APIStyle = strings.ToLower(strings.TrimSpace(*p.APIStyle)) + } if p.CookieTT > 0 { tc.CookieTTLSeconds = p.CookieTT } + if p.TimeoutSeconds != nil { + tc.TimeoutSeconds = clampInt(*p.TimeoutSeconds, 5, 600) + } + if p.Retries != nil { + tc.Retries = clampInt(*p.Retries, 1, 20) + } + if p.RetryBackoffSeconds != nil { + tc.RetryBackoffSeconds = clampFloat(*p.RetryBackoffSeconds, 0, 60) + } + if p.WarmEnabled != nil { + tc.WarmEnabled = *p.WarmEnabled + } + if p.WarmRatio != nil { + tc.WarmRatio = clampFloat(*p.WarmRatio, 0.05, 0.95) + } + if p.WarmCheckSeconds != nil { + tc.WarmCheckSeconds = clampInt(*p.WarmCheckSeconds, 5, 3600) + } tc.Enabled = p.Enabled if tc.Enabled { // provider-specific requirements mirror config.Validate() diff --git a/internal/admin/server.go b/internal/admin/server.go index e298e16..e88bf80 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -212,7 +212,7 @@ func (s *Server) handleOverview(w http.ResponseWriter, _ *http.Request) { "models": models, "sites": sites, "proxy": proxyCfg, - "turnstile": map[string]any{"config": turnstileCfg, "status": s.ts.Status(siteCodes), "history": s.ts.History()}, + "turnstile": map[string]any{"config": turnstileCfg, "status": s.ts.Status(siteCodes), "history": s.ts.History(), "pool": s.ts.PoolStatus()}, "proxypool": poolStatus, "keys": keys, "cache": map[string]any{"chat_config_entries": s.up.ConfigCacheSize(), "routes": routeList(s.up)}, diff --git a/internal/admin/web/app.js b/internal/admin/web/app.js index b4473cb..2524a4e 100644 --- a/internal/admin/web/app.js +++ b/internal/admin/web/app.js @@ -563,6 +563,10 @@ PAGES.turnstile = async () => {
+
+
+
求解与 Cookie 池
+
+
+
+
+
+
+
${switchHTML("ts-warm", !!t.warm_enabled)}
+
+
+
+
Cookie 池开启后:后台每隔检查间隔巡检一次,剩余 TTL 低于阈值时主动重解,请求路径不再等待求解耗时(manual 方式不预热)。
+
@@ -605,7 +623,11 @@ PAGES.turnstile = async () => { -
缓存 TTL ${t.cookie_ttl_seconds}s · 求解失败重试 ${t.retries} 次 · 退避 ${t.retry_backoff_seconds}s
+
缓存 TTL ${t.cookie_ttl_seconds}s · 求解超时 ${t.timeout_seconds || 90}s · 重试 ${t.retries} 次 · 退避 ${t.retry_backoff_seconds}s
+ +

Cookie 池

+
+

求解历史

@@ -626,13 +648,39 @@ PAGES.turnstile = async () => { `).join("") : ''; + const renderPool = (o) => { + const pool = (o.turnstile && o.turnstile.pool) || { entries: [] }; + const label = $("#ts-pool-state"); + if (label) { + label.textContent = `状态:${pool.enabled ? (pool.running ? "运行中" : "已启用,等待下一轮巡检") : "未开启"} · 阈值 剩余 ${Math.round((pool.ratio || 0.3) * 100)}% · 每 ${pool.check_seconds || 60}s 巡检一次`; + } + const box = $("#ts-pool"); + if (!box) return; + const entries = pool.entries || []; + box.innerHTML = entries.length ? entries.map(e => { + const state = e.warming ? '预热中' + : e.valid ? '就绪' + : e.last_error ? '求解失败' : '未建立'; + const left = e.valid ? `剩余 ${Math.max(0, Math.round(e.remaining_s / 60))} 分钟` : "等待预热"; + const next = e.next_warm_at ? `下次 ${timeStr(e.next_warm_at * 1000)}` : "—"; + const last = e.last_at ? `上次 ${timeStr(e.last_at * 1000)} · ${fmtMs(e.last_ms)}` : "尚未求解"; + return `
+
${esc(e.site.toUpperCase())} ${esc(e.route)}
${state}
+
${left} · ${next}
${last}${e.last_error ? `
${esc(String(e.last_error).slice(0, 90))}` : ""}
+
`; + }).join("") : '
暂无池内记录 — 开启预热并等待下一轮巡检
'; + }; + renderPool(ov); + overviewTimer = setInterval(() => { fetchOverview().then(renderPool).catch(() => {}); }, 5000); + $$("[data-rf]").forEach(b => b.onclick = async () => { b.disabled = true; - try { await api("/api/actions", { body: { action: "refresh_cookies", site: b.dataset.rf } }); toast("已作废,将在下次请求时重新求解", "ok"); PAGES.turnstile(); } + try { await api("/api/actions", { body: { action: "refresh_cookies", site: b.dataset.rf } }); toast(t.warm_enabled ? "已作废,Cookie 池将在下一轮巡检时重新预热" : "已作废,将在下次请求时重新求解", "ok"); PAGES.turnstile(); } catch (e) { toast(e.message, "err"); } b.disabled = false; }); $("#ts-provider").value = t.provider || "api"; + $("#ts-style").value = t.api_style === "ezsolver" ? "ezsolver" : "sync"; const TS_HINTS = { api: ["已开启:调用求解服务 API 获取 Turnstile Token", "已关闭:跳过 Turnstile 求解,直接请求上游站点"], browser: ["已开启:用下方配置的本地浏览器自动求解 Token", "已关闭:跳过 Turnstile 求解,直接请求上游站点"], @@ -658,6 +706,13 @@ PAGES.turnstile = async () => { api_url: $("#ts-url").value.trim(), api_key: $("#ts-key").value.trim(), sitekey: $("#ts-sitekey").value.trim(), challenge_action: $("#ts-action").value.trim(), cookie_ttl_seconds: +$("#ts-ttl").value, + api_style: $("#ts-style").value, + timeout_seconds: Math.round(+$("#ts-timeout").value || 90), + retries: Math.round(+$("#ts-retries").value || 5), + retry_backoff_seconds: Math.max(0, +$("#ts-backoff").value || 0), + warm_enabled: $("#ts-warm").checked, + warm_ratio: Math.min(95, Math.max(5, Math.round(+$("#ts-ratio").value || 30))) / 100, + warm_check_seconds: Math.round(+$("#ts-check").value || 60), browser_path: $("#ts-bpath").value.trim(), browser_headless: $("#ts-bhead").checked, browser_user_data_dir: $("#ts-bprof").value.trim(), diff --git a/internal/admin/web/style.css b/internal/admin/web/style.css index 6b368ff..9576b33 100644 --- a/internal/admin/web/style.css +++ b/internal/admin/web/style.css @@ -83,6 +83,7 @@ label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px} .pill{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;border:1px solid var(--line);color:var(--muted)} .pill.on{color:var(--ok);border-color:#1f6a34;background:#0f2318} .pill.off{color:var(--dim)} +.pill.warn{color:var(--warn);border-color:#6b5417;background:#2a2210} /* console / logs */ .log-box{background:#0a0e14;border:1px solid var(--line);border-radius:10px;padding:10px;height:56vh;overflow:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55} diff --git a/internal/config/config.go b/internal/config/config.go index fc5debb..ce40c21 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,15 @@ import ( const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" +// Turnstile tuning defaults. DefaultSolveTimeoutSeconds is the single source +// for how long one solve attempt may run; the warm_* values drive the cookie +// pool (background pre-solving). +const ( + DefaultSolveTimeoutSeconds = 90 + DefaultWarmRatio = 0.3 + DefaultWarmCheckSeconds = 60 +) + // Turnstile token providers: api calls a hosted solver, browser drives a // local Chrome/Edge over CDP, manual only ever uses imported cookies. const ( @@ -29,11 +38,18 @@ type Turnstile struct { APIKey string `toml:"api_key" json:"api_key"` SiteKey string `toml:"sitekey" json:"sitekey"` Action string `toml:"action" json:"action"` + APIStyle string `toml:"api_style" json:"api_style"` TimeoutSeconds int `toml:"timeout_seconds" json:"timeout_seconds"` CookieTTLSeconds int `toml:"cookie_ttl_seconds" json:"cookie_ttl_seconds"` Retries int `toml:"retries" json:"retries"` RetryBackoffSeconds float64 `toml:"retry_backoff_seconds" json:"retry_backoff_seconds"` + // Cookie pool: a background loop re-solves a site × route slot before its + // cached cookie expires, so requests never pay the solve latency. + WarmEnabled bool `toml:"warm_enabled" json:"warm_enabled"` + WarmRatio float64 `toml:"warm_ratio" json:"warm_ratio"` + WarmCheckSeconds int `toml:"warm_check_seconds" json:"warm_check_seconds"` + // provider = "browser" BrowserPath string `toml:"browser_path" json:"browser_path"` BrowserHeadless bool `toml:"browser_headless" json:"browser_headless"` @@ -51,6 +67,65 @@ func (t Turnstile) ProviderValue() string { return p } +// APIStyleValue normalizes api_style for provider "api": "sync" (CapSolver- +// shaped single POST) or "ezsolver" (POST {sitekey,siteurl,timeout} → {token}). +func (t Turnstile) APIStyleValue() string { + switch strings.ToLower(strings.TrimSpace(t.APIStyle)) { + case "ezsolver", "ez": + return "ezsolver" + default: + return "sync" + } +} + +// SolveTimeoutValue normalizes timeout_seconds: the longest one solve attempt +// may run (default 90s, clamped to 5–600s). +func (t Turnstile) SolveTimeoutValue() int { + n := t.TimeoutSeconds + if n <= 0 { + return DefaultSolveTimeoutSeconds + } + if n < 5 { + return 5 + } + if n > 600 { + return 600 + } + return n +} + +// WarmRatioValue normalizes warm_ratio: a pooled cookie is re-solved once its +// remaining TTL drops below this fraction (default 30%, clamped 5–95%). +func (t Turnstile) WarmRatioValue() float64 { + r := t.WarmRatio + if r <= 0 { + return DefaultWarmRatio + } + if r < 0.05 { + return 0.05 + } + if r > 0.95 { + return 0.95 + } + return r +} + +// WarmCheckValue normalizes warm_check_seconds: how often the pool is +// inspected (default 60s, clamped 5s–1h). +func (t Turnstile) WarmCheckValue() int { + n := t.WarmCheckSeconds + if n <= 0 { + return DefaultWarmCheckSeconds + } + if n < 5 { + return 5 + } + if n > 3600 { + return 3600 + } + return n +} + type Site struct { Code string `toml:"-"` BaseURL string `toml:"base_url" json:"base_url"` @@ -183,10 +258,12 @@ func DefaultTurnstile() Turnstile { APIKey: "", SiteKey: "0x4AAAAAADlLZ3ljqZP6cQwq", Action: "chat", - TimeoutSeconds: 90, + TimeoutSeconds: DefaultSolveTimeoutSeconds, CookieTTLSeconds: 10800, Retries: 5, RetryBackoffSeconds: 1.5, + WarmRatio: DefaultWarmRatio, + WarmCheckSeconds: DefaultWarmCheckSeconds, } } @@ -428,6 +505,34 @@ func (c *Config) applyEnv() error { if v := str("TURNSTILE_BROWSER_PATH"); v != nil && strings.TrimSpace(*v) != "" { c.Turnstile.BrowserPath = strings.TrimSpace(*v) } + if v := str("TURNSTILE_TIMEOUT_SECONDS"); v != nil { + n, err := strconv.Atoi(strings.TrimSpace(*v)) + if err != nil || n < 1 { + return fmt.Errorf("invalid TURNSTILE_TIMEOUT_SECONDS %q", *v) + } + c.Turnstile.TimeoutSeconds = n + } + if v := str("TURNSTILE_WARM_ENABLED"); v != nil { + b, err := strconv.ParseBool(strings.TrimSpace(*v)) + if err != nil { + return fmt.Errorf("invalid TURNSTILE_WARM_ENABLED %q", *v) + } + c.Turnstile.WarmEnabled = b + } + if v := str("TURNSTILE_WARM_RATIO"); v != nil { + f, err := strconv.ParseFloat(strings.TrimSpace(*v), 64) + if err != nil || f <= 0 || f > 1 { + return fmt.Errorf("invalid TURNSTILE_WARM_RATIO %q (expected 0–1)", *v) + } + c.Turnstile.WarmRatio = f + } + if v := str("TURNSTILE_WARM_CHECK_SECONDS"); v != nil { + n, err := strconv.Atoi(strings.TrimSpace(*v)) + if err != nil || n < 1 { + return fmt.Errorf("invalid TURNSTILE_WARM_CHECK_SECONDS %q", *v) + } + c.Turnstile.WarmCheckSeconds = n + } if v := str("TURNSTILE_ENABLED"); v != nil { b, err := strconv.ParseBool(strings.TrimSpace(*v)) if err != nil { @@ -517,7 +622,8 @@ func (c *Config) validateTurnstile() error { if c.Turnstile.APIURL == "" { return errors.New("turnstile.api_url is empty (set config to your solver endpoint, or use provider = \"manual\")") } - if c.Turnstile.APIKey == "" { + // Local solve services (api_style = "ezsolver") usually need no key. + if c.Turnstile.APIKey == "" && c.Turnstile.APIStyleValue() != "ezsolver" { return errors.New("turnstile.api_key is empty (set config, or TURNSTILE_API_KEY)") } case ProviderBrowser: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a3b36a7..64a687e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -100,6 +100,11 @@ func TestValidateRejectsBadValues(t *testing.T) { if err := cfg.Validate(); err == nil { t.Error("expected error for empty turnstile api_key") } + cfg.Turnstile.APIStyle = "ezsolver" + if err := cfg.Validate(); err != nil { + t.Errorf("ezsolver style without api_key: %v", err) + } + cfg.Turnstile.APIStyle = "" cfg.Turnstile.APIKey = "k" cfg.Turnstile.Enabled = false cfg.Admin.Port = cfg.Server.Port diff --git a/internal/turnstile/browser.go b/internal/turnstile/browser.go index 88c4994..2b27eb9 100644 --- a/internal/turnstile/browser.go +++ b/internal/turnstile/browser.go @@ -467,10 +467,7 @@ const uaMetadataExpr = `(() => { // warms the page like a human, renders the widget and clicks until a token // lands or the deadline passes. func (s *Solver) pollForToken(ctx context.Context, conn *cdp, sid, sitekey string, cfg config.Turnstile) (string, error) { - timeout := time.Duration(cfg.TimeoutSeconds) * time.Second - if timeout <= 0 { - timeout = 90 * time.Second - } + timeout := time.Duration(cfg.SolveTimeoutValue()) * time.Second deadline := time.Now().Add(timeout) var preDone, inited bool diff --git a/internal/turnstile/solver.go b/internal/turnstile/solver.go index a5982f1..3cc995f 100644 --- a/internal/turnstile/solver.go +++ b/internal/turnstile/solver.go @@ -10,7 +10,9 @@ import ( "errors" "fmt" "io" + "net" "net/http" + "net/url" "strings" "sync" "time" @@ -91,6 +93,11 @@ type Solver struct { // browserMu serializes browser solves: one Chrome window at a time. browserMu sync.Mutex + // cookie pool: background warm refresh, one slot per site × route. + poolMu sync.Mutex + poolOn bool + warms map[string]*warmState + // refreshOverride is a test seam; nil in production. refreshOverride func(ctx context.Context, sess httpx.Session, site *config.Site, c *core) error } @@ -107,7 +114,7 @@ func New(cfg config.Turnstile, userAgent string) *Solver { } } -func (s *Solver) Enabled() bool { return s.cfg.Enabled } +func (s *Solver) Enabled() bool { return s.Config().Enabled } func (s *Solver) Config() config.Turnstile { s.mu.Lock() @@ -133,7 +140,7 @@ func (s *Solver) coreFor(proxy string) *core { defer s.mu.Unlock() c, ok := s.cores[key] if !ok { - client, _ := httpx.StdClient(proxy, time.Duration(s.cfg.TimeoutSeconds+30)*time.Second) + client, _ := httpx.StdClient(proxy, time.Duration(s.cfg.SolveTimeoutValue()+30)*time.Second) c = &core{ proxy: key, client: client, @@ -287,9 +294,17 @@ func (s *Solver) ApplyValidCookies(ctx context.Context, sess httpx.Session, site return s.refreshLocked(ctx, sess, site, c) } +// CookiesReady reports whether an unexpired cached cookie set exists for the +// site × route pair. Upstream uses it to keep the slow-start timer from +// killing a first-time Turnstile solve, which can legitimately take minutes. +func (s *Solver) CookiesReady(siteCode, proxy string) bool { + c := s.coreFor(proxy) + return c.load(siteCode) != nil +} + // Refresh forces a fresh solve → verify cycle. func (s *Solver) Refresh(ctx context.Context, sess httpx.Session, site *config.Site, proxy string) error { - if !s.cfg.Enabled { + if !s.Enabled() { return nil } c := s.coreFor(proxy) @@ -385,32 +400,70 @@ func (s *Solver) solveToken(ctx context.Context, site *config.Site, sitekey stri func (s *Solver) solveTokenAPI(ctx context.Context, baseURL, sitekey string, c *core) (string, error) { cfg := s.Config() - payload, _ := json.Marshal(map[string]any{ - "url": baseURL, - "sitekey": sitekey, - "action": cfg.Action, - "cdata": "", - "timeoutSeconds": cfg.TimeoutSeconds, - }) + style := cfg.APIStyleValue() + var payload []byte + if style == "ezsolver" { + payload, _ = json.Marshal(map[string]any{ + "sitekey": sitekey, + "siteurl": baseURL, + "timeout": cfg.SolveTimeoutValue(), + }) + } else { + payload, _ = json.Marshal(map[string]any{ + "url": baseURL, + "sitekey": sitekey, + "action": cfg.Action, + "cdata": "", + "timeoutSeconds": cfg.SolveTimeoutValue(), + }) + } var last error + // A loopback solve service must be reached directly: pushing it through + // the route proxy would resolve 127.0.0.1 on the far side of the tunnel. + client := c.client + if isLocalHost(cfg.APIURL) { + client = &http.Client{Timeout: time.Duration(cfg.SolveTimeoutValue()+30) * time.Second} + } for attempt := 0; attempt < cfg.Retries; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.APIURL, bytes.NewReader(payload)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+cfg.APIKey) - resp, err := c.client.Do(req) + if cfg.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + } + resp, err := client.Do(req) if err != nil { last = &Error{Msg: "turnstile solver request failed: " + err.Error(), Retryable: true} } else { body, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode >= 400 { + retry := retryableStatus(resp.StatusCode) + if style == "ezsolver" { + // Local solve services answer 5xx for per-attempt + // timeouts; another attempt is reasonable. + retry = true + } last = &Error{ Msg: fmt.Sprintf("turnstile solver http %d: %s (check [turnstile].api_key / api_url)", resp.StatusCode, trim(body)), - Retryable: retryableStatus(resp.StatusCode), + Retryable: retry, + } + } else if style == "ezsolver" { + var out struct { + Token string `json:"token"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &out); err != nil { + last = &Error{Msg: "turnstile solver bad json: " + err.Error()} + } else if out.Error != "" { + last = &Error{Msg: "turnstile solver error: " + trim([]byte(out.Error))} + } else if out.Token == "" { + last = &Error{Msg: "turnstile solver returned no token"} + } else { + return out.Token, nil } } else { var out struct { @@ -444,6 +497,25 @@ func (s *Solver) solveTokenAPI(ctx context.Context, baseURL, sitekey string, c * return "", last } +// isLocalHost reports whether rawURL points at the local machine or a +// private-network address. Such solve services must be reached directly +// instead of through the route proxy. +func isLocalHost(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + host := strings.ToLower(u.Hostname()) + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() +} + // retryableStatus decides which solver HTTP statuses are worth another attempt. // Solver-side rejections (bad key, exhausted quota, challenge rejected) return // the same answer every time, so they are not retried. diff --git a/internal/turnstile/solver_test.go b/internal/turnstile/solver_test.go index 3888055..73a830d 100644 --- a/internal/turnstile/solver_test.go +++ b/internal/turnstile/solver_test.go @@ -2,7 +2,11 @@ package turnstile import ( "context" + "encoding/json" "io" + "net/http" + "net/http/httptest" + "net/url" "reflect" "strings" "sync" @@ -239,3 +243,43 @@ func TestRotateGuestSwapsVisitorID(t *testing.T) { t.Errorf("rotating an unknown site returned %q, want empty", got) } } + +func TestSolveTokenAPIEzSolverStyle(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"tok-123","elapsed":1.5}`)) + })) + defer srv.Close() + + cfg := config.Turnstile{ + Enabled: true, Provider: config.ProviderAPI, APIStyle: "ezsolver", + APIURL: srv.URL, TimeoutSeconds: 30, Retries: 1, + } + solver := New(cfg, httpx.DefaultUserAgent) + // A broken route-proxied client proves the loopback bypass: the call must + // still succeed because local solve services are reached directly. + brokenURL, _ := url.Parse("socks5://127.0.0.1:1") + c := &core{ + proxy: "direct", + client: &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(brokenURL)}}, + cache: map[string]*cookieState{}, locks: map[string]*sync.Mutex{}, + } + + token, err := solver.solveTokenAPI(context.Background(), "https://deepseek.de/", "0xabc", c) + if err != nil { + t.Fatalf("solve: %v", err) + } + if token != "tok-123" { + t.Fatalf("token = %q", token) + } + if gotBody["sitekey"] != "0xabc" || gotBody["siteurl"] != "https://deepseek.de/" { + t.Fatalf("request body = %v", gotBody) + } + if gotBody["timeout"] != float64(30) { + t.Fatalf("timeout = %v", gotBody["timeout"]) + } +} diff --git a/internal/turnstile/warmer.go b/internal/turnstile/warmer.go new file mode 100644 index 0000000..d6a4aba --- /dev/null +++ b/internal/turnstile/warmer.go @@ -0,0 +1,278 @@ +package turnstile + +import ( + "context" + "sort" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/httpx" +) + +// WarmTarget is one cookie-pool slot: a site reached through one proxy route. +// Verified cookies are bound to the egress IP that solved them, so the pool +// keeps one slot per site × route. +type WarmTarget struct { + Site config.Site + Proxy string // "" means direct +} + +// WarmEntry describes the pool state of one site × route slot. +type WarmEntry struct { + Site string `json:"site"` + Route string `json:"route"` + Valid bool `json:"valid"` + Remaining int64 `json:"remaining_s"` + Warming bool `json:"warming"` + LastAt int64 `json:"last_at"` + LastMs int64 `json:"last_ms"` + LastErr string `json:"last_error,omitempty"` + NextWarm int64 `json:"next_warm_at"` +} + +// PoolStatus is the console view of the cookie pool. +type PoolStatus struct { + Enabled bool `json:"enabled"` + Running bool `json:"running"` + Ratio float64 `json:"ratio"` + CheckS int `json:"check_seconds"` + Entries []WarmEntry `json:"entries"` +} + +type warmState struct { + site string + route string + warming bool + fails int + lastAt time.Time + lastMs int64 + lastErr string +} + +// StartWarmer launches the cookie-pool loop. targets is consulted on every +// sweep, so console edits (sites, routes, toggles) are picked up live. A slot +// is warmed when its cookie is missing, expired, or has less than warm_ratio +// of its TTL left — requests then never wait for a solve to finish. +func (s *Solver) StartWarmer(ctx context.Context, targets func() []WarmTarget) { + if targets == nil { + return + } + s.poolMu.Lock() + if s.poolOn { + s.poolMu.Unlock() + return + } + s.poolOn = true + if s.warms == nil { + s.warms = map[string]*warmState{} + } + s.poolMu.Unlock() + go s.warmLoop(ctx, targets) +} + +func (s *Solver) warmLoop(ctx context.Context, targets func() []WarmTarget) { + defer func() { + s.poolMu.Lock() + s.poolOn = false + s.poolMu.Unlock() + }() + for { + s.sweep(ctx, targets) + wait := time.Duration(s.Config().WarmCheckValue()) * time.Second + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + } +} + +// sweep warms every due slot. Solves run in the background so one slow +// browser window cannot stall the inspection; a slot already being warmed is +// skipped until it finishes, and a slot whose last attempt failed waits out an +// exponential backoff before the next try. +func (s *Solver) sweep(ctx context.Context, targets func() []WarmTarget) { + cfg := s.Config() + if !cfg.Enabled || !cfg.WarmEnabled || cfg.ProviderValue() == config.ProviderManual { + return + } + ttl := time.Duration(cfg.CookieTTLSeconds) * time.Second + if ttl <= 0 { + ttl = 3 * time.Hour + } + for _, tgt := range targets() { + key := poolKey(tgt.Proxy, tgt.Site.Code) + if !s.warmDue(key, tgt, ttl, cfg.WarmRatioValue(), cfg.WarmCheckValue()) { + continue + } + s.markWarm(key, tgt, true, time.Time{}, 0, "") + go s.warmOne(ctx, key, tgt, cfg) + } +} + +func (s *Solver) warmDue(key string, tgt WarmTarget, ttl time.Duration, ratio float64, checkS int) bool { + s.poolMu.Lock() + st := s.warms[key] + if st != nil && st.warming { + s.poolMu.Unlock() + return false + } + var lastAt time.Time + fails := 0 + if st != nil { + lastAt, fails = st.lastAt, st.fails + } + s.poolMu.Unlock() + + // A failed slot backs off exponentially (×2 per consecutive failure, up + // to 15 minutes) so a hopeless route cannot retry-storm the browser pool. + if fails > 0 && !lastAt.IsZero() { + backoff := time.Duration(checkS) * time.Second + for i := 1; i < fails && backoff < 15*time.Minute; i++ { + backoff *= 2 + } + if backoff > 15*time.Minute || backoff <= 0 { + backoff = 15 * time.Minute + } + if time.Since(lastAt) < backoff { + return false + } + } + + c := s.coreFor(tgt.Proxy) + c.mu.Lock() + cs := c.cache[tgt.Site.Code] + c.mu.Unlock() + if cs == nil { + return true // cold slot — fill it + } + return time.Until(cs.expiresAt) <= time.Duration(ratio*float64(ttl)) +} + +func (s *Solver) warmOne(ctx context.Context, key string, tgt WarmTarget, cfg config.Turnstile) { + site := tgt.Site + start := time.Now() + sess, err := httpx.NewSession(httpx.Options{ + ProxyURL: tgt.Proxy, + Timeout: time.Duration(cfg.SolveTimeoutValue()+30) * time.Second, + }) + if err == nil { + err = s.Refresh(ctx, sess, &site, tgt.Proxy) + sess.Close() + } + lastErr := "" + if err != nil { + lastErr = err.Error() + } + s.markWarm(key, tgt, false, start, time.Since(start).Milliseconds(), lastErr) +} + +func (s *Solver) markWarm(key string, tgt WarmTarget, warming bool, at time.Time, ms int64, lastErr string) { + s.poolMu.Lock() + defer s.poolMu.Unlock() + if s.warms == nil { + s.warms = map[string]*warmState{} + } + st, ok := s.warms[key] + if !ok { + st = &warmState{site: tgt.Site.Code, route: routeLabel(tgt.Proxy)} + s.warms[key] = st + } + st.warming = warming + if !at.IsZero() { + st.lastAt = at + st.lastMs = ms + st.lastErr = lastErr + if lastErr == "" { + st.fails = 0 + } else { + st.fails++ + } + } +} + +// PoolStatus reports every known slot: cached lifetime, warm progress, last +// solve duration and the next planned refresh. +func (s *Solver) PoolStatus() PoolStatus { + cfg := s.Config() + pooling := cfg.Enabled && cfg.WarmEnabled && cfg.ProviderValue() != config.ProviderManual + ps := PoolStatus{ + Enabled: pooling, + Ratio: cfg.WarmRatioValue(), + CheckS: cfg.WarmCheckValue(), + Entries: make([]WarmEntry, 0, 4), + } + s.poolMu.Lock() + ps.Running = s.poolOn + states := make(map[string]*warmState, len(s.warms)) + for k, v := range s.warms { + cp := *v + states[k] = &cp + } + s.poolMu.Unlock() + + s.mu.Lock() + cores := make([]*core, 0, len(s.cores)) + for _, c := range s.cores { + cores = append(cores, c) + } + s.mu.Unlock() + + now := time.Now() + ttl := time.Duration(cfg.CookieTTLSeconds) * time.Second + if ttl <= 0 { + ttl = 3 * time.Hour + } + + entries := map[string]*WarmEntry{} + for _, c := range cores { + c.mu.Lock() + for code, cs := range c.cache { + e := &WarmEntry{Site: code, Route: c.proxy, Valid: now.Before(cs.expiresAt)} + if e.Valid { + e.Remaining = int64(cs.expiresAt.Sub(now).Seconds()) + } + entries[poolKey(c.proxy, code)] = e + } + c.mu.Unlock() + } + for k, st := range states { + e, ok := entries[k] + if !ok { + e = &WarmEntry{Site: st.site, Route: st.route} + entries[k] = e + } + e.Warming = st.warming + if !st.lastAt.IsZero() { + e.LastAt = st.lastAt.Unix() + } + e.LastMs = st.lastMs + e.LastErr = st.lastErr + } + for _, e := range entries { + if pooling && e.Valid { + until := time.Duration(e.Remaining)*time.Second - time.Duration(ps.Ratio*float64(ttl)) + if until < 0 { + until = 0 + } + e.NextWarm = now.Add(until).Unix() + } + ps.Entries = append(ps.Entries, *e) + } + sort.Slice(ps.Entries, func(i, j int) bool { + if ps.Entries[i].Site != ps.Entries[j].Site { + return ps.Entries[i].Site < ps.Entries[j].Site + } + return ps.Entries[i].Route < ps.Entries[j].Route + }) + return ps +} + +func poolKey(proxy, site string) string { return routeLabel(proxy) + "|" + site } + +func routeLabel(proxy string) string { + if proxy == "" { + return "direct" + } + return proxy +} diff --git a/internal/turnstile/warmer_test.go b/internal/turnstile/warmer_test.go new file mode 100644 index 0000000..8950948 --- /dev/null +++ b/internal/turnstile/warmer_test.go @@ -0,0 +1,206 @@ +package turnstile + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/nyoungo/dsfree2api/internal/config" + "github.com/nyoungo/dsfree2api/internal/httpx" +) + +func warmerSolver(t *testing.T, mutate func(*config.Turnstile)) (*Solver, *config.Config) { + t.Helper() + cfg, err := config.Load(examplePath) + if err != nil { + t.Fatalf("load config: %v", err) + } + cfg.Turnstile.Enabled = true + cfg.Turnstile.WarmEnabled = true + cfg.Turnstile.CookieTTLSeconds = 3600 + cfg.Turnstile.WarmRatio = 0.3 + if mutate != nil { + mutate(&cfg.Turnstile) + } + return New(cfg.Turnstile, httpx.DefaultUserAgent), cfg +} + +func waitCalls(t *testing.T, calls *int32, want int32) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if atomic.LoadInt32(calls) >= want { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("refresh calls = %d, want >= %d", atomic.LoadInt32(calls), want) +} + +func waitSettled(t *testing.T, solver *Solver) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + ps := solver.PoolStatus() + busy := false + for _, e := range ps.Entries { + if e.Warming { + busy = true + } + } + if !busy { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("cookie pool did not settle") +} + +// rewindLastAt backdates the last attempt of a slot so backoff windows can be +// tested without sleeping through them. +func rewindLastAt(solver *Solver, key string, d time.Duration) { + solver.poolMu.Lock() + if st := solver.warms[key]; st != nil { + st.lastAt = time.Now().Add(-d) + } + solver.poolMu.Unlock() +} + +func TestWarmDueBacksOffAfterFailure(t *testing.T) { + solver, cfg := warmerSolver(t, nil) + tgt := WarmTarget{Site: *cfg.Sites["de"], Proxy: ""} + key := poolKey("", "de") + ttl := time.Hour + + if !solver.warmDue(key, tgt, ttl, 0.3, 5) { + t.Fatal("cold slot should be due") + } + + // First failure: the 5s backoff must hold. + solver.markWarm(key, tgt, false, time.Now(), 100, "boom") + if solver.warmDue(key, tgt, ttl, 0.3, 5) { + t.Fatal("first backoff was ignored") + } + + // Second failure doubles it: 6s elapsed is still inside the 10s window. + solver.markWarm(key, tgt, false, time.Now(), 100, "boom") + rewindLastAt(solver, key, 6*time.Second) + if solver.warmDue(key, tgt, ttl, 0.3, 5) { + t.Fatal("second failure did not double the backoff") + } + + // 11s elapsed clears the 10s window. + rewindLastAt(solver, key, 11*time.Second) + if !solver.warmDue(key, tgt, ttl, 0.3, 5) { + t.Fatal("slot should be due once the backoff elapsed") + } + + // A success resets the failure counter. + solver.markWarm(key, tgt, false, time.Now(), 50, "") + if !solver.warmDue(key, tgt, ttl, 0.3, 5) { + t.Fatal("success should clear the backoff") + } + + // The backoff is capped at 15 minutes. + for i := 0; i < 12; i++ { + solver.markWarm(key, tgt, false, time.Now(), 100, "boom") + } + rewindLastAt(solver, key, 11*time.Second) + if solver.warmDue(key, tgt, ttl, 0.3, 5) { + t.Fatal("capped backoff should still hold at 11s") + } + rewindLastAt(solver, key, 16*time.Minute) + if !solver.warmDue(key, tgt, ttl, 0.3, 5) { + t.Fatal("slot should be due after the capped backoff") + } +} + +func TestWarmSweepFillsColdSlotAndSkipsFresh(t *testing.T) { + solver, cfg := warmerSolver(t, nil) + var calls int32 + solver.refreshOverride = func(_ context.Context, _ httpx.Session, s *config.Site, c *core) error { + atomic.AddInt32(&calls, 1) + c.mu.Lock() + c.cache[s.Code] = &cookieState{cookies: map[string]string{"dsts": "ok"}, expiresAt: time.Now().Add(time.Hour)} + c.mu.Unlock() + return nil + } + targets := func() []WarmTarget { + return []WarmTarget{{Site: *cfg.Sites["de"], Proxy: ""}} + } + + // Cold slot: the first sweep must start a solve. + solver.sweep(context.Background(), targets) + waitCalls(t, &calls, 1) + waitSettled(t, solver) + + // Fresh cookie (1h left, threshold 0.3×1h): a second sweep must not solve. + solver.sweep(context.Background(), targets) + time.Sleep(80 * time.Millisecond) + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("fresh slot was re-warmed: calls = %d", got) + } + + // Remaining TTL below the threshold: due again. + c := solver.coreFor("") + c.mu.Lock() + c.cache["de"] = &cookieState{cookies: map[string]string{"dsts": "ok"}, expiresAt: time.Now().Add(30 * time.Second)} + c.mu.Unlock() + solver.sweep(context.Background(), targets) + waitCalls(t, &calls, 2) + waitSettled(t, solver) + + ps := solver.PoolStatus() + if !ps.Enabled { + t.Error("pool should report enabled") + } + if len(ps.Entries) != 1 { + t.Fatalf("pool entries = %d, want 1", len(ps.Entries)) + } + e := ps.Entries[0] + if !e.Valid || e.LastAt == 0 || e.LastErr != "" || e.Warming { + t.Errorf("pool entry = %+v", e) + } + if e.NextWarm == 0 { + t.Error("next_warm_at should be scheduled for a valid pooled cookie") + } +} + +func TestWarmSweepSkipsManualAndDisabled(t *testing.T) { + var calls int32 + override := func(context.Context, httpx.Session, *config.Site, *core) error { + atomic.AddInt32(&calls, 1) + return nil + } + + solver, cfg := warmerSolver(t, func(tc *config.Turnstile) { tc.Provider = config.ProviderManual }) + solver.refreshOverride = override + solver.sweep(context.Background(), func() []WarmTarget { + return []WarmTarget{{Site: *cfg.Sites["de"]}} + }) + time.Sleep(50 * time.Millisecond) + if calls != 0 { + t.Fatalf("manual provider must not warm, calls = %d", calls) + } + + solver2, cfg2 := warmerSolver(t, func(tc *config.Turnstile) { tc.Enabled = false }) + solver2.refreshOverride = override + solver2.sweep(context.Background(), func() []WarmTarget { + return []WarmTarget{{Site: *cfg2.Sites["de"]}} + }) + time.Sleep(50 * time.Millisecond) + if calls != 0 { + t.Fatalf("disabled solver must not warm, calls = %d", calls) + } + + solver3, cfg3 := warmerSolver(t, func(tc *config.Turnstile) { tc.WarmEnabled = false }) + solver3.refreshOverride = override + solver3.sweep(context.Background(), func() []WarmTarget { + return []WarmTarget{{Site: *cfg3.Sites["de"]}} + }) + time.Sleep(50 * time.Millisecond) + if calls != 0 { + t.Fatalf("pool switch off must not warm, calls = %d", calls) + } +} diff --git a/internal/upstream/client.go b/internal/upstream/client.go index 6c68c85..ff62ce5 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -411,7 +411,11 @@ func (c *Client) chatWithRetries(ctx context.Context, modelID string, model conf for attempt := 0; ; attempt++ { var failed []Route for i, route := range routes { - allowSlow := i == 0 && len(routes) > 1 + // A first-time Turnstile solve can far exceed the slow-start + // budget; only arm the timer when cookies are already cached + // (or no solver is configured at all). + cookiesReady := c.ts == nil || c.ts.CookiesReady(site.Code, route.Proxy) + allowSlow := i == 0 && len(routes) > 1 && cookiesReady err := c.chatOnceSlowStart(ctx, site, modelID, model, prompt, route, allowSlow, info, yield) if err == nil { if sawQuota {
${h.ok ? "成功" : "失败"}${h.error ? ` ${esc(h.error.slice(0, 70))}` : ""}
尚无记录