/** * Turnstile token resolver — 三層優先策略: * * 1. 自建 Playwright 求解器(playwright_solver_url 已設定) * 2. CapSolver API(capsolver_api_key 已設定) * 3. 回退 bypass token(必然失敗,但有清楚錯誤訊息) * * Token 快取 4.5 分鐘(geminigen.ai Turnstile token 約 5 分鐘有效)。 */ import { db, configTable } from "@workspace/db"; import { eq } from "drizzle-orm"; const CAPSOLVER_URL = "https://api.capsolver.com"; const YESCAPTCHA_URL = "https://api.yescaptcha.com"; const TURNSTILE_SITE_KEY = "0x4AAAAAACDBydnKT0zYzh2H"; const TURNSTILE_PAGE_URL = "https://geminigen.ai"; const TOKEN_TTL_MS = 270_000; // 4.5 minutes interface CachedToken { token: string; expiresAt: number; } let cachedToken: CachedToken | null = null; // ─── DB helper ──────────────────────────────────────────────────────────────── async function getConfig(key: string): Promise { try { const rows = await db .select() .from(configTable) .where(eq(configTable.key, key)) .limit(1); return rows[0]?.value?.trim() || null; } catch { return null; } } // ─── Strategy 1: Self-hosted Playwright solver ──────────────────────────────── async function solveWithPlaywright(solverUrl: string): Promise { try { const solverSecret = await getConfig("playwright_solver_secret"); const headers: Record = { "Content-Type": "application/json", }; if (solverSecret) headers["X-Solver-Secret"] = solverSecret; console.log(`[captcha] Calling Playwright solver: ${solverUrl}`); const resp = await fetch(solverUrl, { method: "POST", headers, body: JSON.stringify({}), signal: AbortSignal.timeout(55_000), }); const data = (await resp.json()) as { token?: string; cached?: boolean; solvedInMs?: number; error?: string; }; if (!resp.ok || !data.token) { console.error( `[captcha] Playwright solver error (${resp.status}): ${data.error || "no token"}` ); return null; } console.log( `[captcha] Playwright solved — cached=${data.cached}, ms=${data.solvedInMs ?? "N/A"}` ); return data.token; } catch (err: any) { console.error(`[captcha] Playwright solver fetch error: ${err?.message}`); return null; } } // ─── Strategy 2: CapSolver API ──────────────────────────────────────────────── async function solveWithCapsolver(apiKey: string): Promise { try { const createResp = await fetch(`${CAPSOLVER_URL}/createTask`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientKey: apiKey, task: { type: "AntiTurnstileTaskProxyless", websiteURL: TURNSTILE_PAGE_URL, websiteKey: TURNSTILE_SITE_KEY, }, }), }); const createData = (await createResp.json()) as { errorId?: number; errorCode?: string; errorDescription?: string; taskId?: string; }; if (createData.errorId || !createData.taskId) { console.error( `[captcha] CapSolver create error: ${createData.errorCode} — ${createData.errorDescription}` ); return null; } const taskId = createData.taskId; console.log(`[captcha] CapSolver task created: ${taskId}`); const maxWait = 120_000; const start = Date.now(); while (Date.now() - start < maxWait) { await new Promise((r) => setTimeout(r, 3000)); const resultResp = await fetch(`${CAPSOLVER_URL}/getTaskResult`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientKey: apiKey, taskId }), }); const resultData = (await resultResp.json()) as { errorId?: number; errorCode?: string; status?: string; solution?: { token?: string }; }; if (resultData.errorId) { console.error(`[captcha] CapSolver result error: ${resultData.errorCode}`); return null; } if (resultData.status === "ready") { const token = resultData.solution?.token; if (token) { console.log(`[captcha] CapSolver solved! Token: ${token.substring(0, 20)}...`); return token; } return null; } } console.error("[captcha] CapSolver timed out after 120s"); return null; } catch (err: any) { console.error(`[captcha] CapSolver error: ${err?.message}`); return null; } } // ─── Strategy 3: YesCaptcha API ────────────────────────────────────────────── async function solveWithYesCaptcha(apiKey: string): Promise { try { const createResp = await fetch(`${YESCAPTCHA_URL}/createTask`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientKey: apiKey, task: { type: "TurnstileTaskProxylessM1", websiteURL: TURNSTILE_PAGE_URL, websiteKey: TURNSTILE_SITE_KEY, }, }), }); const createData = (await createResp.json()) as { errorId?: number; errorCode?: string; errorDescription?: string; taskId?: string; }; if (createData.errorId || !createData.taskId) { console.error( `[captcha] YesCaptcha create error: ${createData.errorCode} — ${createData.errorDescription}` ); return null; } const taskId = createData.taskId; console.log(`[captcha] YesCaptcha task created: ${taskId}`); const maxWait = 120_000; const start = Date.now(); while (Date.now() - start < maxWait) { await new Promise((r) => setTimeout(r, 3000)); const resultResp = await fetch(`${YESCAPTCHA_URL}/getTaskResult`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientKey: apiKey, taskId }), }); const resultData = (await resultResp.json()) as { errorId?: number; errorCode?: string; status?: string; solution?: { token?: string }; }; if (resultData.errorId) { console.error(`[captcha] YesCaptcha result error: ${resultData.errorCode}`); return null; } if (resultData.status === "ready") { const token = resultData.solution?.token; if (token) { console.log(`[captcha] YesCaptcha solved! Token: ${token.substring(0, 20)}...`); return token; } return null; } } console.error("[captcha] YesCaptcha timed out after 120s"); return null; } catch (err: any) { console.error(`[captcha] YesCaptcha error: ${err?.message}`); return null; } } // ─── Public API ─────────────────────────────────────────────────────────────── /** * Get a valid Cloudflare Turnstile token for geminigen.ai. * * Priority: * 1. Self-hosted Playwright solver (playwright_solver_url in config) — 免費 * 2. YesCaptcha API (yescaptcha_api_key in config) — 有免費額度 * 3. CapSolver API (capsolver_api_key in config) — 付費備援 * 4. Bypass placeholder (will fail — no solver configured) */ export async function getTurnstileToken(): Promise { if (cachedToken && cachedToken.expiresAt > Date.now()) { console.log("[captcha] Using cached Turnstile token"); return cachedToken.token; } let token: string | null = null; // Strategy 1: Self-hosted Playwright solver const solverUrl = await getConfig("playwright_solver_url"); if (solverUrl) { token = await solveWithPlaywright(solverUrl); if (token) { cachedToken = { token, expiresAt: Date.now() + TOKEN_TTL_MS }; return token; } console.warn("[captcha] Playwright solver failed — trying YesCaptcha"); } // Strategy 2: YesCaptcha const yescaptchaKey = await getConfig("yescaptcha_api_key"); if (yescaptchaKey) { token = await solveWithYesCaptcha(yescaptchaKey); if (token) { cachedToken = { token, expiresAt: Date.now() + TOKEN_TTL_MS }; return token; } console.warn("[captcha] YesCaptcha failed — trying CapSolver"); } // Strategy 3: CapSolver const capsolverKey = await getConfig("capsolver_api_key"); if (capsolverKey) { token = await solveWithCapsolver(capsolverKey); if (token) { cachedToken = { token, expiresAt: Date.now() + TOKEN_TTL_MS }; return token; } console.warn("[captcha] CapSolver failed — no further fallback"); } if (!solverUrl && !yescaptchaKey && !capsolverKey) { console.warn( "[captcha] No Turnstile solver configured — set playwright_solver_url, yescaptcha_api_key, or capsolver_api_key in Admin → Config" ); } return "TURNSTILE_BYPASS"; } /** Force-clear the cached token (e.g., if geminigen.ai rejects it) */ export function invalidateTurnstileToken(): void { cachedToken = null; }