/** * 付费 Captcha API 兜底:CapSolver / 2Captcha 统一 Turnstile 求解接口 */ const { CAPTCHA_PROVIDER, CAPTCHA_API_KEY, CAPTCHA_MAX_WAIT_MS, } = require('./constants'); const { sanitizePageUrlForCfSolver } = require('./cf-detector'); /** AntiCloudflareTask 所需静态代理(ip:port:user:pass),未配置则不可用 */ const CAPTCHA_PROXY = (process.env.CAPTCHA_PROXY || '').trim(); /** * @param {string} url * @param {Record} [headers] * @returns {Promise} */ async function httpJsonPost(url, body, headers = {}) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }); const text = await res.text(); try { return JSON.parse(text); } catch (_) { throw new Error(`Captcha API 返回非 JSON: ${text.slice(0, 200)}`); } } /** * @param {string} url * @returns {Promise} */ async function httpJsonGet(url) { const res = await fetch(url); const text = await res.text(); try { return JSON.parse(text); } catch (_) { throw new Error(`Captcha API 返回非 JSON: ${text.slice(0, 200)}`); } } /** * CapSolver Turnstile 求解 * @param {{ pageUrl: string, sitekey: string, apiKey: string }} params * @returns {Promise} */ async function solveViaCapSolver({ pageUrl, sitekey, apiKey }) { const create = await httpJsonPost('https://api.capsolver.com/createTask', { clientKey: apiKey, task: { type: 'AntiTurnstileTaskProxyLess', websiteURL: pageUrl, websiteKey: sitekey, }, }); if (create.errorId !== 0 || !create.taskId) { throw new Error(`CapSolver createTask 失败: ${create.errorDescription || create.errorCode || 'unknown'}`); } const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 3000)); const result = await httpJsonPost('https://api.capsolver.com/getTaskResult', { clientKey: apiKey, taskId: create.taskId, }); if (result.status === 'ready' && result.solution && result.solution.token) { return result.solution.token; } if (result.errorId !== 0) { throw new Error(`CapSolver getTaskResult 失败: ${result.errorDescription || result.errorCode}`); } } throw new Error('CapSolver 求解超时'); } /** * 2Captcha Turnstile 求解 * @param {{ pageUrl: string, sitekey: string, apiKey: string }} params * @returns {Promise} */ async function solveVia2Captcha({ pageUrl, sitekey, apiKey }) { const params = new URLSearchParams({ key: apiKey, method: 'turnstile', sitekey, pageurl: pageUrl, json: '1', }); const create = await httpJsonGet(`https://2captcha.com/in.php?${params.toString()}`); if (create.status !== 1 || !create.request) { throw new Error(`2Captcha in.php 失败: ${create.request || create.error_text || 'unknown'}`); } const taskId = create.request; const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 5000)); const result = await httpJsonGet( `https://2captcha.com/res.php?key=${encodeURIComponent(apiKey)}&action=get&id=${encodeURIComponent(taskId)}&json=1` ); if (result.status === 1 && result.request) { return result.request; } if (result.request && result.request !== 'CAPCHA_NOT_READY') { throw new Error(`2Captcha res.php 失败: ${result.request}`); } } throw new Error('2Captcha 求解超时'); } /** * CapSolver Managed Challenge(返回 cf_clearance cookie,需 CAPTCHA_PROXY) * @param {{ pageUrl: string, apiKey: string, proxy: string, html?: string, userAgent?: string }} params * @returns {Promise<{ cookies: Record, userAgent?: string }>} */ async function solveViaCapSolverCloudflare({ pageUrl, apiKey, proxy, html, userAgent }) { const task = { type: 'AntiCloudflareTask', websiteURL: pageUrl, proxy, }; if (html) { task.html = html.slice(0, 500000); } if (userAgent) { task.userAgent = userAgent; } const create = await httpJsonPost('https://api.capsolver.com/createTask', { clientKey: apiKey, task, }); if (create.errorId !== 0 || !create.taskId) { throw new Error(`CapSolver AntiCloudflare createTask 失败: ${create.errorDescription || create.errorCode || 'unknown'}`); } const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 3000)); const result = await httpJsonPost('https://api.capsolver.com/getTaskResult', { clientKey: apiKey, taskId: create.taskId, }); if (result.status === 'ready' && result.solution) { const cookies = result.solution.cookies || {}; if (typeof cookies === 'string') { return { cookies: { cf_clearance: cookies }, userAgent: result.solution.userAgent }; } if (result.solution.token && !cookies.cf_clearance) { return { cookies: { cf_clearance: result.solution.token }, userAgent: result.solution.userAgent, }; } return { cookies, userAgent: result.solution.userAgent, }; } if (result.errorId !== 0) { throw new Error(`CapSolver AntiCloudflare getTaskResult 失败: ${result.errorDescription || result.errorCode}`); } } throw new Error('CapSolver AntiCloudflare 求解超时'); } /** * 统一 Cloudflare Managed Challenge 求解 * @param {{ pageUrl: string, provider?: string, apiKey?: string, proxy?: string, html?: string, userAgent?: string }} params * @returns {Promise<{ cookies: Record, userAgent?: string, provider: string }>} */ async function solveCloudflare({ pageUrl, provider, apiKey, proxy, html, userAgent }) { const resolvedProvider = (provider || CAPTCHA_PROVIDER).toLowerCase(); const resolvedKey = apiKey || CAPTCHA_API_KEY; const resolvedProxy = (proxy || CAPTCHA_PROXY).trim(); const cleanPageUrl = sanitizePageUrlForCfSolver(pageUrl); if (!resolvedKey) { throw new Error('未配置 CAPTCHA_API_KEY,无法使用 AntiCloudflareTask'); } if (!resolvedProxy) { throw new Error('AntiCloudflareTask 需要配置 CAPTCHA_PROXY(静态代理 ip:port:user:pass)'); } if (resolvedProvider !== 'capsolver') { throw new Error('AntiCloudflareTask 当前仅支持 CapSolver'); } const { cookies, userAgent: solvedUa } = await solveViaCapSolverCloudflare({ pageUrl: cleanPageUrl, apiKey: resolvedKey, proxy: resolvedProxy, html, userAgent, }); if (!cookies || !cookies.cf_clearance) { throw new Error('CapSolver AntiCloudflare 未返回 cf_clearance'); } return { cookies, userAgent: solvedUa || userAgent, provider: resolvedProvider }; } /** * 将 CapSolver AntiCloudflare 返回的 cookie / UA 写入页面并 reload * @param {import('puppeteer').Page} page * @param {string} cleanUrl * @param {{ cookies: Record, userAgent?: string }} solution */ async function applyCfClearanceSolution(page, cleanUrl, solution) { if (solution.userAgent) { await page.setUserAgent(solution.userAgent).catch(() => {}); } let host = ''; let path = '/'; try { const parsed = new URL(cleanUrl); host = parsed.hostname; path = parsed.pathname || '/'; } catch (_) { host = ''; } for (const [name, value] of Object.entries(solution.cookies || {})) { if (!value) { continue; } const cookieDomain = host.startsWith('.') ? host : `.${host}`; await page.setCookie({ name, value: String(value), domain: cookieDomain, path: '/', secure: true, sameSite: 'None', }).catch(() => {}); } console.log(`[CF] cf_clearance 已注入,goto 业务页 url=${cleanUrl}`); await page.goto(cleanUrl, { waitUntil: 'domcontentloaded', timeout: 60000, }).catch(() => {}); await page.waitForNetworkIdle({ idleTime: 500, timeout: 30000 }).catch(() => { console.warn('[CF] cf_clearance goto 后 networkidle 超时,继续后续流程'); }); } /** * 统一 Turnstile 求解入口 * @param {{ pageUrl: string, sitekey: string, provider?: string, apiKey?: string }} params * @returns {Promise<{ token: string, provider: string }>} */ async function solveTurnstile({ pageUrl, sitekey, provider, apiKey }) { const resolvedProvider = (provider || CAPTCHA_PROVIDER).toLowerCase(); const resolvedKey = apiKey || CAPTCHA_API_KEY; const cleanPageUrl = sanitizePageUrlForCfSolver(pageUrl); if (!sitekey) { throw new Error('无法提取 Turnstile sitekey'); } if (!resolvedKey) { throw new Error('未配置 CAPTCHA_API_KEY,无法使用付费 Captcha 兜底'); } let token; if (resolvedProvider === '2captcha') { token = await solveVia2Captcha({ pageUrl: cleanPageUrl, sitekey, apiKey: resolvedKey }); } else { token = await solveViaCapSolver({ pageUrl: cleanPageUrl, sitekey, apiKey: resolvedKey }); } return { token, provider: resolvedProvider }; } /** * Captcha API 是否已配置 * @returns {boolean} */ function isCaptchaConfigured() { return CAPTCHA_API_KEY.length > 0; } module.exports = { solveTurnstile, solveCloudflare, applyCfClearanceSolution, isCaptchaConfigured, };