Files
2026-07-03 20:12:20 +08:00

319 lines
11 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cloudflare 挑战统一处理:检测 → 会话快速通道 → 内置等待 → Captcha API 兜底
*
* 当 antiBot.postCfReadyUrlContains 已配置时,session_reuse 仅在业务 URL 已到达时才走快速通道;
* 否则清除可能过期的 cf_clearance 并重新触发 TurnstileWhatshub 短链 → work-order-sharing 场景)。
*/
const { detectCloudflare } = require('./cf-detector');
const { waitForTurnstile } = require('./turnstile-handler');
const { isCaptchaConfigured } = require('./captcha-solver');
const { runCaptchaApiFallback } = require('./cf-api-fallback');
const { isSessionLikelyValid } = require('./session-store');
/**
* 是否配置了 CF 后必须到达的业务 URL
* @param {object} antiBot
*/
function needsPostCfBusinessUrl(antiBot) {
return !!(antiBot?.postCfReadyUrlContains || '').trim();
}
/**
* 当前页面 URL 是否已包含业务路径片段
* @param {import('puppeteer').Page} page
* @param {object} antiBot
*/
function isPostCfBusinessUrlReady(page, antiBot) {
const needle = (antiBot?.postCfReadyUrlContains || '').trim();
if (!needle) {
return true;
}
return page.url().includes(needle);
}
/**
* 清除 cf_clearance,避免「有 cookie 但 SPA 未触发跳转」的假过关
* @param {import('puppeteer').Page} page
*/
async function clearCfClearanceCookies(page) {
const cookies = await page.cookies();
for (const c of cookies) {
if (c.name !== 'cf_clearance') {
continue;
}
const host = (c.domain || '').replace(/^\./, '');
if (host) {
await page.deleteCookie({ name: c.name, url: `https://${host}/` }).catch(() => {});
}
await page.deleteCookie({
name: c.name,
domain: c.domain,
path: c.path || '/',
}).catch(() => {});
}
}
/**
* reload 后检查业务 URL 是否就绪
* @param {import('puppeteer').Page} page
* @param {(page: import('puppeteer').Page) => Promise<string>} [waitForUrlSettled]
* @param {number} timeoutMs
* @param {object} antiBot
*/
async function tryPostCfRecoverViaReload(page, waitForUrlSettled, timeoutMs, antiBot) {
if (!needsPostCfBusinessUrl(antiBot) || isPostCfBusinessUrlReady(page, antiBot)) {
return isPostCfBusinessUrlReady(page, antiBot);
}
console.log(`[CF] 业务 URL 未就绪 current=${page.url()},尝试 reload`);
try {
await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.min(timeoutMs, 45000) });
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
return isPostCfBusinessUrlReady(page, antiBot);
} catch (reloadErr) {
console.warn('[CF] reload 失败:', reloadErr.message);
return false;
}
}
/**
* 清除 cf_clearance 并 reload,使 Turnstile 重新出现
* @param {import('puppeteer').Page} page
* @param {(page: import('puppeteer').Page) => Promise<string>} [waitForUrlSettled]
* @param {number} timeoutMs
*/
async function invalidateStaleCfAndReload(page, waitForUrlSettled, timeoutMs, antiBot = null) {
console.log('[CF] 清除 cf_clearance 并 reload,重新触发 Turnstile');
await clearCfClearanceCookies(page);
await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.min(timeoutMs, 45000) });
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
return detectCloudflare(page, antiBot);
}
/**
* session_reuse 快速返回前校验:有 postCf 要求时必须已到业务页
* @param {import('puppeteer').Page} page
* @param {object} antiBot
* @param {(page: import('puppeteer').Page) => Promise<string>} [waitForUrlSettled]
* @param {number} timeoutMs
* @param {number} started
* @param {boolean} cfDetected
*/
async function trySessionReuseFastPath(page, antiBot, waitForUrlSettled, timeoutMs, started, cfDetected) {
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
if (!needsPostCfBusinessUrl(antiBot) || isPostCfBusinessUrlReady(page, antiBot)) {
return {
success: true,
code: null,
challengeType: null,
stage: 'session_reuse',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected,
};
}
console.log(`[CF] cf_clearance/无挑战但业务页未就绪 url=${page.url()},降级重试`);
if (await tryPostCfRecoverViaReload(page, waitForUrlSettled, timeoutMs, antiBot)) {
return {
success: true,
code: null,
challengeType: null,
stage: 'session_reuse_reload',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected,
};
}
return null;
}
/**
* @param {import('puppeteer').Page} page
* @param {{ turnstile?: boolean, solverFallback?: boolean, challengeTimeoutMs?: number, postCfReadyUrlContains?: string }} antiBot
* @param {(page: import('puppeteer').Page) => Promise<string>} [waitForUrlSettled]
* @param {import('./session-store').StoredSession|null} [storedSession]
*/
async function handleCloudflareChallenge(page, antiBot, waitForUrlSettled, storedSession = null) {
const started = Date.now();
const timeoutMs = antiBot.challengeTimeoutMs || 60000;
let cfState = await detectCloudflare(page, antiBot);
if (!cfState.blocked && cfState.hasCfClearance) {
const fast = await trySessionReuseFastPath(
page, antiBot, waitForUrlSettled, timeoutMs, started, false
);
if (fast) {
return fast;
}
cfState = await invalidateStaleCfAndReload(page, waitForUrlSettled, timeoutMs, antiBot);
}
if (!cfState.blocked) {
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
if (!needsPostCfBusinessUrl(antiBot) || isPostCfBusinessUrlReady(page, antiBot)) {
return {
success: true,
code: null,
challengeType: null,
stage: storedSession && isSessionLikelyValid(storedSession) ? 'session_reuse' : null,
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: false,
};
}
if (await tryPostCfRecoverViaReload(page, waitForUrlSettled, timeoutMs, antiBot)) {
return {
success: true,
code: null,
challengeType: null,
stage: 'post_nav_reload',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: false,
};
}
if (cfState.hasCfClearance) {
cfState = await invalidateStaleCfAndReload(page, waitForUrlSettled, timeoutMs, antiBot);
} else {
return {
success: true,
code: null,
challengeType: null,
stage: 'pending_business_nav',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: false,
};
}
}
if (storedSession && isSessionLikelyValid(storedSession)) {
console.log('[CF] 会话有效但仍被拦截,尝试 reload 一次');
try {
await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.min(timeoutMs, 45000) });
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
cfState = await detectCloudflare(page, antiBot);
if (!cfState.blocked) {
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
return {
success: true,
code: null,
challengeType: cfState.challengeType,
stage: 'session_reload',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
} catch (reloadErr) {
console.warn('[CF] reload 失败:', reloadErr.message);
}
}
console.log(`[CF] 检测到挑战 type=${cfState.challengeType} url=${cfState.url}`);
if (antiBot.turnstile !== false) {
const builtIn = await waitForTurnstile(page, { timeoutMs, useBuiltInClick: true });
if (builtIn.success) {
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
cfState = await detectCloudflare(page, antiBot);
if (!cfState.blocked) {
return {
success: true,
code: null,
challengeType: cfState.challengeType,
stage: 'built_in',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
}
}
if (antiBot.solverFallback !== false && isCaptchaConfigured()) {
try {
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
await runCaptchaApiFallback(page, {
antiBot,
navUrl: page.url(),
waitForUrlSettled,
timeoutMs,
});
const apiWaitMs = Math.min(timeoutMs, 30000);
await waitForTurnstile(page, { timeoutMs: apiWaitMs });
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
cfState = await detectCloudflare(page, antiBot);
if (!cfState.blocked) {
return {
success: true,
code: null,
challengeType: 'turnstile',
stage: 'api',
solverUsed: true,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
} catch (apiErr) {
console.error('[CF] Captcha API 兜底失败:', apiErr.message);
return {
success: false,
code: 'CF_TURNSTILE_FAILED',
challengeType: cfState.challengeType || 'turnstile',
stage: 'api',
solverUsed: true,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
}
return {
success: false,
code: 'CF_TURNSTILE_FAILED',
challengeType: cfState.challengeType || 'unknown',
stage: antiBot.solverFallback !== false && !isCaptchaConfigured() ? 'built_in' : 'timeout',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
module.exports = {
handleCloudflareChallenge,
isCaptchaConfigured,
needsPostCfBusinessUrl,
isPostCfBusinessUrlReady,
clearCfClearanceCookies,
};