2026-06-29 04:54:41 +08:00
|
|
|
|
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
|
|
|
|
console.error('[FATAL] uncaughtException:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
|
|
|
|
console.error('[FATAL] unhandledRejection:', reason);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
const express = require('express');
|
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
|
const {
|
|
|
|
|
|
rewriteHttpsToHttpForLiteralIp,
|
|
|
|
|
|
attachHttpIpRewriteInterceptor,
|
|
|
|
|
|
} = require('./http-ip-rewrite');
|
|
|
|
|
|
const {
|
|
|
|
|
|
resolveHttpRedirects,
|
|
|
|
|
|
extractShareTokenFromUrl,
|
|
|
|
|
|
} = require('./url-resolve');
|
|
|
|
|
|
const {
|
|
|
|
|
|
DEFAULT_UA,
|
|
|
|
|
|
RUNTIME_DIR,
|
|
|
|
|
|
resolveChromeExecutable,
|
|
|
|
|
|
MAX_CONCURRENT_BROWSERS,
|
|
|
|
|
|
MAX_CONCURRENT_BROWSERS_REAL,
|
|
|
|
|
|
API_INTERCEPT_TIMEOUT_MS,
|
|
|
|
|
|
API_INTERCEPT_TIMEOUT_REAL_MS,
|
2026-06-20 15:49:40 +08:00
|
|
|
|
NAVIGATION_TIMEOUT_MS,
|
|
|
|
|
|
NAVIGATION_WAIT_UNTIL,
|
|
|
|
|
|
NAVIGATION_MAX_RETRIES,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
PAGE_DEFAULT_TIMEOUT_MS,
|
2026-06-20 15:49:40 +08:00
|
|
|
|
SESSION_TTL_MS,
|
|
|
|
|
|
PERSIST_BROWSER_PROFILE,
|
|
|
|
|
|
POOL_IDLE_MS,
|
|
|
|
|
|
MAX_POOLED_BROWSERS,
|
2026-06-20 04:47:34 +08:00
|
|
|
|
} = require('./lib/constants');
|
|
|
|
|
|
const {
|
|
|
|
|
|
normalizeAntiBot,
|
|
|
|
|
|
createBrowserSession,
|
2026-06-20 15:49:40 +08:00
|
|
|
|
acquireBrowserSession,
|
2026-06-20 04:47:34 +08:00
|
|
|
|
runWithProfileLimit,
|
|
|
|
|
|
createManagedPage,
|
|
|
|
|
|
getFactoryStats,
|
|
|
|
|
|
standardLimiter,
|
2026-06-20 15:49:40 +08:00
|
|
|
|
browserPool,
|
2026-06-20 04:47:34 +08:00
|
|
|
|
} = require('./lib/browser-factory');
|
|
|
|
|
|
const { launchStandardBrowser } = require('./lib/launch-standard');
|
|
|
|
|
|
const { isRealBrowserAvailable } = require('./lib/launch-real-browser');
|
|
|
|
|
|
const { handleCloudflareChallenge, isCaptchaConfigured } = require('./lib/cf-handler');
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const { detectCloudflare, urlIndicatesCfChallenge } = require('./lib/cf-detector');
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const { runCaptchaApiFallback, shouldPreferManagedCloudflareSolver } = require('./lib/cf-api-fallback');
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
getCaptchaProxyChromeArgs,
|
|
|
|
|
|
applyCaptchaProxyToPage,
|
|
|
|
|
|
parseCaptchaProxy,
|
|
|
|
|
|
hasCaptchaProxy,
|
|
|
|
|
|
} = require('./lib/captcha-proxy');
|
|
|
|
|
|
const { attachSitekeyCapture } = require('./lib/sitekey-capture');
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const {
|
|
|
|
|
|
saveSession,
|
|
|
|
|
|
getSessionDebugInfo,
|
|
|
|
|
|
getSessionStats,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
isSessionLikelyValid,
|
2026-06-20 15:49:40 +08:00
|
|
|
|
} = require('./lib/session-store');
|
|
|
|
|
|
const { resolveSessionContext, touchSessionIfPresent } = require('./lib/session-context');
|
|
|
|
|
|
const { getProfileStats, profileExists } = require('./lib/profile-dir');
|
2026-07-02 03:13:27 +08:00
|
|
|
|
const {
|
|
|
|
|
|
isHuojianLongLinkUrl,
|
|
|
|
|
|
isHuojianShortEntryUrl,
|
|
|
|
|
|
applyHuojianBusinessHostHint,
|
|
|
|
|
|
} = require('./lib/huojian-url');
|
2026-07-02 19:03:06 +08:00
|
|
|
|
const { runUiPagination } = require('./lib/ui-pagination-runner');
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
|
app.use(express.json({ limit: '50mb' }));
|
|
|
|
|
|
|
|
|
|
|
|
let shuttingDown = false;
|
|
|
|
|
|
|
|
|
|
|
|
/** HTTP 层:槽位排队 + 队列超时/关闭中 的统一错误响应 */
|
|
|
|
|
|
async function runBrowserTask(taskName, handler, profile = 'standard') {
|
|
|
|
|
|
if (shuttingDown) {
|
|
|
|
|
|
const err = new Error('SERVICE_SHUTTING_DOWN');
|
|
|
|
|
|
err.code = 'SERVICE_SHUTTING_DOWN';
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return runWithProfileLimit(taskName, profile, handler);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sendTaskError(res, error, extra = {}) {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
if (error.code === 'QUEUE_TIMEOUT' || error.message === 'POOL_ACQUIRE_TIMEOUT') {
|
2026-06-20 04:47:34 +08:00
|
|
|
|
return res.status(503).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
error: '服务繁忙,排队超时,请稍后重试',
|
|
|
|
|
|
queue: standardLimiter.getStats(),
|
|
|
|
|
|
...extra,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (error.code === 'SERVICE_SHUTTING_DOWN') {
|
|
|
|
|
|
return res.status(503).json({ success: false, error: '服务正在关闭', ...extra });
|
|
|
|
|
|
}
|
|
|
|
|
|
const errType = classifyPuppeteerError(error);
|
|
|
|
|
|
console.error(`[${errType}]`, error.message);
|
2026-06-29 04:54:41 +08:00
|
|
|
|
const payload = { success: false, error: error.message, ...extra };
|
|
|
|
|
|
if (error.pageDiagnostics) {
|
|
|
|
|
|
payload.page = error.pageDiagnostics;
|
|
|
|
|
|
}
|
|
|
|
|
|
return res.status(500).json(payload);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
async function navigateToPage(page, url) {
|
|
|
|
|
|
await withExponentialBackoff(
|
|
|
|
|
|
() => page.goto(url, { waitUntil: NAVIGATION_WAIT_UNTIL, timeout: NAVIGATION_TIMEOUT_MS }),
|
|
|
|
|
|
{ maxRetries: NAVIGATION_MAX_RETRIES, baseDelayMs: 1000 }
|
|
|
|
|
|
);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function safeCloseBrowser(browser) {
|
|
|
|
|
|
if (!browser) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pages = await browser.pages();
|
|
|
|
|
|
await Promise.all(pages.map((p) => p.close().catch(() => {})));
|
|
|
|
|
|
await browser.close();
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const proc = browser.process();
|
|
|
|
|
|
if (proc && !proc.killed) proc.kill('SIGKILL');
|
|
|
|
|
|
} catch (_) { /* ignore */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function classifyPuppeteerError(err) {
|
|
|
|
|
|
const msg = (err?.message || '').toLowerCase();
|
|
|
|
|
|
if (err?.name === 'TimeoutError' || msg.includes('timeout')) return 'timeout';
|
|
|
|
|
|
if (msg.includes('net::') || msg.includes('network') || msg.includes('err_connection')
|
|
|
|
|
|
|| msg.includes('err_address_unreachable') || msg.includes('err_name_not_resolved')) return 'network';
|
2026-07-03 20:12:20 +08:00
|
|
|
|
if (msg.includes('navigation') || msg.includes('frame was detached')
|
|
|
|
|
|
|| msg.includes('detached frame') || msg.includes('attempted to use detached')) return 'navigation';
|
2026-06-20 04:47:34 +08:00
|
|
|
|
if (msg.includes('target closed') || msg.includes('session closed')) return 'target_closed';
|
|
|
|
|
|
return 'unknown';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isRetryableError(err) {
|
|
|
|
|
|
const type = classifyPuppeteerError(err);
|
|
|
|
|
|
return type === 'timeout' || type === 'network' || type === 'navigation';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function withExponentialBackoff(fn, {
|
|
|
|
|
|
maxRetries = 3,
|
|
|
|
|
|
baseDelayMs = 500,
|
|
|
|
|
|
maxDelayMs = 8000,
|
|
|
|
|
|
shouldRetry = isRetryableError,
|
|
|
|
|
|
} = {}) {
|
|
|
|
|
|
let lastError;
|
|
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await fn(attempt);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
lastError = err;
|
|
|
|
|
|
if (attempt >= maxRetries || !shouldRetry(err)) throw err;
|
|
|
|
|
|
const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
throw lastError;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function waitForUrlSettled(page, { maxAttempts = 15, stableMs = 500, redirectSettleMs = 1500 } = {}) {
|
|
|
|
|
|
let currentUrl = page.url();
|
|
|
|
|
|
let urlSettled = false;
|
|
|
|
|
|
let settleAttempts = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (!urlSettled && settleAttempts < maxAttempts) {
|
|
|
|
|
|
const urlBeforeWait = currentUrl;
|
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
|
(expectedUrl) => window.location.href === expectedUrl,
|
|
|
|
|
|
{ timeout: stableMs, polling: 100 },
|
|
|
|
|
|
urlBeforeWait
|
|
|
|
|
|
).catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
const newUrl = page.url();
|
|
|
|
|
|
if (newUrl === currentUrl) {
|
|
|
|
|
|
urlSettled = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`[路由重定向探测] 页面从 ${currentUrl} 跳转到了 ${newUrl}`);
|
|
|
|
|
|
currentUrl = newUrl;
|
|
|
|
|
|
await page.waitForNetworkIdle({ idleTime: 300, timeout: redirectSettleMs }).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
settleAttempts++;
|
|
|
|
|
|
}
|
|
|
|
|
|
return currentUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
/** 合并 antiBot 扩展字段(postCfReady* 由 PHP 按工单类型传入,未配置则跳过) */
|
|
|
|
|
|
function resolveAntiBot(rawAntiBot) {
|
|
|
|
|
|
const base = normalizeAntiBot(rawAntiBot);
|
|
|
|
|
|
if (!rawAntiBot || !base.enabled) {
|
|
|
|
|
|
return base;
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
...base,
|
|
|
|
|
|
postCfReadyUrlContains: rawAntiBot.postCfReadyUrlContains || '',
|
|
|
|
|
|
postCfReadySelector: rawAntiBot.postCfReadySelector || '',
|
2026-07-03 20:12:20 +08:00
|
|
|
|
postCfReadyApiUrl: rawAntiBot.postCfReadyApiUrl || '',
|
2026-07-01 01:26:27 +08:00
|
|
|
|
postCfReadyTimeoutMs: rawAntiBot.postCfReadyTimeoutMs || 0,
|
|
|
|
|
|
postCfSettleMs: rawAntiBot.postCfSettleMs || 0,
|
|
|
|
|
|
postCfSpaWaitMs: rawAntiBot.postCfSpaWaitMs || 0,
|
|
|
|
|
|
cfClearanceRequired: rawAntiBot.cfClearanceRequired !== false,
|
|
|
|
|
|
businessHostHint: rawAntiBot.businessHostHint || '',
|
|
|
|
|
|
landingUrlHint: rawAntiBot.landingUrlHint || '',
|
2026-07-03 20:12:20 +08:00
|
|
|
|
turnstileSitekey: rawAntiBot.turnstileSitekey || '',
|
|
|
|
|
|
cfCloudflareSolver: rawAntiBot.cfCloudflareSolver !== false,
|
|
|
|
|
|
cfChallengeMode: rawAntiBot.cfChallengeMode || '',
|
2026-07-01 01:26:27 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whatshub 短链场景:PHP 未传 postCfReady* 时 Node 侧自动补全(避免 session_reuse 后 15s auth 超时)
|
|
|
|
|
|
* @param {object} antiBot
|
|
|
|
|
|
* @param {string} pageUrl
|
|
|
|
|
|
*/
|
|
|
|
|
|
function applyWhatshubPostCfDefaults(antiBot, pageUrl) {
|
|
|
|
|
|
if (!antiBot || !antiBot.enabled) {
|
|
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((antiBot.postCfReadyUrlContains || '').trim()) {
|
|
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = new URL(pageUrl || '');
|
2026-07-05 02:13:06 +08:00
|
|
|
|
const isWhatshubHost = parsed.hostname.includes('whatshub.cc');
|
|
|
|
|
|
const isShortLink = parsed.pathname.includes('/m/');
|
|
|
|
|
|
const isLongLink = pageUrl.includes('work-order-sharing');
|
|
|
|
|
|
if (!isWhatshubHost || (!isShortLink && !isLongLink)) {
|
2026-07-01 01:26:27 +08:00
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
2026-07-05 02:13:06 +08:00
|
|
|
|
console.log('[CF] Whatshub 自动启用 postCfReady 默认配置');
|
2026-07-01 01:26:27 +08:00
|
|
|
|
return {
|
|
|
|
|
|
...antiBot,
|
|
|
|
|
|
postCfReadyUrlContains: 'work-order-sharing',
|
|
|
|
|
|
postCfReadySelector: '.el-input__inner',
|
2026-07-05 02:13:06 +08:00
|
|
|
|
postCfReadyApiUrl: '/api/whatshub-counter/workShare/open/statistics',
|
|
|
|
|
|
postCfReadyTimeoutMs: antiBot.postCfReadyTimeoutMs || 60000,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
postCfSettleMs: antiBot.postCfSettleMs || 500,
|
2026-07-05 02:13:06 +08:00
|
|
|
|
postCfSpaWaitMs: antiBot.postCfSpaWaitMs || 4000,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
};
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A2C 短链 → user.a2c.chat:PHP 未传 postCfReady* 时 Node 侧自动补全
|
|
|
|
|
|
* @param {object} antiBot
|
|
|
|
|
|
* @param {string} pageUrl
|
|
|
|
|
|
*/
|
|
|
|
|
|
function applyA2cPostCfDefaults(antiBot, pageUrl) {
|
|
|
|
|
|
if (!antiBot || !antiBot.enabled) {
|
|
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((antiBot.postCfReadyUrlContains || '').trim()) {
|
|
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let shouldApply = false;
|
|
|
|
|
|
const sessionKey = String(antiBot.sessionKey || '').toLowerCase();
|
|
|
|
|
|
if (sessionKey.startsWith('a2c:')) {
|
|
|
|
|
|
shouldApply = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = new URL(pageUrl || '');
|
|
|
|
|
|
if (parsed.hostname.includes('a2c.chat')) {
|
|
|
|
|
|
shouldApply = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!shouldApply) {
|
|
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[CF] A2C 自动启用 postCfReady 默认配置');
|
|
|
|
|
|
return {
|
|
|
|
|
|
...antiBot,
|
|
|
|
|
|
postCfReadyUrlContains: '/visitors/counter/share',
|
2026-07-03 20:12:20 +08:00
|
|
|
|
postCfReadySelector: '.el-table',
|
|
|
|
|
|
postCfReadyApiUrl: antiBot.postCfReadyApiUrl || '/api/talk/counter/share/record/list',
|
|
|
|
|
|
postCfReadyTimeoutMs: antiBot.postCfReadyTimeoutMs || 60000,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
postCfSettleMs: antiBot.postCfSettleMs || 500,
|
2026-07-03 20:12:20 +08:00
|
|
|
|
postCfSpaWaitMs: antiBot.postCfSpaWaitMs || 8000,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
cfClearanceRequired: antiBot.cfClearanceRequired === undefined ? false : antiBot.cfClearanceRequired,
|
2026-07-03 20:12:20 +08:00
|
|
|
|
solverFallback: antiBot.solverFallback === undefined ? true : antiBot.solverFallback,
|
|
|
|
|
|
cfCloudflareSolver: antiBot.cfCloudflareSolver !== false,
|
|
|
|
|
|
cfChallengeMode: antiBot.cfChallengeMode || 'managed',
|
2026-07-01 01:26:27 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 03:13:27 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 火箭短链 → 动态长链域 /gds?link=:PHP 未传 postCfReady* 时 Node 侧自动补全
|
|
|
|
|
|
* @param {object} antiBot
|
|
|
|
|
|
* @param {string} pageUrl
|
|
|
|
|
|
*/
|
|
|
|
|
|
function applyHuojianPostCfDefaults(antiBot, pageUrl) {
|
|
|
|
|
|
if (!antiBot || !antiBot.enabled) {
|
|
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((antiBot.postCfReadyUrlContains || '').trim()) {
|
|
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let shouldApply = false;
|
|
|
|
|
|
const sessionKey = String(antiBot.sessionKey || '').toLowerCase();
|
|
|
|
|
|
if (sessionKey.startsWith('huojian:')) {
|
|
|
|
|
|
shouldApply = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isHuojianLongLinkUrl(pageUrl) || isHuojianShortEntryUrl(pageUrl)) {
|
|
|
|
|
|
shouldApply = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!shouldApply) {
|
|
|
|
|
|
return antiBot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[CF] 火箭 自动启用 postCfReady 默认配置');
|
|
|
|
|
|
return {
|
|
|
|
|
|
...antiBot,
|
|
|
|
|
|
postCfReadyUrlContains: '/gds',
|
|
|
|
|
|
postCfReadySelector: '.el-message-box__input',
|
|
|
|
|
|
postCfReadyTimeoutMs: antiBot.postCfReadyTimeoutMs || 45000,
|
|
|
|
|
|
postCfSettleMs: antiBot.postCfSettleMs || 500,
|
|
|
|
|
|
postCfSpaWaitMs: antiBot.postCfSpaWaitMs || 8000,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 按 pageUrl 合并 antiBot(含 Whatshub / A2C / 火箭 默认 postCf) */
|
2026-07-01 01:26:27 +08:00
|
|
|
|
function resolveAntiBotForPage(rawAntiBot, pageUrl) {
|
2026-07-02 03:13:27 +08:00
|
|
|
|
return applyHuojianPostCfDefaults(
|
|
|
|
|
|
applyA2cPostCfDefaults(
|
|
|
|
|
|
applyWhatshubPostCfDefaults(resolveAntiBot(rawAntiBot), pageUrl),
|
|
|
|
|
|
pageUrl
|
|
|
|
|
|
),
|
2026-07-01 01:26:27 +08:00
|
|
|
|
pageUrl
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isBusinessUrlReady(page, urlNeedle) {
|
|
|
|
|
|
return !urlNeedle || page.url().includes(urlNeedle);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
/** A2C 已直达 user.a2c.chat 分享页(非 yyk.ink 短链跳转场景) */
|
|
|
|
|
|
function isA2cDirectSharePage(page, urlNeedle = '') {
|
|
|
|
|
|
const needle = (urlNeedle || '').trim();
|
|
|
|
|
|
if (!needle.includes('/visitors/counter/share')) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = new URL(page.url() || '');
|
|
|
|
|
|
return parsed.hostname.includes('a2c.chat') && parsed.pathname.includes('/visitors/counter/share');
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 过盾后等待业务 list API 返回(A2C Vue 挂载信号) */
|
|
|
|
|
|
async function waitForPostCfListApi(page, antiBot, timeoutMs) {
|
|
|
|
|
|
const apiUrl = (antiBot.postCfReadyApiUrl || '').trim();
|
|
|
|
|
|
if (!apiUrl) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[CF] 等待业务 API 响应 api=${apiUrl} timeout=${timeoutMs}ms`);
|
|
|
|
|
|
const matched = await page.waitForResponse(
|
|
|
|
|
|
(resp) => {
|
|
|
|
|
|
const req = resp.request();
|
|
|
|
|
|
if (req.method() === 'OPTIONS') {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return resp.url().includes(apiUrl) && resp.status() >= 200 && resp.status() < 400;
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: timeoutMs }
|
|
|
|
|
|
).catch(() => null);
|
|
|
|
|
|
if (matched) {
|
|
|
|
|
|
console.log(`[CF] 业务 API 已响应 status=${matched.status()} url=${matched.url()}`);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
console.warn('[CF] 业务 API 等待超时,降级为 DOM selector 检测');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 等待业务 DOM:先 attached 再 visible,避免 Vue 渲染中途误判 */
|
|
|
|
|
|
async function waitForPostCfSelector(page, selector, timeoutMs) {
|
|
|
|
|
|
await page.waitForSelector(selector, { timeout: timeoutMs, visible: false });
|
|
|
|
|
|
await page.waitForSelector(selector, { timeout: Math.min(15000, timeoutMs), visible: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
/** Whatshub 分享短链 /m/xxx/1 */
|
|
|
|
|
|
function isWhatshubShortLinkPath(url) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = new URL(url || '');
|
|
|
|
|
|
return parsed.hostname.includes('whatshub.cc') && parsed.pathname.includes('/m/');
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whatshub 短链 + Real Browser:不覆盖 UA、不预注入磁盘 cf_clearance
|
|
|
|
|
|
* @param {string} pageUrl
|
|
|
|
|
|
* @param {object} antiBot
|
|
|
|
|
|
* @param {object[]} mergedCookies
|
|
|
|
|
|
* @param {object} extraOpts
|
|
|
|
|
|
*/
|
|
|
|
|
|
function buildManagedPageOptions(pageUrl, antiBot, mergedCookies, extraOpts = {}) {
|
|
|
|
|
|
const opts = { ...extraOpts };
|
|
|
|
|
|
const isWhatshubShort = isWhatshubShortLinkPath(pageUrl);
|
|
|
|
|
|
const isRealProfile = antiBot?.profile === 'real';
|
|
|
|
|
|
|
|
|
|
|
|
if (isWhatshubShort && isRealProfile && antiBot?.enabled) {
|
|
|
|
|
|
opts.skipUserAgent = true;
|
|
|
|
|
|
const filtered = (mergedCookies || []).filter((c) => c.name !== 'cf_clearance');
|
|
|
|
|
|
if (filtered.length < (mergedCookies || []).length) {
|
|
|
|
|
|
console.log('[Whatshub] 短链首访:跳过磁盘 cf_clearance 预注入,避免 SPA 路由未触发');
|
|
|
|
|
|
}
|
|
|
|
|
|
opts.cookies = filtered;
|
|
|
|
|
|
return opts;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
opts.cookies = mergedCookies;
|
|
|
|
|
|
if (!opts.skipUserAgent && !opts.userAgent) {
|
|
|
|
|
|
opts.userAgent = DEFAULT_UA;
|
|
|
|
|
|
}
|
|
|
|
|
|
return opts;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-03 20:12:20 +08:00
|
|
|
|
* A2C:短链入口时优先导航至 PHP 预解析的长链,减少 yyk.ink 跳转竞态
|
|
|
|
|
|
* @param {string} pageUrl
|
|
|
|
|
|
* @param {object} antiBot
|
|
|
|
|
|
*/
|
|
|
|
|
|
function resolveNavigationUrl(pageUrl, antiBot) {
|
|
|
|
|
|
const hint = String(antiBot?.landingUrlHint || '').trim();
|
|
|
|
|
|
if (hint === '' || !hint.includes('/visitors/counter/share')) {
|
|
|
|
|
|
return pageUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pageHost = new URL(pageUrl).hostname;
|
|
|
|
|
|
const hintHost = new URL(hint).hostname;
|
|
|
|
|
|
if (hintHost.includes('a2c.chat') && pageHost !== hintHost) {
|
|
|
|
|
|
console.log(`[A2C] 短链入口,直接导航至预解析长链: ${hint}`);
|
|
|
|
|
|
return hint;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
return pageUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Chrome 启动参数:仅当 useCaptchaProxy=true 时注入 CAPTCHA_PROXY(与 CapSolver 同源 IP)
|
|
|
|
|
|
* @param {object|null|undefined} antiBot
|
|
|
|
|
|
* @param {string[]} [baseArgs]
|
|
|
|
|
|
* @param {boolean} [useCaptchaProxy]
|
|
|
|
|
|
*/
|
|
|
|
|
|
function buildBrowserExtraArgs(antiBot, baseArgs = [], useCaptchaProxy = false) {
|
|
|
|
|
|
const args = [...baseArgs];
|
|
|
|
|
|
if (!useCaptchaProxy || !hasCaptchaProxy()) {
|
|
|
|
|
|
return args;
|
|
|
|
|
|
}
|
|
|
|
|
|
const proxyArgs = getCaptchaProxyChromeArgs();
|
|
|
|
|
|
if (proxyArgs.length > 0) {
|
|
|
|
|
|
const parsed = parseCaptchaProxy();
|
|
|
|
|
|
console.log(
|
|
|
|
|
|
`[Proxy] Chrome 使用 CAPTCHA_PROXY ${parsed.host}:${parsed.port}(与 CapSolver 同源 IP)`
|
|
|
|
|
|
);
|
|
|
|
|
|
args.push(...proxyArgs);
|
|
|
|
|
|
}
|
|
|
|
|
|
return args;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Managed A2C 等:仅在检测到真实 CF 挑战时切换为 CAPTCHA_PROXY Browser(无盾则直连)
|
|
|
|
|
|
* 会原地更新 browserCtx.browserSession / browserCtx.page
|
|
|
|
|
|
* @param {object} browserCtx
|
2026-07-01 01:26:27 +08:00
|
|
|
|
*/
|
2026-07-03 20:12:20 +08:00
|
|
|
|
async function ensureCaptchaProxyBrowserIfNeeded(browserCtx) {
|
|
|
|
|
|
const {
|
|
|
|
|
|
antiBot,
|
|
|
|
|
|
profile,
|
|
|
|
|
|
navigationUrl,
|
|
|
|
|
|
mergedCookies,
|
|
|
|
|
|
baseExtraArgs = ['--mute-audio'],
|
|
|
|
|
|
pageExtraOpts = {},
|
|
|
|
|
|
} = browserCtx;
|
|
|
|
|
|
let { browserSession, page } = browserCtx;
|
|
|
|
|
|
|
|
|
|
|
|
if (!antiBot?.enabled || browserSession?.captchaProxyActive) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!shouldPreferManagedCloudflareSolver(antiBot) || !hasCaptchaProxy()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const cfState = await detectCloudflare(page, antiBot);
|
|
|
|
|
|
const needsProxy = cfState.blocked || urlIndicatesCfChallenge(page.url());
|
|
|
|
|
|
if (!needsProxy) {
|
|
|
|
|
|
console.log('[Proxy] 未检测到 CF 挑战,Chrome 直连(不使用 CAPTCHA_PROXY)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[Proxy] 检测到 CF 挑战,切换 Browser 为 CAPTCHA_PROXY(与 CapSolver 同源 IP)');
|
|
|
|
|
|
|
|
|
|
|
|
const snapshotCookies = await page.cookies().catch(() => []);
|
|
|
|
|
|
await closeTaskPage(page);
|
|
|
|
|
|
await browserSession.release(true);
|
|
|
|
|
|
|
|
|
|
|
|
const extraArgs = buildBrowserExtraArgs(antiBot, baseExtraArgs, true);
|
|
|
|
|
|
browserSession = await acquireBrowserSession({
|
|
|
|
|
|
profile,
|
|
|
|
|
|
extraArgs,
|
|
|
|
|
|
sessionKey: antiBot.sessionKey || '',
|
|
|
|
|
|
});
|
2026-07-03 20:51:56 +08:00
|
|
|
|
tagBrowserSession(browserSession, profile, antiBot.sessionKey || '', extraArgs);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
browserSession.captchaProxyActive = true;
|
|
|
|
|
|
|
2026-07-03 20:51:56 +08:00
|
|
|
|
const proxySessionRef = { current: browserSession };
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = await createTaskPage(
|
|
|
|
|
|
browserSession.browser,
|
|
|
|
|
|
browserSession.reused ? null : browserSession.page,
|
|
|
|
|
|
navigationUrl,
|
|
|
|
|
|
antiBot,
|
|
|
|
|
|
mergedCookies.length > 0 ? mergedCookies : snapshotCookies,
|
2026-07-03 20:51:56 +08:00
|
|
|
|
{
|
|
|
|
|
|
...pageExtraOpts,
|
|
|
|
|
|
useCaptchaProxy: true,
|
|
|
|
|
|
browserSession,
|
|
|
|
|
|
browserSessionRef: proxySessionRef,
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
);
|
2026-07-03 20:51:56 +08:00
|
|
|
|
browserSession = proxySessionRef.current;
|
2026-07-03 20:12:20 +08:00
|
|
|
|
|
|
|
|
|
|
if (antiBot?.enabled) {
|
|
|
|
|
|
attachSitekeyCapture(page, browserSession.browser);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await prepareWhatshubShortLinkBeforeNav(page, navigationUrl, antiBot);
|
|
|
|
|
|
await navigateToPage(page, navigationUrl);
|
|
|
|
|
|
await waitForUrlSettled(page).catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof browserCtx.afterRelaunchNav === 'function') {
|
|
|
|
|
|
await browserCtx.afterRelaunchNav(page);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
browserCtx.browserSession = browserSession;
|
|
|
|
|
|
browserCtx.page = page;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:51:56 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 为温池复用 / newPage 重试附加元数据(profile、sessionKey、启动参数)
|
|
|
|
|
|
* @param {object} browserSession
|
|
|
|
|
|
* @param {'standard'|'real'} profile
|
|
|
|
|
|
* @param {string} sessionKey
|
|
|
|
|
|
* @param {string[]} launchExtraArgs
|
|
|
|
|
|
*/
|
|
|
|
|
|
function tagBrowserSession(browserSession, profile, sessionKey, launchExtraArgs = []) {
|
|
|
|
|
|
browserSession.profile = profile;
|
|
|
|
|
|
browserSession.sessionKey = sessionKey || '';
|
|
|
|
|
|
browserSession.launchExtraArgs = launchExtraArgs;
|
|
|
|
|
|
return browserSession;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @param {Error|unknown} err */
|
|
|
|
|
|
function isNewTabOpenError(err) {
|
|
|
|
|
|
const msg = String(err?.message || err || '').toLowerCase();
|
|
|
|
|
|
return msg.includes('target.createtarget') || msg.includes('failed to open a new tab');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 温池 Browser 若已无任何 Tab,销毁并冷启动(避免后续 newPage 失败)
|
|
|
|
|
|
* @param {object|null} browserSession
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function relaunchBrowserSessionIfNoTabs(browserSession) {
|
|
|
|
|
|
if (!browserSession?.pooled || !browserSession.browser) {
|
|
|
|
|
|
return browserSession;
|
|
|
|
|
|
}
|
|
|
|
|
|
let pages = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
pages = await browserSession.browser.pages();
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
pages = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pages.length > 0) {
|
|
|
|
|
|
return browserSession;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.warn('[createTaskPage] 温池 Browser pages=0,销毁并冷启动');
|
|
|
|
|
|
await browserSession.release(true);
|
|
|
|
|
|
const relaunched = await acquireBrowserSession({
|
|
|
|
|
|
profile: browserSession.profile || 'real',
|
|
|
|
|
|
extraArgs: browserSession.launchExtraArgs || ['--mute-audio'],
|
|
|
|
|
|
sessionKey: browserSession.sessionKey || '',
|
|
|
|
|
|
});
|
|
|
|
|
|
tagBrowserSession(
|
|
|
|
|
|
relaunched,
|
|
|
|
|
|
browserSession.profile || relaunched.profile || 'real',
|
|
|
|
|
|
browserSession.sessionKey || '',
|
|
|
|
|
|
browserSession.launchExtraArgs || ['--mute-audio']
|
|
|
|
|
|
);
|
|
|
|
|
|
return relaunched;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 任务结束关闭本次 Tab(温池复用 Browser 时保留至少一个 Tab) */
|
2026-07-03 20:12:20 +08:00
|
|
|
|
async function closeTaskPage(page) {
|
|
|
|
|
|
if (!page) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-07-03 20:51:56 +08:00
|
|
|
|
if (typeof page.isClosed === 'function' && page.isClosed()) {
|
|
|
|
|
|
return;
|
2026-07-03 20:12:20 +08:00
|
|
|
|
}
|
2026-07-03 20:51:56 +08:00
|
|
|
|
const browser = typeof page.browser === 'function' ? page.browser() : null;
|
|
|
|
|
|
if (browser) {
|
|
|
|
|
|
const pages = await browser.pages().catch(() => []);
|
|
|
|
|
|
if (pages.length <= 1) {
|
|
|
|
|
|
await page.goto('about:blank', {
|
|
|
|
|
|
waitUntil: 'domcontentloaded',
|
|
|
|
|
|
timeout: Math.min(NAVIGATION_TIMEOUT_MS, 15000),
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await page.close();
|
2026-07-03 20:12:20 +08:00
|
|
|
|
} catch (_) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-03 20:51:56 +08:00
|
|
|
|
* 配置任务页(UA / Cookie / 事件监听)
|
|
|
|
|
|
* @param {import('puppeteer').Page} page
|
|
|
|
|
|
* @param {import('puppeteer').Browser} browser
|
|
|
|
|
|
* @param {object} opts
|
|
|
|
|
|
* @param {object} extraOpts
|
|
|
|
|
|
* @param {object|null|undefined} antiBot
|
2026-07-03 20:12:20 +08:00
|
|
|
|
*/
|
2026-07-03 20:51:56 +08:00
|
|
|
|
async function configureTaskPage(page, browser, opts, extraOpts, antiBot) {
|
2026-07-01 01:26:27 +08:00
|
|
|
|
page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
|
|
|
|
page.setDefaultTimeout(PAGE_DEFAULT_TIMEOUT_MS);
|
|
|
|
|
|
|
|
|
|
|
|
if (!opts.skipUserAgent) {
|
|
|
|
|
|
await page.setUserAgent(opts.userAgent || DEFAULT_UA);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (opts.viewport) {
|
|
|
|
|
|
await page.setViewport(opts.viewport);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (opts.cookies && opts.cookies.length > 0) {
|
|
|
|
|
|
await page.setCookie(...opts.cookies);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
if (extraOpts.useCaptchaProxy && hasCaptchaProxy()) {
|
|
|
|
|
|
await applyCaptchaProxyToPage(page);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
page.on('error', (err) => console.error('[Page Error]', err.message));
|
|
|
|
|
|
page.on('pageerror', (err) => console.error('[Page JS Error]', err.message));
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
if (antiBot?.enabled) {
|
|
|
|
|
|
attachSitekeyCapture(page, browser);
|
|
|
|
|
|
}
|
2026-07-03 20:51:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 任务页创建(含 Whatshub skipUserAgent)
|
|
|
|
|
|
* 温池复用 Browser 时每次 newPage;失败则销毁温池条目并冷启动重试
|
|
|
|
|
|
*
|
|
|
|
|
|
* extraOpts.browserSession:传入时可触发 pages=0 重建与 newPage 失败重试
|
|
|
|
|
|
* extraOpts.browserSessionRef:可选 { current },重试后回写最新 session
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function createTaskPage(browser, warmPage, pageUrl, antiBot, mergedCookies, extraOpts = {}) {
|
|
|
|
|
|
const opts = buildManagedPageOptions(pageUrl, antiBot, mergedCookies, extraOpts);
|
|
|
|
|
|
let browserSession = extraOpts.browserSession || null;
|
|
|
|
|
|
let currentBrowser = browser;
|
|
|
|
|
|
let currentWarmPage = warmPage;
|
|
|
|
|
|
|
|
|
|
|
|
if (browserSession) {
|
|
|
|
|
|
browserSession = await relaunchBrowserSessionIfNoTabs(browserSession);
|
|
|
|
|
|
currentBrowser = browserSession.browser;
|
|
|
|
|
|
currentWarmPage = browserSession.reused ? null : browserSession.page;
|
|
|
|
|
|
if (extraOpts.browserSessionRef) {
|
|
|
|
|
|
extraOpts.browserSessionRef.current = browserSession;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let page = null;
|
|
|
|
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
|
|
|
|
const useWarmPage = !!(currentWarmPage && extraOpts.useWarmPage !== false && !extraOpts.forceNewPage);
|
|
|
|
|
|
try {
|
|
|
|
|
|
page = useWarmPage ? currentWarmPage : await currentBrowser.newPage();
|
|
|
|
|
|
break;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (attempt === 0 && browserSession && isNewTabOpenError(err)) {
|
|
|
|
|
|
console.warn('[createTaskPage] newPage 失败,销毁温池并冷启动重试:', err.message);
|
|
|
|
|
|
await browserSession.release(true);
|
|
|
|
|
|
browserSession = await acquireBrowserSession({
|
|
|
|
|
|
profile: browserSession.profile || (antiBot?.profile === 'real' ? 'real' : 'standard'),
|
|
|
|
|
|
extraArgs: browserSession.launchExtraArgs || ['--mute-audio'],
|
|
|
|
|
|
sessionKey: browserSession.sessionKey || antiBot?.sessionKey || '',
|
|
|
|
|
|
});
|
|
|
|
|
|
tagBrowserSession(
|
|
|
|
|
|
browserSession,
|
|
|
|
|
|
browserSession.profile || (antiBot?.profile === 'real' ? 'real' : 'standard'),
|
|
|
|
|
|
browserSession.sessionKey || antiBot?.sessionKey || '',
|
|
|
|
|
|
browserSession.launchExtraArgs || ['--mute-audio']
|
|
|
|
|
|
);
|
|
|
|
|
|
if (extraOpts.browserSessionRef) {
|
|
|
|
|
|
extraOpts.browserSessionRef.current = browserSession;
|
|
|
|
|
|
}
|
|
|
|
|
|
currentBrowser = browserSession.browser;
|
|
|
|
|
|
currentWarmPage = browserSession.reused ? null : browserSession.page;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
|
2026-07-03 20:51:56 +08:00
|
|
|
|
if (!page) {
|
|
|
|
|
|
throw new Error('createTaskPage: 未能打开任务 Tab');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await configureTaskPage(page, currentBrowser, opts, extraOpts, antiBot);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
return page;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** CF 已过但仍在短链、未到业务页(Whatshub / A2C yyk.ink 等) */
|
|
|
|
|
|
function isOnShortLinkNotBusiness(page, urlNeedle) {
|
|
|
|
|
|
const url = page.url();
|
|
|
|
|
|
if (urlNeedle && url.includes(urlNeedle)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isWhatshubShortLinkPath(url)) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (urlNeedle && urlNeedle.includes('/visitors/counter/share')) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return !new URL(url).hostname.includes('a2c.chat');
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-02 03:13:27 +08:00
|
|
|
|
if (urlNeedle && urlNeedle.includes('/gds')) {
|
|
|
|
|
|
return !url.includes('/gds');
|
|
|
|
|
|
}
|
2026-07-01 01:26:27 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* CF 完成后等待 Vue SPA 从短链跳转到 work-order-sharing(对齐 debug 脚本 goto+8s)
|
|
|
|
|
|
* Whatshub:二次 goto 短链(保留 clearance),禁止 reload(会重新触发 __cf_chl_rt_tk)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettledFn, pageUrl = '') {
|
|
|
|
|
|
const urlNeedle = (antiBot.postCfReadyUrlContains || '').trim();
|
|
|
|
|
|
const targetUrl = pageUrl || page.url();
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const onA2cDirect = isA2cDirectSharePage(page, urlNeedle);
|
|
|
|
|
|
if (!urlNeedle || (!isOnShortLinkNotBusiness(page, urlNeedle) && !onA2cDirect)) {
|
2026-07-01 01:26:27 +08:00
|
|
|
|
return isBusinessUrlReady(page, urlNeedle);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!(await isCfTrulyComplete(page, antiBot))) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const spaWaitMs = onA2cDirect
|
|
|
|
|
|
? Math.max(8000, antiBot.postCfSpaWaitMs || 8000)
|
|
|
|
|
|
: Math.max(8000, antiBot.postCfSpaWaitMs || 8000);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const isWhatshub = isWhatshubShortLinkPath(targetUrl);
|
|
|
|
|
|
|
|
|
|
|
|
console.log(
|
2026-07-03 20:12:20 +08:00
|
|
|
|
`[CF] CF 已完成,等待 SPA ${onA2cDirect ? '直链挂载' : '跳转'} settle=${spaWaitMs}ms current=${page.url()}`
|
2026-07-01 01:26:27 +08:00
|
|
|
|
);
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, spaWaitMs));
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof waitForUrlSettledFn === 'function') {
|
|
|
|
|
|
await waitForUrlSettledFn(page).catch(() => {});
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await page.waitForNetworkIdle({ idleTime: 500, timeout: 20000 }).catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
if (onA2cDirect) {
|
|
|
|
|
|
await waitForPostCfListApi(page, antiBot, Math.max(15000, antiBot.postCfReadyTimeoutMs || 60000)).catch(() => {});
|
|
|
|
|
|
}
|
2026-07-01 01:26:27 +08:00
|
|
|
|
|
|
|
|
|
|
if (isBusinessUrlReady(page, urlNeedle)) {
|
|
|
|
|
|
console.log(`[CF] SPA 已跳转至业务页 url=${page.url()}`);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isWhatshub && isWhatshubShortLinkPath(targetUrl)) {
|
|
|
|
|
|
console.log(`[CF] Whatshub 二次 goto 触发 SPA 路由(保留 clearance)url=${targetUrl}`);
|
|
|
|
|
|
await navigateToPage(page, targetUrl);
|
|
|
|
|
|
if (typeof waitForUrlSettledFn === 'function') {
|
|
|
|
|
|
await waitForUrlSettledFn(page).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!(await isCfTrulyComplete(page, antiBot))) {
|
|
|
|
|
|
console.log('[CF] 二次 goto 后 CF 未完成,等待过盾...');
|
|
|
|
|
|
const cfWait = await waitForCfTrulyComplete(page, antiBot.challengeTimeoutMs || 60000, 500, antiBot);
|
|
|
|
|
|
if (!cfWait.success) {
|
|
|
|
|
|
console.log(`[CF] SPA 等待后仍未到业务页 url=${page.url()}`);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[CF] Whatshub 二次 goto 后 SPA 等待 settle=${spaWaitMs}ms current=${page.url()}`);
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, spaWaitMs));
|
|
|
|
|
|
await page.waitForNetworkIdle({ idleTime: 500, timeout: 15000 }).catch(() => {});
|
|
|
|
|
|
if (isBusinessUrlReady(page, urlNeedle)) {
|
|
|
|
|
|
console.log(`[CF] 二次 goto 后 SPA 已跳转 url=${page.url()}`);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[CF] SPA 等待后仍未到业务页 url=${page.url()}`);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 非 Whatshub 场景:软 reload;Whatshub 短链改用二次 goto */
|
|
|
|
|
|
async function softReloadForSpa(page, waitForUrlSettledFn, antiBot, pageUrl = '') {
|
|
|
|
|
|
const targetUrl = pageUrl || page.url();
|
|
|
|
|
|
if (isWhatshubShortLinkPath(targetUrl)) {
|
|
|
|
|
|
console.log(`[CF] Whatshub 跳过 soft reload,改用二次 goto url=${targetUrl}`);
|
|
|
|
|
|
return waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettledFn, targetUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[CF] 尝试软 reload(保留 clearance)url=${page.url()}`);
|
|
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT_MS }).catch(() => {});
|
|
|
|
|
|
if (typeof waitForUrlSettledFn === 'function') {
|
|
|
|
|
|
await waitForUrlSettledFn(page).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
return waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettledFn, targetUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** CF 是否真正完成:默认需 cf_clearance;A2C 等 cfClearanceRequired=false 时仅看真实挑战 */
|
|
|
|
|
|
async function isCfTrulyComplete(page, antiBot = null) {
|
|
|
|
|
|
if (urlIndicatesCfChallenge(page.url())) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (antiBot && antiBot.cfClearanceRequired === false) {
|
|
|
|
|
|
const state = await detectCloudflare(page, antiBot);
|
|
|
|
|
|
return !state.blocked;
|
|
|
|
|
|
}
|
|
|
|
|
|
const cookies = await page.cookies();
|
|
|
|
|
|
if (!cookies.some((c) => c.name === 'cf_clearance')) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
const state = await detectCloudflare(page, antiBot);
|
|
|
|
|
|
return !state.blocked;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
/** 轮询直到 CF 真正完成;并行扫描 sitekey 供 CapSolver 提前使用 */
|
2026-07-01 01:26:27 +08:00
|
|
|
|
async function waitForCfTrulyComplete(page, timeoutMs, pollMs = 500, antiBot = null) {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const { getSitekeyCapture } = require('./lib/sitekey-capture');
|
|
|
|
|
|
const capture = getSitekeyCapture(page);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const started = Date.now();
|
|
|
|
|
|
while (Date.now() - started < timeoutMs) {
|
|
|
|
|
|
if (await isCfTrulyComplete(page, antiBot)) {
|
|
|
|
|
|
return { success: true, elapsedMs: Date.now() - started };
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
if (capture) {
|
|
|
|
|
|
await capture.scanNow(page, antiBot).catch(() => {});
|
|
|
|
|
|
}
|
2026-07-01 01:26:27 +08:00
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
|
|
|
|
}
|
|
|
|
|
|
return { success: false, elapsedMs: Date.now() - started };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 校验 cfResult,防止 handleCloudflareChallenge 假 success */
|
|
|
|
|
|
async function finalizeCfResult(page, cfResult, antiBot = null) {
|
|
|
|
|
|
if (!cfResult || !cfResult.success) {
|
|
|
|
|
|
return cfResult;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (await isCfTrulyComplete(page, antiBot)) {
|
|
|
|
|
|
return cfResult;
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[CF] 过盾返回 success 但 CF 未真正完成 url=${page.url()}`);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
code: 'CF_TURNSTILE_FAILED',
|
|
|
|
|
|
challengeType: cfResult.challengeType || 'turnstile',
|
|
|
|
|
|
stage: 'incomplete',
|
|
|
|
|
|
solverUsed: cfResult.solverUsed || false,
|
|
|
|
|
|
elapsedMs: cfResult.elapsedMs || 0,
|
|
|
|
|
|
cfDetected: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 完整过盾:内置 Real Browser Turnstile 轮询 → Captcha API 兜底
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function runCfChallengeFlow(page, antiBot, waitForUrlSettled, pageUrl = '') {
|
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
|
const timeoutMs = Math.min(Math.max(Number(antiBot.challengeTimeoutMs) || 60000, 5000), 120000);
|
|
|
|
|
|
const navUrl = pageUrl || page.url();
|
|
|
|
|
|
console.log(`[CF] 开始过盾 url=${page.url()}`);
|
|
|
|
|
|
|
|
|
|
|
|
if (waitForUrlSettled) {
|
|
|
|
|
|
await waitForUrlSettled(page).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (antiBot.cfClearanceRequired === false) {
|
|
|
|
|
|
const preState = await detectCloudflare(page, antiBot);
|
|
|
|
|
|
if (!preState.blocked) {
|
|
|
|
|
|
console.log('[CF] 免 clearance 模式:无真实 CF 挑战,跳过 Turnstile/CapSolver');
|
|
|
|
|
|
await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettled, navUrl);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
code: null,
|
|
|
|
|
|
challengeType: null,
|
|
|
|
|
|
stage: 'no_clearance_required',
|
|
|
|
|
|
solverUsed: false,
|
|
|
|
|
|
elapsedMs: Date.now() - started,
|
|
|
|
|
|
cfDetected: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[CF] 免 clearance 模式但检测到真实 CF 挑战 type=${preState.challengeType},继续过盾`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const preferManaged = shouldPreferManagedCloudflareSolver(antiBot);
|
|
|
|
|
|
|
|
|
|
|
|
if (preferManaged && antiBot.solverFallback !== false && isCaptchaConfigured()) {
|
|
|
|
|
|
console.log('[CF] Managed Challenge:跳过内置 60s 轮询,直接使用 CapSolver AntiCloudflareTask');
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (waitForUrlSettled) {
|
|
|
|
|
|
await waitForUrlSettled(page).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
await runCaptchaApiFallback(page, {
|
|
|
|
|
|
antiBot,
|
|
|
|
|
|
navUrl,
|
|
|
|
|
|
waitForUrlSettled,
|
|
|
|
|
|
timeoutMs,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const apiWait = await waitForCfTrulyComplete(page, Math.min(timeoutMs, 60000), 500, antiBot);
|
|
|
|
|
|
if (apiWait.success) {
|
|
|
|
|
|
if (waitForUrlSettled) {
|
|
|
|
|
|
await waitForUrlSettled(page).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettled, navUrl);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
code: null,
|
|
|
|
|
|
challengeType: 'managed',
|
|
|
|
|
|
stage: 'api_managed',
|
|
|
|
|
|
solverUsed: true,
|
|
|
|
|
|
elapsedMs: Date.now() - started,
|
|
|
|
|
|
cfDetected: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
console.warn('[CF] Managed CapSolver 注入后仍未获得有效 cf_clearance');
|
|
|
|
|
|
} catch (apiErr) {
|
|
|
|
|
|
console.error('[CF] Managed Challenge CapSolver 失败:', apiErr.message);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
code: 'CF_TURNSTILE_FAILED',
|
|
|
|
|
|
challengeType: 'managed',
|
|
|
|
|
|
stage: 'api_managed',
|
|
|
|
|
|
solverUsed: true,
|
|
|
|
|
|
elapsedMs: Date.now() - started,
|
|
|
|
|
|
cfDetected: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
code: 'CF_TURNSTILE_FAILED',
|
|
|
|
|
|
challengeType: 'managed',
|
|
|
|
|
|
stage: 'api_managed',
|
|
|
|
|
|
solverUsed: true,
|
|
|
|
|
|
elapsedMs: Date.now() - started,
|
|
|
|
|
|
cfDetected: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
if (antiBot.turnstile !== false) {
|
|
|
|
|
|
console.log(`[CF] 内置 Turnstile 轮询 timeout=${timeoutMs}ms`);
|
|
|
|
|
|
const builtIn = await waitForCfTrulyComplete(page, timeoutMs, 500, antiBot);
|
|
|
|
|
|
if (builtIn.success) {
|
|
|
|
|
|
if (waitForUrlSettled) {
|
|
|
|
|
|
await waitForUrlSettled(page).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettled, navUrl);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
code: null,
|
|
|
|
|
|
challengeType: 'turnstile',
|
|
|
|
|
|
stage: 'built_in',
|
|
|
|
|
|
solverUsed: false,
|
|
|
|
|
|
elapsedMs: Date.now() - started,
|
|
|
|
|
|
cfDetected: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
console.warn(`[CF] 内置 Turnstile 未在 ${builtIn.elapsedMs}ms 内完成,尝试 Captcha API`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (antiBot.solverFallback !== false && isCaptchaConfigured()) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (waitForUrlSettled) {
|
|
|
|
|
|
await waitForUrlSettled(page).catch(() => {});
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await runCaptchaApiFallback(page, {
|
|
|
|
|
|
antiBot,
|
|
|
|
|
|
navUrl,
|
|
|
|
|
|
waitForUrlSettled,
|
|
|
|
|
|
timeoutMs,
|
|
|
|
|
|
});
|
2026-07-01 01:26:27 +08:00
|
|
|
|
|
|
|
|
|
|
const apiWait = await waitForCfTrulyComplete(page, Math.min(timeoutMs, 45000), 500, antiBot);
|
|
|
|
|
|
if (apiWait.success) {
|
|
|
|
|
|
if (waitForUrlSettled) {
|
|
|
|
|
|
await waitForUrlSettled(page).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettled, navUrl);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
code: null,
|
|
|
|
|
|
challengeType: 'turnstile',
|
|
|
|
|
|
stage: 'api',
|
|
|
|
|
|
solverUsed: true,
|
|
|
|
|
|
elapsedMs: Date.now() - started,
|
|
|
|
|
|
cfDetected: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
console.warn('[CF] Captcha API token 注入后仍未获得 cf_clearance');
|
|
|
|
|
|
} catch (apiErr) {
|
|
|
|
|
|
console.error('[CF] Captcha API 兜底失败:', apiErr.message);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
code: 'CF_TURNSTILE_FAILED',
|
|
|
|
|
|
challengeType: 'turnstile',
|
|
|
|
|
|
stage: 'api',
|
|
|
|
|
|
solverUsed: true,
|
|
|
|
|
|
elapsedMs: Date.now() - started,
|
|
|
|
|
|
cfDetected: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
code: 'CF_TURNSTILE_FAILED',
|
|
|
|
|
|
challengeType: 'turnstile',
|
|
|
|
|
|
stage: antiBot.solverFallback !== false && !isCaptchaConfigured() ? 'built_in' : 'timeout',
|
|
|
|
|
|
solverUsed: false,
|
|
|
|
|
|
elapsedMs: Date.now() - started,
|
|
|
|
|
|
cfDetected: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 安全 CF 处理:未真正完成则走完整过盾,禁止 cf-handler 假过关 */
|
|
|
|
|
|
async function handleCloudflareChallengeSafe(page, antiBot, waitForUrlSettled, storedSession) {
|
|
|
|
|
|
if (!(await isCfTrulyComplete(page, antiBot))) {
|
|
|
|
|
|
return finalizeCfResult(page, await runCfChallengeFlow(page, antiBot, waitForUrlSettled), antiBot);
|
|
|
|
|
|
}
|
|
|
|
|
|
const result = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled, storedSession);
|
|
|
|
|
|
return finalizeCfResult(page, result, antiBot);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 是否应 purge 陈旧 clearance(短链 SPA 未跳转时禁止 purge) */
|
|
|
|
|
|
function shouldPurgeStaleClearance(page, cfState, urlNeedle = '') {
|
|
|
|
|
|
if (urlIndicatesCfChallenge(page.url())) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (urlNeedle && isOnShortLinkNotBusiness(page, urlNeedle) && cfState.hasCfClearance) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return cfState.hasCfClearance && !cfState.blocked;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 业务页等待前确保 CF 已完成 */
|
|
|
|
|
|
async function ensureCfCompleteBeforeBusinessReady(page, antiBot, waitForUrlSettledFn, storedSession) {
|
|
|
|
|
|
if (await isCfTrulyComplete(page, antiBot)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const cfState = await detectCloudflare(page, antiBot);
|
|
|
|
|
|
console.log(`[CF] 业务页等待前 CF 未完成 blocked=${cfState.blocked} url=${page.url()}`);
|
|
|
|
|
|
const cfResult = await handleCloudflareChallengeSafe(page, antiBot, waitForUrlSettledFn, storedSession);
|
|
|
|
|
|
if (!cfResult.success) {
|
|
|
|
|
|
const err = new Error('Cloudflare Turnstile 验证失败(业务页等待前)');
|
|
|
|
|
|
err.code = 'CF_TURNSTILE_FAILED';
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof waitForUrlSettledFn === 'function') {
|
|
|
|
|
|
await waitForUrlSettledFn(page).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 清除 cf_clearance(含 Chrome Profile / CDP 层) */
|
|
|
|
|
|
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(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function purgeCfClearanceFully(page, pageUrl) {
|
|
|
|
|
|
await clearCfClearanceCookies(page);
|
|
|
|
|
|
let hostname = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
hostname = new URL(pageUrl).hostname;
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cdp = await page.createCDPSession();
|
|
|
|
|
|
const domains = new Set([hostname, `.${hostname}`]);
|
|
|
|
|
|
const cookies = await page.cookies();
|
|
|
|
|
|
for (const c of cookies) {
|
|
|
|
|
|
if (c.domain) {
|
|
|
|
|
|
domains.add(c.domain);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const domain of domains) {
|
|
|
|
|
|
await cdp.send('Network.deleteCookies', { name: 'cf_clearance', domain }).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('[CF] CDP 清除 cf_clearance 失败:', err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whatshub 短链首访:清除 Chrome Profile 内陈旧 cf_clearance,确保 Turnstile+SPA 在同一 goto 生命周期
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function prepareWhatshubShortLinkBeforeNav(page, pageUrl, antiBot) {
|
|
|
|
|
|
if (!antiBot?.enabled || antiBot.profile !== 'real' || !isWhatshubShortLinkPath(pageUrl)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const cookies = await page.cookies();
|
|
|
|
|
|
if (!cookies.some((c) => c.name === 'cf_clearance')) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log('[Whatshub] 短链首访:清除 Profile/页面内陈旧 cf_clearance');
|
|
|
|
|
|
await purgeCfClearanceFully(page, pageUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* CF 处理 + 业务 URL 守卫:磁盘 session 陈旧 clearance 时 purge;__cf_chl 中间态只过盾不 purge
|
|
|
|
|
|
*/
|
2026-07-03 20:12:20 +08:00
|
|
|
|
async function runCloudflareWithBusinessGuard(page, antiBot, waitForUrlSettled, storedSession, pageUrl, browserCtx = null) {
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const urlNeedle = (antiBot.postCfReadyUrlContains || '').trim();
|
|
|
|
|
|
const hasStoredSession = !!(storedSession && isSessionLikelyValid(storedSession));
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
async function maybeEnsureProxy(currentPage) {
|
|
|
|
|
|
if (!browserCtx) {
|
|
|
|
|
|
return currentPage;
|
|
|
|
|
|
}
|
|
|
|
|
|
browserCtx.page = currentPage;
|
|
|
|
|
|
await ensureCaptchaProxyBrowserIfNeeded(browserCtx);
|
|
|
|
|
|
return browserCtx.page;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
async function reNavigateWithCfPurge(label) {
|
|
|
|
|
|
if (urlIndicatesCfChallenge(page.url())) {
|
|
|
|
|
|
console.log(`[CF] ${label}: URL 含 __cf_chl,跳过 purge,直接过盾`);
|
|
|
|
|
|
return handleCloudflareChallengeSafe(page, antiBot, waitForUrlSettled, storedSession);
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[CF] ${label}: 清除陈旧 CF 并重新 goto ${pageUrl} (current=${page.url()})`);
|
|
|
|
|
|
await purgeCfClearanceFully(page, pageUrl);
|
|
|
|
|
|
await navigateToPage(page, pageUrl);
|
|
|
|
|
|
if (waitForUrlSettled) {
|
|
|
|
|
|
await waitForUrlSettled(page).catch(() => {});
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = await maybeEnsureProxy(page);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
return handleCloudflareChallengeSafe(page, antiBot, waitForUrlSettled, storedSession);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let cfResult;
|
|
|
|
|
|
if (urlNeedle && !isBusinessUrlReady(page, urlNeedle) && hasStoredSession) {
|
|
|
|
|
|
const pre = await detectCloudflare(page, antiBot);
|
|
|
|
|
|
if (shouldPurgeStaleClearance(page, pre, urlNeedle)) {
|
|
|
|
|
|
cfResult = await reNavigateWithCfPurge('预检(磁盘会话-陈旧clearance)');
|
|
|
|
|
|
} else if (await isCfTrulyComplete(page, antiBot)) {
|
|
|
|
|
|
await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettled, pageUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!cfResult) {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = await maybeEnsureProxy(page);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
cfResult = await handleCloudflareChallengeSafe(page, antiBot, waitForUrlSettled, storedSession);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 02:13:06 +08:00
|
|
|
|
// 短链跨域落地(如 yyk.ink → user.a2c.chat、Whatshub /m/ → work-order-sharing)后,业务域可能仍有 CF
|
2026-07-01 01:26:27 +08:00
|
|
|
|
if (cfResult.success && !(await isCfTrulyComplete(page, antiBot))) {
|
|
|
|
|
|
const crossState = await detectCloudflare(page, antiBot);
|
|
|
|
|
|
if (crossState.blocked || urlIndicatesCfChallenge(page.url())) {
|
2026-07-05 02:13:06 +08:00
|
|
|
|
const isWhatshubLongCf =
|
|
|
|
|
|
urlNeedle === 'work-order-sharing' && urlIndicatesCfChallenge(page.url());
|
|
|
|
|
|
if (isWhatshubLongCf) {
|
|
|
|
|
|
console.log(`[CF] Whatshub 长链二次过盾 url=${page.url()}`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`[CF] 跨域落地后 CF 未完成,二次过盾 url=${page.url()}`);
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = await maybeEnsureProxy(page);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
cfResult = await finalizeCfResult(
|
|
|
|
|
|
page,
|
|
|
|
|
|
await runCfChallengeFlow(page, antiBot, waitForUrlSettled, page.url()),
|
|
|
|
|
|
antiBot
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (cfResult.success && urlNeedle && !isBusinessUrlReady(page, urlNeedle)) {
|
|
|
|
|
|
await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettled, pageUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let retries = 0;
|
|
|
|
|
|
while (
|
|
|
|
|
|
cfResult.success
|
|
|
|
|
|
&& urlNeedle
|
|
|
|
|
|
&& !isBusinessUrlReady(page, urlNeedle)
|
|
|
|
|
|
&& retries < 2
|
|
|
|
|
|
) {
|
|
|
|
|
|
retries += 1;
|
|
|
|
|
|
if (!(await isCfTrulyComplete(page, antiBot))) {
|
|
|
|
|
|
console.log(`[CF] 业务页未达且 CF 未完成,完整过盾 重试#${retries}`);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = await maybeEnsureProxy(page);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
cfResult = await finalizeCfResult(
|
|
|
|
|
|
page,
|
|
|
|
|
|
await runCfChallengeFlow(page, antiBot, waitForUrlSettled, pageUrl),
|
|
|
|
|
|
antiBot
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`[CF] CF 已完成但未到业务页,等待 SPA 跳转 重试#${retries}`);
|
|
|
|
|
|
const spaOk = await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettled, pageUrl);
|
|
|
|
|
|
if (!spaOk && retries >= 2) {
|
|
|
|
|
|
await softReloadForSpa(page, waitForUrlSettled, antiBot, pageUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isBusinessUrlReady(page, urlNeedle)) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!cfResult.success) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
if (browserCtx) {
|
|
|
|
|
|
browserCtx.page = page;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
return finalizeCfResult(page, cfResult, antiBot);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function collectPageDiagnostics(page) {
|
|
|
|
|
|
const url = page.url();
|
|
|
|
|
|
const snapshot = await page.evaluate(() => ({
|
|
|
|
|
|
url: window.location.href,
|
|
|
|
|
|
title: document.title || '',
|
|
|
|
|
|
bodyText: (document.body && document.body.innerText ? document.body.innerText : '').slice(0, 500),
|
|
|
|
|
|
elInputInner: document.querySelectorAll('.el-input__inner').length,
|
2026-07-03 20:12:20 +08:00
|
|
|
|
elTable: document.querySelectorAll('.el-table').length,
|
|
|
|
|
|
btnNext: document.querySelectorAll('.btn-next').length,
|
|
|
|
|
|
elPagination: document.querySelectorAll('.el-pagination').length,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
})).catch(() => null);
|
|
|
|
|
|
return snapshot || { url, title: '', bodyText: '', elInputInner: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function awaitPostCfBusinessReady(page, antiBot, waitForUrlSettledFn, storedSession = null, pageUrl = '') {
|
|
|
|
|
|
if (!antiBot || !antiBot.enabled) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const urlContains = (antiBot.postCfReadyUrlContains || '').trim();
|
|
|
|
|
|
const selector = (antiBot.postCfReadySelector || '').trim();
|
|
|
|
|
|
if (urlContains === '' && selector === '') {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const navUrl = pageUrl || page.url();
|
|
|
|
|
|
|
|
|
|
|
|
await ensureCfCompleteBeforeBusinessReady(page, antiBot, waitForUrlSettledFn, storedSession);
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const onA2cDirect = isA2cDirectSharePage(page, urlContains);
|
|
|
|
|
|
let skipSpaWait = false;
|
|
|
|
|
|
if (onA2cDirect && await isCfTrulyComplete(page, antiBot)) {
|
|
|
|
|
|
const domReady = selector
|
|
|
|
|
|
? await page.$(selector).catch(() => null)
|
|
|
|
|
|
: true;
|
|
|
|
|
|
if (domReady) {
|
|
|
|
|
|
console.log('[CF] A2C 业务 DOM 已就绪,跳过重复 SPA 等待');
|
|
|
|
|
|
skipSpaWait = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!skipSpaWait) {
|
|
|
|
|
|
await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettledFn, navUrl);
|
|
|
|
|
|
}
|
2026-07-01 01:26:27 +08:00
|
|
|
|
|
|
|
|
|
|
const timeoutMs = Math.max(5000, antiBot.postCfReadyTimeoutMs || 30000);
|
|
|
|
|
|
console.log(
|
|
|
|
|
|
`[CF] 等待业务页就绪 current=${page.url()} urlContains="${urlContains}" `
|
|
|
|
|
|
+ `selector="${selector}" timeout=${timeoutMs}ms`
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (urlContains !== '') {
|
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
|
(needle) => window.location.href.includes(needle),
|
|
|
|
|
|
{ timeout: timeoutMs, polling: 200 },
|
|
|
|
|
|
urlContains
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await waitForPostCfListApi(page, antiBot, timeoutMs);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
if (selector !== '') {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await waitForPostCfSelector(page, selector, timeoutMs);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const retryMs = Math.min(30000, timeoutMs);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const needsRetry = urlContains !== '' && !page.url().includes(urlContains);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const needsDomRetry = urlContains !== '' && page.url().includes(urlContains) && isA2cDirectSharePage(page, urlContains);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
if (needsRetry) {
|
|
|
|
|
|
if (!(await isCfTrulyComplete(page, antiBot))) {
|
|
|
|
|
|
console.warn(`[CF] 业务页等待失败且 CF 未完成 url=${page.url()},完整过盾后重试`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ensureCfCompleteBeforeBusinessReady(page, antiBot, waitForUrlSettledFn, storedSession);
|
|
|
|
|
|
if (urlContains !== '') {
|
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
|
(needle) => window.location.href.includes(needle),
|
|
|
|
|
|
{ timeout: retryMs, polling: 200 },
|
|
|
|
|
|
urlContains
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (selector !== '') {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await waitForPostCfSelector(page, selector, retryMs);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (retryErr) {
|
|
|
|
|
|
retryErr.pageDiagnostics = await collectPageDiagnostics(page);
|
|
|
|
|
|
throw retryErr;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.warn(`[CF] 业务页等待失败 url=${page.url()},SPA 等待 + 二次 goto`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettledFn, navUrl);
|
|
|
|
|
|
if (!page.url().includes(urlContains)) {
|
|
|
|
|
|
await softReloadForSpa(page, waitForUrlSettledFn, antiBot, navUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (urlContains !== '') {
|
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
|
(needle) => window.location.href.includes(needle),
|
|
|
|
|
|
{ timeout: retryMs, polling: 200 },
|
|
|
|
|
|
urlContains
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await waitForPostCfListApi(page, antiBot, retryMs);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
if (selector !== '') {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await waitForPostCfSelector(page, selector, retryMs);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (retryErr) {
|
|
|
|
|
|
retryErr.pageDiagnostics = await collectPageDiagnostics(page);
|
|
|
|
|
|
throw retryErr;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-03 20:12:20 +08:00
|
|
|
|
} else if (needsDomRetry) {
|
|
|
|
|
|
console.warn(`[CF] A2C URL 已就绪但 DOM 未挂载 url=${page.url()},soft reload 后重试`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await page.waitForNetworkIdle({ idleTime: 500, timeout: 20000 }).catch(() => {});
|
|
|
|
|
|
await softReloadForSpa(page, waitForUrlSettledFn, antiBot, navUrl);
|
|
|
|
|
|
await waitForPostCfListApi(page, antiBot, retryMs);
|
|
|
|
|
|
if (selector !== '') {
|
|
|
|
|
|
await waitForPostCfSelector(page, selector, retryMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (retryErr) {
|
|
|
|
|
|
retryErr.pageDiagnostics = await collectPageDiagnostics(page);
|
|
|
|
|
|
throw retryErr;
|
|
|
|
|
|
}
|
2026-07-01 01:26:27 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
err.pageDiagnostics = await collectPageDiagnostics(page);
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const settleMs = antiBot.postCfSettleMs || 0;
|
|
|
|
|
|
if (settleMs > 0) {
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[CF] 业务页就绪 url=${page.url()}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function executeAuthActionsWithDiagnostics(page, authActions) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await executeAuthActions(page, authActions);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
err.pageDiagnostics = await collectPageDiagnostics(page);
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
async function executeAuthActions(page, authActions) {
|
|
|
|
|
|
if (!Array.isArray(authActions)) return;
|
|
|
|
|
|
|
|
|
|
|
|
for (const action of authActions) {
|
|
|
|
|
|
if (action.type === 'type') {
|
|
|
|
|
|
await page.waitForSelector(action.selector, { timeout: 15000 });
|
|
|
|
|
|
await page.type(action.selector, action.value, { delay: 100 });
|
|
|
|
|
|
} else if (action.type === 'click') {
|
|
|
|
|
|
await page.waitForSelector(action.selector, { timeout: 15000 });
|
|
|
|
|
|
await page.evaluate((sel) => { const el = document.querySelector(sel); if (el) el.click(); }, action.selector);
|
|
|
|
|
|
} else if (action.type === 'press') {
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
|
|
|
|
await page.keyboard.press(action.key);
|
|
|
|
|
|
} else if (action.type === 'wait') {
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, action.ms));
|
|
|
|
|
|
} else if (action.type === 'vue_fill') {
|
|
|
|
|
|
await page.waitForSelector(action.selector, { timeout: 15000 });
|
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
|
(sel) => {
|
|
|
|
|
|
const el = document.querySelector(sel);
|
|
|
|
|
|
if (!el) return false;
|
|
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
|
|
return rect.width > 0 && rect.height > 0;
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 2000, polling: 100 },
|
|
|
|
|
|
action.selector
|
|
|
|
|
|
).catch(() => new Promise((r) => setTimeout(r, 500)));
|
|
|
|
|
|
|
|
|
|
|
|
await page.evaluate((sel, val) => {
|
|
|
|
|
|
const inputs = document.querySelectorAll(sel);
|
|
|
|
|
|
for (const input of inputs) {
|
|
|
|
|
|
if (input.getBoundingClientRect().width > 0 && input.getBoundingClientRect().height > 0) {
|
|
|
|
|
|
input.value = val;
|
|
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
|
|
input.focus();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, action.selector, action.value);
|
|
|
|
|
|
} else if (action.type === 'vue_click') {
|
|
|
|
|
|
await page.waitForSelector(action.selector, { timeout: 15000 });
|
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
|
(sel) => {
|
|
|
|
|
|
const el = document.querySelector(sel);
|
|
|
|
|
|
if (!el) return false;
|
|
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
|
|
return rect.width > 0 && rect.height > 0;
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 2000, polling: 100 },
|
|
|
|
|
|
action.selector
|
|
|
|
|
|
).catch(() => new Promise((r) => setTimeout(r, 500)));
|
|
|
|
|
|
|
|
|
|
|
|
await page.evaluate((sel, matchText) => {
|
|
|
|
|
|
const btns = document.querySelectorAll(sel);
|
|
|
|
|
|
for (const btn of btns) {
|
|
|
|
|
|
if (btn.getBoundingClientRect().width > 0 && btn.getBoundingClientRect().height > 0) {
|
|
|
|
|
|
let shouldClick = false;
|
|
|
|
|
|
if (matchText) {
|
|
|
|
|
|
const text = (btn.textContent || btn.innerText || '').trim();
|
|
|
|
|
|
if (text.includes(matchText.trim())) { shouldClick = true; }
|
|
|
|
|
|
} else {
|
|
|
|
|
|
shouldClick = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (shouldClick) {
|
|
|
|
|
|
btn.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
|
|
|
|
const eventOpts = { bubbles: true, cancelable: true, view: window };
|
|
|
|
|
|
btn.dispatchEvent(new MouseEvent('mousedown', eventOpts));
|
|
|
|
|
|
btn.dispatchEvent(new MouseEvent('mouseup', eventOpts));
|
|
|
|
|
|
btn.dispatchEvent(new MouseEvent('click', eventOpts));
|
|
|
|
|
|
btn.click();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, action.selector, action.text);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createApiInterceptor(page, apiUrls, defaultTimeoutMs = API_INTERCEPT_TIMEOUT_MS) {
|
|
|
|
|
|
const interceptedApis = {};
|
|
|
|
|
|
let responseHandler = null;
|
|
|
|
|
|
let timeoutId = null;
|
|
|
|
|
|
let resolveWait = null;
|
|
|
|
|
|
let waitPromise = null;
|
|
|
|
|
|
|
|
|
|
|
|
const tryResolveWait = () => {
|
|
|
|
|
|
if (!resolveWait || Object.keys(interceptedApis).length < apiUrls.length) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (timeoutId) {
|
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
|
timeoutId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (responseHandler) {
|
|
|
|
|
|
page.off('response', responseHandler);
|
|
|
|
|
|
responseHandler = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const done = resolveWait;
|
|
|
|
|
|
resolveWait = null;
|
|
|
|
|
|
done();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
|
if (timeoutId) {
|
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
|
timeoutId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (responseHandler) {
|
|
|
|
|
|
page.off('response', responseHandler);
|
|
|
|
|
|
responseHandler = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (resolveWait) {
|
|
|
|
|
|
const done = resolveWait;
|
|
|
|
|
|
resolveWait = null;
|
|
|
|
|
|
done();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
responseHandler = async (response) => {
|
|
|
|
|
|
const request = response.request();
|
|
|
|
|
|
if (request.method() === 'OPTIONS') return;
|
|
|
|
|
|
const matchedPath = apiUrls.find((api) => request.url().includes(api) && !interceptedApis[api]);
|
|
|
|
|
|
if (matchedPath && response.status() >= 200 && response.status() < 300) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
interceptedApis[matchedPath] = {
|
|
|
|
|
|
url: request.url(),
|
|
|
|
|
|
headers: request.headers(),
|
|
|
|
|
|
data: JSON.parse(await response.text()),
|
|
|
|
|
|
};
|
|
|
|
|
|
tryResolveWait();
|
|
|
|
|
|
} catch (_) { /* JSON 解析失败跳过 */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
page.on('response', responseHandler);
|
|
|
|
|
|
|
|
|
|
|
|
const beginWaiting = (timeoutMs = defaultTimeoutMs) => {
|
|
|
|
|
|
if (waitPromise) {
|
|
|
|
|
|
return waitPromise;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Object.keys(interceptedApis).length >= apiUrls.length) {
|
|
|
|
|
|
waitPromise = Promise.resolve();
|
|
|
|
|
|
return waitPromise;
|
|
|
|
|
|
}
|
|
|
|
|
|
waitPromise = new Promise((resolve) => {
|
|
|
|
|
|
resolveWait = resolve;
|
|
|
|
|
|
timeoutId = setTimeout(() => {
|
|
|
|
|
|
cleanup();
|
|
|
|
|
|
resolve();
|
|
|
|
|
|
}, timeoutMs);
|
|
|
|
|
|
});
|
|
|
|
|
|
return waitPromise;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const waitForApis = async (timeoutMs = defaultTimeoutMs) => {
|
|
|
|
|
|
await beginWaiting(timeoutMs);
|
|
|
|
|
|
return waitPromise;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return { interceptedApis, beginWaiting, waitForApis, cleanup };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function seedShareTokenCookie(page, pageUrl) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = new URL(pageUrl);
|
|
|
|
|
|
const token = parsed.searchParams.get('token');
|
|
|
|
|
|
if (!token) return;
|
|
|
|
|
|
const cookieUrl = `http://${parsed.hostname}/`;
|
|
|
|
|
|
await page.setCookie({ name: 'share_token', value: token, url: cookieUrl });
|
|
|
|
|
|
} catch (_) { /* ignore */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveShareToken(...urls) {
|
|
|
|
|
|
for (const url of urls) {
|
|
|
|
|
|
const token = extractShareTokenFromUrl(url);
|
|
|
|
|
|
if (token) return token;
|
|
|
|
|
|
}
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveInterceptTimeout(antiBot) {
|
|
|
|
|
|
const cfg = normalizeAntiBot(antiBot);
|
|
|
|
|
|
return cfg.enabled && cfg.profile === 'real'
|
|
|
|
|
|
? API_INTERCEPT_TIMEOUT_REAL_MS
|
|
|
|
|
|
: API_INTERCEPT_TIMEOUT_MS;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 并发监控:供上游调度器判断负载 */
|
|
|
|
|
|
app.get('/api/stats', (_req, res) => {
|
|
|
|
|
|
const executablePath = resolveChromeExecutable();
|
|
|
|
|
|
const factoryStats = getFactoryStats();
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
shuttingDown,
|
|
|
|
|
|
queue: factoryStats.standard,
|
|
|
|
|
|
queueReal: factoryStats.real,
|
2026-06-20 15:49:40 +08:00
|
|
|
|
pool: factoryStats.pool,
|
|
|
|
|
|
sessions: getSessionStats(),
|
|
|
|
|
|
profiles: getProfileStats(),
|
2026-06-20 04:47:34 +08:00
|
|
|
|
realBrowserReady: factoryStats.realBrowserReady,
|
|
|
|
|
|
captchaConfigured: isCaptchaConfigured(),
|
|
|
|
|
|
chrome: {
|
|
|
|
|
|
executablePath,
|
|
|
|
|
|
exists: fs.existsSync(executablePath),
|
|
|
|
|
|
},
|
|
|
|
|
|
config: {
|
|
|
|
|
|
maxConcurrentBrowsers: MAX_CONCURRENT_BROWSERS,
|
|
|
|
|
|
maxConcurrentBrowsersReal: MAX_CONCURRENT_BROWSERS_REAL,
|
|
|
|
|
|
apiInterceptTimeoutMs: API_INTERCEPT_TIMEOUT_MS,
|
|
|
|
|
|
apiInterceptTimeoutRealMs: API_INTERCEPT_TIMEOUT_REAL_MS,
|
2026-06-20 15:49:40 +08:00
|
|
|
|
navigationTimeoutMs: NAVIGATION_TIMEOUT_MS,
|
|
|
|
|
|
navigationWaitUntil: NAVIGATION_WAIT_UNTIL,
|
|
|
|
|
|
sessionTtlMs: SESSION_TTL_MS,
|
|
|
|
|
|
persistBrowserProfile: PERSIST_BROWSER_PROFILE,
|
|
|
|
|
|
poolIdleMs: POOL_IDLE_MS,
|
|
|
|
|
|
maxPooledBrowsers: MAX_POOLED_BROWSERS,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** 运维调试:查看指定 sessionKey 的磁盘会话与 profile 状态 */
|
|
|
|
|
|
app.get('/api/session/:sessionKey', (req, res) => {
|
|
|
|
|
|
const sessionKey = decodeURIComponent(req.params.sessionKey || '');
|
|
|
|
|
|
if (!sessionKey) {
|
|
|
|
|
|
return res.status(400).json({ success: false, error: 'sessionKey 不能为空' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const info = getSessionDebugInfo(sessionKey);
|
|
|
|
|
|
const profileStandard = getProfileStats();
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
session: info,
|
|
|
|
|
|
profileExists: {
|
|
|
|
|
|
standard: profileExists(sessionKey, 'standard'),
|
|
|
|
|
|
real: profileExists(sessionKey, 'real'),
|
2026-06-20 04:47:34 +08:00
|
|
|
|
},
|
2026-06-20 15:49:40 +08:00
|
|
|
|
profilesDir: profileStandard.profilesDir,
|
2026-06-20 04:47:34 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** 运维自检:实际尝试 launch 一次 Browser(profile=standard|real) */
|
|
|
|
|
|
app.get('/api/browser-launch-test', async (req, res) => {
|
|
|
|
|
|
const profile = req.query.profile === 'real' ? 'real' : 'standard';
|
|
|
|
|
|
let browser;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await runBrowserTask(`browser-launch-test-${profile}`, async () => {
|
|
|
|
|
|
if (profile === 'real') {
|
|
|
|
|
|
const session = await createBrowserSession({ profile: 'real', extraArgs: ['--mute-audio'] });
|
|
|
|
|
|
browser = session.browser;
|
|
|
|
|
|
const pages = await browser.pages();
|
|
|
|
|
|
return { pageCount: pages.length, profile };
|
|
|
|
|
|
}
|
|
|
|
|
|
browser = await launchStandardBrowser(['--mute-audio']);
|
|
|
|
|
|
const pages = await browser.pages();
|
|
|
|
|
|
return { pageCount: pages.length, profile };
|
|
|
|
|
|
}, profile);
|
|
|
|
|
|
res.json({ success: true, message: 'Browser 启动成功', profile });
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
error: error.message,
|
|
|
|
|
|
profile,
|
|
|
|
|
|
chrome: {
|
|
|
|
|
|
executablePath: resolveChromeExecutable(),
|
|
|
|
|
|
exists: fs.existsSync(resolveChromeExecutable()),
|
|
|
|
|
|
},
|
|
|
|
|
|
realBrowserReady: isRealBrowserAvailable(),
|
|
|
|
|
|
runtimeDir: RUNTIME_DIR,
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (browser) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await browser.close();
|
|
|
|
|
|
} catch (_) { /* ignore */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** 接口一:初始化授权与首屏拦截 */
|
|
|
|
|
|
app.post('/api/auth-and-intercept', async (req, res) => {
|
|
|
|
|
|
const { pageUrl, apiUrls, authActions, antiBot: rawAntiBot } = req.body;
|
|
|
|
|
|
if (!pageUrl || !Array.isArray(apiUrls)) {
|
|
|
|
|
|
return res.status(400).json({ success: false, error: '参数错误' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const antiBot = resolveAntiBotForPage(rawAntiBot, pageUrl);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
const profile = antiBot.profile || 'standard';
|
|
|
|
|
|
const interceptTimeout = resolveInterceptTimeout(antiBot);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const cfLog = { profile, cfDetected: false, turnstileStage: null, solverUsed: false, elapsedMs: 0, browserReused: false };
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await runBrowserTask('auth-and-intercept', async () => {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
let browserSession = null;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
let interceptorCleanup = null;
|
2026-07-03 20:12:20 +08:00
|
|
|
|
let page = null;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
const taskStarted = Date.now();
|
2026-06-20 15:49:40 +08:00
|
|
|
|
let cfDestroyBrowser = false;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
browserSession = await acquireBrowserSession({
|
2026-06-20 04:47:34 +08:00
|
|
|
|
profile,
|
|
|
|
|
|
extraArgs: ['--mute-audio'],
|
2026-06-20 15:49:40 +08:00
|
|
|
|
sessionKey: antiBot.sessionKey || '',
|
2026-06-20 04:47:34 +08:00
|
|
|
|
});
|
2026-07-03 20:51:56 +08:00
|
|
|
|
tagBrowserSession(browserSession, profile, antiBot.sessionKey || '', ['--mute-audio']);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
cfLog.browserReused = !!browserSession.reused;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const { storedSession, mergedCookies } = resolveSessionContext(antiBot, pageUrl);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const navigationUrl = resolveNavigationUrl(pageUrl, antiBot);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-07-03 20:51:56 +08:00
|
|
|
|
const authSessionRef = { current: browserSession };
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = await createTaskPage(
|
2026-07-01 01:26:27 +08:00
|
|
|
|
browserSession.browser,
|
2026-07-03 20:12:20 +08:00
|
|
|
|
browserSession.reused ? null : browserSession.page,
|
|
|
|
|
|
navigationUrl,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
antiBot,
|
2026-07-03 20:51:56 +08:00
|
|
|
|
mergedCookies,
|
|
|
|
|
|
{ browserSession, browserSessionRef: authSessionRef }
|
2026-07-01 01:26:27 +08:00
|
|
|
|
);
|
2026-07-03 20:51:56 +08:00
|
|
|
|
browserSession = authSessionRef.current;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
const httpResolvedUrl = await resolveHttpRedirects(pageUrl).catch(() => pageUrl);
|
|
|
|
|
|
if (httpResolvedUrl !== pageUrl) {
|
|
|
|
|
|
console.log(`[URL解析] HTTP 重定向: ${pageUrl} -> ${httpResolvedUrl}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let shareToken = resolveShareToken(pageUrl, httpResolvedUrl);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await seedShareTokenCookie(page, navigationUrl);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
if (httpResolvedUrl !== pageUrl) {
|
|
|
|
|
|
await seedShareTokenCookie(page, httpResolvedUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await prepareWhatshubShortLinkBeforeNav(page, navigationUrl, antiBot);
|
|
|
|
|
|
await navigateToPage(page, navigationUrl);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
let finalPageUrl = await waitForUrlSettled(page);
|
2026-07-02 03:13:27 +08:00
|
|
|
|
applyHuojianBusinessHostHint(antiBot, finalPageUrl);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
if (finalPageUrl !== pageUrl) {
|
|
|
|
|
|
console.log(`[URL解析] 浏览器落地: ${pageUrl} -> ${finalPageUrl}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const browserCtx = {
|
|
|
|
|
|
browserSession,
|
|
|
|
|
|
page,
|
|
|
|
|
|
antiBot,
|
|
|
|
|
|
profile,
|
|
|
|
|
|
navigationUrl,
|
|
|
|
|
|
mergedCookies,
|
|
|
|
|
|
baseExtraArgs: ['--mute-audio'],
|
|
|
|
|
|
pageExtraOpts: {},
|
|
|
|
|
|
afterRelaunchNav: async (p) => {
|
|
|
|
|
|
await seedShareTokenCookie(p, navigationUrl);
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
await ensureCaptchaProxyBrowserIfNeeded(browserCtx);
|
|
|
|
|
|
browserSession = browserCtx.browserSession;
|
|
|
|
|
|
page = browserCtx.page;
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
let interceptor;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
if (antiBot.enabled) {
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const cfResult = await runCloudflareWithBusinessGuard(
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page, antiBot, waitForUrlSettled, storedSession, pageUrl, browserCtx
|
2026-07-01 01:26:27 +08:00
|
|
|
|
);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = browserCtx.page;
|
|
|
|
|
|
browserSession = browserCtx.browserSession;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
cfLog.cfDetected = cfResult.cfDetected;
|
|
|
|
|
|
cfLog.turnstileStage = cfResult.stage;
|
|
|
|
|
|
cfLog.solverUsed = cfResult.solverUsed;
|
|
|
|
|
|
cfLog.elapsedMs = cfResult.elapsedMs;
|
|
|
|
|
|
|
|
|
|
|
|
if (!cfResult.success) {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
cfDestroyBrowser = true;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
res.status(422).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
code: cfResult.code || 'CF_TURNSTILE_FAILED',
|
|
|
|
|
|
challengeType: cfResult.challengeType || 'turnstile',
|
|
|
|
|
|
stage: cfResult.stage || 'timeout',
|
|
|
|
|
|
error: 'Cloudflare Turnstile 验证失败',
|
|
|
|
|
|
cf: cfLog,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
finalPageUrl = page.url();
|
2026-07-02 03:13:27 +08:00
|
|
|
|
applyHuojianBusinessHostHint(antiBot, finalPageUrl);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
touchSessionIfPresent(antiBot.sessionKey);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
|
|
|
|
|
|
await awaitPostCfBusinessReady(page, antiBot, waitForUrlSettled, storedSession, pageUrl);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const finalShareToken = resolveShareToken(finalPageUrl);
|
|
|
|
|
|
if (finalShareToken && finalShareToken !== shareToken) {
|
|
|
|
|
|
shareToken = finalShareToken;
|
|
|
|
|
|
await seedShareTokenCookie(page, finalPageUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
interceptor = createApiInterceptor(page, apiUrls, interceptTimeout);
|
|
|
|
|
|
interceptorCleanup = interceptor.cleanup;
|
|
|
|
|
|
await attachHttpIpRewriteInterceptor(page, 'auth-and-intercept', { shareToken });
|
2026-06-29 04:54:41 +08:00
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
await executeAuthActionsWithDiagnostics(page, authActions);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
await interceptor.waitForApis(interceptTimeout);
|
|
|
|
|
|
|
|
|
|
|
|
const rawCookies = await page.cookies();
|
|
|
|
|
|
const cookiePayload = rawCookies.map((c) => ({
|
|
|
|
|
|
name: c.name,
|
|
|
|
|
|
value: c.value,
|
|
|
|
|
|
domain: c.domain,
|
|
|
|
|
|
path: c.path,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
if (antiBot.sessionKey) {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
let lastHost = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
lastHost = new URL(finalPageUrl).hostname;
|
|
|
|
|
|
} catch (_) { /* ignore */ }
|
|
|
|
|
|
saveSession(antiBot.sessionKey, cookiePayload, SESSION_TTL_MS, {
|
|
|
|
|
|
lastHost,
|
|
|
|
|
|
savedProfile: profile,
|
|
|
|
|
|
});
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
pageUrl,
|
|
|
|
|
|
finalPageUrl,
|
|
|
|
|
|
interceptedApis: interceptor.interceptedApis,
|
|
|
|
|
|
cookies: cookiePayload,
|
|
|
|
|
|
cf: cfLog,
|
|
|
|
|
|
profile,
|
2026-06-20 15:49:40 +08:00
|
|
|
|
browserReused: browserSession.reused,
|
2026-06-20 04:47:34 +08:00
|
|
|
|
elapsedMs: Date.now() - taskStarted,
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (interceptorCleanup) interceptorCleanup();
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await closeTaskPage(page);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
if (browserSession) {
|
|
|
|
|
|
await browserSession.release(cfDestroyBrowser);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, profile);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (!res.headersSent) sendTaskError(res, error, { cf: cfLog, profile });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** 接口二:脱离 UI 极速并发拉取 */
|
|
|
|
|
|
app.post('/api/batch-fetch', async (req, res) => {
|
|
|
|
|
|
const { tasks, cookies, antiBot: rawAntiBot } = req.body;
|
|
|
|
|
|
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
|
|
|
|
return res.status(400).json({ success: false, error: 'tasks 参数错误' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const antiBot = normalizeAntiBot(rawAntiBot);
|
|
|
|
|
|
const profile = antiBot.profile || 'standard';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await runBrowserTask('batch-fetch', async () => {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
let browserSession = null;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
try {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
browserSession = await acquireBrowserSession({
|
2026-06-20 04:47:34 +08:00
|
|
|
|
profile,
|
|
|
|
|
|
extraArgs: ['--disable-web-security'],
|
2026-06-20 15:49:40 +08:00
|
|
|
|
sessionKey: antiBot.sessionKey || '',
|
2026-06-20 04:47:34 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const firstUrl = tasks[0].fullUrl;
|
|
|
|
|
|
const { mergedCookies } = resolveSessionContext(antiBot, firstUrl, cookies || []);
|
|
|
|
|
|
|
|
|
|
|
|
const page = await createManagedPage(browserSession.browser, browserSession.page, { cookies: mergedCookies });
|
|
|
|
|
|
const firstOrigin = new URL(firstUrl).origin;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
await page.setRequestInterception(true);
|
|
|
|
|
|
page.on('request', (reqObj) => {
|
|
|
|
|
|
if (reqObj.isInterceptResolutionHandled()) return;
|
|
|
|
|
|
const url = rewriteHttpsToHttpForLiteralIp(reqObj.url());
|
|
|
|
|
|
const resourceType = reqObj.resourceType();
|
|
|
|
|
|
if (['image', 'media', 'font'].includes(resourceType)) {
|
|
|
|
|
|
reqObj.abort();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (url === firstOrigin || url === firstOrigin + '/') {
|
|
|
|
|
|
reqObj.respond({ status: 200, contentType: 'text/html', body: '<html><body>Context Ready</body></html>' });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
reqObj.continue();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
await navigateToPage(page, firstOrigin);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
const FETCH_CONCURRENCY = Math.max(1, parseInt(process.env.FETCH_CONCURRENCY || '5', 10));
|
|
|
|
|
|
const FETCH_RETRY = 2;
|
|
|
|
|
|
|
|
|
|
|
|
const fetchResults = await page.evaluate(async (taskList, concurrency, maxRetry) => {
|
|
|
|
|
|
const out = {};
|
|
|
|
|
|
const createLimiter = (limit) => {
|
|
|
|
|
|
let active = 0;
|
|
|
|
|
|
const queue = [];
|
|
|
|
|
|
return (fn) => new Promise((resolve, reject) => {
|
|
|
|
|
|
const run = async () => {
|
|
|
|
|
|
active++;
|
|
|
|
|
|
try { resolve(await fn()); }
|
|
|
|
|
|
catch (e) { reject(e); }
|
|
|
|
|
|
finally {
|
|
|
|
|
|
active--;
|
|
|
|
|
|
if (queue.length) queue.shift()();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
if (active < limit) run();
|
|
|
|
|
|
else queue.push(run);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
const limit = createLimiter(concurrency);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchWithRetry = async (finalUrl, options, retriesLeft) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(finalUrl, options);
|
|
|
|
|
|
return { success: true, data: await res.json() };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (retriesLeft <= 0) return { success: false, error: err.toString() };
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 300 * Math.pow(2, maxRetry - retriesLeft)));
|
|
|
|
|
|
return fetchWithRetry(finalUrl, options, retriesLeft - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const jobs = [];
|
|
|
|
|
|
for (const task of taskList) {
|
|
|
|
|
|
out[task.apiPath] = [];
|
|
|
|
|
|
const cleanHeaders = { ...task.headers };
|
|
|
|
|
|
['content-length', 'host', 'origin', 'referer', 'accept-encoding'].forEach((k) => delete cleanHeaders[k]);
|
|
|
|
|
|
|
|
|
|
|
|
for (const params of task.paramList) {
|
|
|
|
|
|
jobs.push(limit(async () => {
|
|
|
|
|
|
let finalUrl = task.fullUrl;
|
|
|
|
|
|
const options = { method: task.method.toUpperCase(), headers: cleanHeaders };
|
|
|
|
|
|
if (options.method === 'GET') {
|
|
|
|
|
|
const qs = new URLSearchParams(params).toString();
|
|
|
|
|
|
finalUrl = finalUrl.includes('?') ? `${finalUrl}&${qs}` : `${finalUrl}?${qs}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
options.body = JSON.stringify(params);
|
|
|
|
|
|
}
|
|
|
|
|
|
const result = await fetchWithRetry(finalUrl, options, maxRetry);
|
|
|
|
|
|
out[task.apiPath].push({ params, ...result });
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await Promise.all(jobs);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}, tasks, FETCH_CONCURRENCY, FETCH_RETRY);
|
|
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
res.json({ success: true, results: fetchResults, profile, browserReused: browserSession.reused });
|
2026-06-20 04:47:34 +08:00
|
|
|
|
} finally {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
if (browserSession) {
|
|
|
|
|
|
await browserSession.release(false);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, profile);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (!res.headersSent) sendTaskError(res, error, { profile });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** 接口三:强制 UI 模拟点击拉取 */
|
|
|
|
|
|
app.post('/api/ui-pagination', async (req, res) => {
|
|
|
|
|
|
const {
|
|
|
|
|
|
pageUrl,
|
|
|
|
|
|
finalPageUrl,
|
|
|
|
|
|
apiUrl,
|
|
|
|
|
|
cookies,
|
|
|
|
|
|
nextBtnSelector,
|
|
|
|
|
|
clicksToPerform,
|
|
|
|
|
|
waitMs = 2000,
|
|
|
|
|
|
firstPageData,
|
|
|
|
|
|
authActions,
|
|
|
|
|
|
antiBot: rawAntiBot,
|
|
|
|
|
|
} = req.body;
|
|
|
|
|
|
const navigationUrl = finalPageUrl || pageUrl;
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const antiBot = resolveAntiBotForPage(rawAntiBot, pageUrl || navigationUrl);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
const profile = antiBot.profile || 'standard';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await runBrowserTask('ui-pagination', async () => {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
let browserSession = null;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
const debugLogs = [];
|
2026-07-03 20:12:20 +08:00
|
|
|
|
let page = null;
|
2026-06-20 15:49:40 +08:00
|
|
|
|
let cfDestroyBrowser = false;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
browserSession = await acquireBrowserSession({
|
2026-06-20 04:47:34 +08:00
|
|
|
|
profile,
|
|
|
|
|
|
extraArgs: ['--window-size=1920,1080'],
|
2026-06-20 15:49:40 +08:00
|
|
|
|
sessionKey: antiBot.sessionKey || '',
|
2026-06-20 04:47:34 +08:00
|
|
|
|
});
|
2026-07-03 20:51:56 +08:00
|
|
|
|
tagBrowserSession(browserSession, profile, antiBot.sessionKey || '', ['--window-size=1920,1080']);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const { storedSession, mergedCookies } = resolveSessionContext(antiBot, navigationUrl, cookies || []);
|
|
|
|
|
|
|
2026-07-03 20:51:56 +08:00
|
|
|
|
const uiSessionRef = { current: browserSession };
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = await createTaskPage(
|
2026-07-01 01:26:27 +08:00
|
|
|
|
browserSession.browser,
|
2026-07-03 20:12:20 +08:00
|
|
|
|
browserSession.reused ? null : browserSession.page,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
navigationUrl,
|
|
|
|
|
|
antiBot,
|
|
|
|
|
|
mergedCookies,
|
2026-07-03 20:51:56 +08:00
|
|
|
|
{
|
|
|
|
|
|
viewport: { width: 1920, height: 1080 },
|
|
|
|
|
|
browserSession,
|
|
|
|
|
|
browserSessionRef: uiSessionRef,
|
|
|
|
|
|
}
|
2026-07-01 01:26:27 +08:00
|
|
|
|
);
|
2026-07-03 20:51:56 +08:00
|
|
|
|
browserSession = uiSessionRef.current;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const log = (msg) => { console.log(msg); debugLogs.push(msg); };
|
2026-06-20 04:47:34 +08:00
|
|
|
|
log(`[启动] 准备访问页面: ${navigationUrl}`);
|
|
|
|
|
|
if (finalPageUrl && finalPageUrl !== pageUrl) {
|
|
|
|
|
|
log(`[URL解析] 使用 auth 阶段落地地址,跳过短链重定向: ${pageUrl} -> ${finalPageUrl}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const shareToken = resolveShareToken(finalPageUrl, pageUrl);
|
|
|
|
|
|
await seedShareTokenCookie(page, navigationUrl);
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
await prepareWhatshubShortLinkBeforeNav(page, navigationUrl, antiBot);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
await navigateToPage(page, navigationUrl);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
const settledPageUrl = await waitForUrlSettled(page);
|
|
|
|
|
|
if (settledPageUrl !== navigationUrl) {
|
|
|
|
|
|
log(`[URL解析] 翻页阶段落地: ${navigationUrl} -> ${settledPageUrl}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const browserCtx = {
|
|
|
|
|
|
browserSession,
|
|
|
|
|
|
page,
|
|
|
|
|
|
antiBot,
|
|
|
|
|
|
profile,
|
|
|
|
|
|
navigationUrl,
|
|
|
|
|
|
mergedCookies,
|
|
|
|
|
|
baseExtraArgs: ['--window-size=1920,1080'],
|
|
|
|
|
|
pageExtraOpts: { viewport: { width: 1920, height: 1080 } },
|
|
|
|
|
|
afterRelaunchNav: async (p) => {
|
|
|
|
|
|
await seedShareTokenCookie(p, navigationUrl);
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
await ensureCaptchaProxyBrowserIfNeeded(browserCtx);
|
|
|
|
|
|
browserSession = browserCtx.browserSession;
|
|
|
|
|
|
page = browserCtx.page;
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
if (antiBot.enabled) {
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const cfResult = await runCloudflareWithBusinessGuard(
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page, antiBot, waitForUrlSettled, storedSession, navigationUrl, browserCtx
|
2026-07-01 01:26:27 +08:00
|
|
|
|
);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = browserCtx.page;
|
|
|
|
|
|
browserSession = browserCtx.browserSession;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
if (!cfResult.success) {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
cfDestroyBrowser = true;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
res.status(422).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
code: cfResult.code || 'CF_TURNSTILE_FAILED',
|
|
|
|
|
|
challengeType: cfResult.challengeType,
|
|
|
|
|
|
stage: cfResult.stage,
|
|
|
|
|
|
error: 'Cloudflare Turnstile 验证失败(翻页阶段)',
|
|
|
|
|
|
debug: debugLogs,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-20 15:49:40 +08:00
|
|
|
|
touchSessionIfPresent(antiBot.sessionKey);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
await awaitPostCfBusinessReady(page, antiBot, waitForUrlSettled, storedSession, navigationUrl);
|
|
|
|
|
|
await attachHttpIpRewriteInterceptor(page, 'ui-pagination', { shareToken });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await attachHttpIpRewriteInterceptor(page, 'ui-pagination', { shareToken });
|
2026-06-29 04:54:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const paginationResult = await runUiPagination(page, {
|
|
|
|
|
|
apiUrl,
|
|
|
|
|
|
nextBtnSelector,
|
|
|
|
|
|
clicksToPerform,
|
|
|
|
|
|
waitMs,
|
|
|
|
|
|
firstPageData,
|
|
|
|
|
|
authActions,
|
|
|
|
|
|
executeAuthActions,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
data: paginationResult.data,
|
|
|
|
|
|
debug: [...debugLogs, ...paginationResult.debugLogs],
|
|
|
|
|
|
profile,
|
|
|
|
|
|
browserReused: browserSession.reused,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (!res.headersSent) {
|
|
|
|
|
|
sendTaskError(res, error, { debug: debugLogs, profile });
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await closeTaskPage(page);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
if (browserSession) {
|
|
|
|
|
|
await browserSession.release(cfDestroyBrowser);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
2026-06-20 15:49:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, profile);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (!res.headersSent) sendTaskError(res, error, { profile });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
/** 接口四:单 Browser 内 auth + 拦截 + UI 翻页(降低 CF 二次触发) */
|
|
|
|
|
|
app.post('/api/auth-intercept-and-paginate', async (req, res) => {
|
|
|
|
|
|
const {
|
|
|
|
|
|
pageUrl,
|
|
|
|
|
|
apiUrls,
|
|
|
|
|
|
authActions,
|
|
|
|
|
|
antiBot: rawAntiBot,
|
|
|
|
|
|
pagination,
|
|
|
|
|
|
} = req.body;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
if (!pageUrl || !Array.isArray(apiUrls) || !pagination) {
|
|
|
|
|
|
return res.status(400).json({ success: false, error: '参数错误:需要 pageUrl、apiUrls、pagination' });
|
|
|
|
|
|
}
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const {
|
|
|
|
|
|
apiUrl,
|
|
|
|
|
|
nextBtnSelector,
|
|
|
|
|
|
clicksToPerform = 0,
|
|
|
|
|
|
waitMs = 2000,
|
|
|
|
|
|
firstPageData,
|
|
|
|
|
|
} = pagination;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const antiBot = resolveAntiBotForPage(rawAntiBot, pageUrl);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const profile = antiBot.profile || 'standard';
|
|
|
|
|
|
const interceptTimeout = resolveInterceptTimeout(antiBot);
|
|
|
|
|
|
const cfLog = { profile, cfDetected: false, turnstileStage: null, solverUsed: false, elapsedMs: 0, browserReused: false };
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await runBrowserTask('auth-intercept-and-paginate', async () => {
|
|
|
|
|
|
let browserSession = null;
|
|
|
|
|
|
let interceptorCleanup = null;
|
2026-07-03 20:12:20 +08:00
|
|
|
|
let page = null;
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const taskStarted = Date.now();
|
|
|
|
|
|
let cfDestroyBrowser = false;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
try {
|
|
|
|
|
|
browserSession = await acquireBrowserSession({
|
|
|
|
|
|
profile,
|
|
|
|
|
|
extraArgs: ['--mute-audio', '--window-size=1920,1080'],
|
|
|
|
|
|
sessionKey: antiBot.sessionKey || '',
|
|
|
|
|
|
});
|
2026-07-03 20:51:56 +08:00
|
|
|
|
tagBrowserSession(
|
|
|
|
|
|
browserSession,
|
|
|
|
|
|
profile,
|
|
|
|
|
|
antiBot.sessionKey || '',
|
|
|
|
|
|
['--mute-audio', '--window-size=1920,1080']
|
|
|
|
|
|
);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
cfLog.browserReused = !!browserSession.reused;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const { storedSession, mergedCookies } = resolveSessionContext(antiBot, pageUrl);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const navigationUrl = resolveNavigationUrl(pageUrl, antiBot);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-07-03 20:51:56 +08:00
|
|
|
|
const mergedSessionRef = { current: browserSession };
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = await createTaskPage(
|
2026-07-01 01:26:27 +08:00
|
|
|
|
browserSession.browser,
|
2026-07-03 20:12:20 +08:00
|
|
|
|
browserSession.reused ? null : browserSession.page,
|
|
|
|
|
|
navigationUrl,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
antiBot,
|
|
|
|
|
|
mergedCookies,
|
2026-07-03 20:51:56 +08:00
|
|
|
|
{
|
|
|
|
|
|
viewport: { width: 1920, height: 1080 },
|
|
|
|
|
|
browserSession,
|
|
|
|
|
|
browserSessionRef: mergedSessionRef,
|
|
|
|
|
|
}
|
2026-07-01 01:26:27 +08:00
|
|
|
|
);
|
2026-07-03 20:51:56 +08:00
|
|
|
|
browserSession = mergedSessionRef.current;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
const httpResolvedUrl = await resolveHttpRedirects(pageUrl).catch(() => pageUrl);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
let shareToken = resolveShareToken(pageUrl, httpResolvedUrl);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await seedShareTokenCookie(page, navigationUrl);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await prepareWhatshubShortLinkBeforeNav(page, navigationUrl, antiBot);
|
|
|
|
|
|
await navigateToPage(page, navigationUrl);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
let finalPageUrl = await waitForUrlSettled(page);
|
2026-07-02 03:13:27 +08:00
|
|
|
|
applyHuojianBusinessHostHint(antiBot, finalPageUrl);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const browserCtx = {
|
|
|
|
|
|
browserSession,
|
|
|
|
|
|
page,
|
|
|
|
|
|
antiBot,
|
|
|
|
|
|
profile,
|
|
|
|
|
|
navigationUrl,
|
|
|
|
|
|
mergedCookies,
|
|
|
|
|
|
baseExtraArgs: ['--mute-audio', '--window-size=1920,1080'],
|
|
|
|
|
|
pageExtraOpts: { viewport: { width: 1920, height: 1080 } },
|
|
|
|
|
|
afterRelaunchNav: async (p) => {
|
|
|
|
|
|
await seedShareTokenCookie(p, navigationUrl);
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
await ensureCaptchaProxyBrowserIfNeeded(browserCtx);
|
|
|
|
|
|
browserSession = browserCtx.browserSession;
|
|
|
|
|
|
page = browserCtx.page;
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
let interceptor;
|
2026-06-20 15:49:40 +08:00
|
|
|
|
if (antiBot.enabled) {
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const cfResult = await runCloudflareWithBusinessGuard(
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page, antiBot, waitForUrlSettled, storedSession, navigationUrl, browserCtx
|
2026-07-01 01:26:27 +08:00
|
|
|
|
);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
page = browserCtx.page;
|
|
|
|
|
|
browserSession = browserCtx.browserSession;
|
2026-06-20 15:49:40 +08:00
|
|
|
|
cfLog.cfDetected = cfResult.cfDetected;
|
|
|
|
|
|
cfLog.turnstileStage = cfResult.stage;
|
|
|
|
|
|
cfLog.solverUsed = cfResult.solverUsed;
|
|
|
|
|
|
cfLog.elapsedMs = cfResult.elapsedMs;
|
|
|
|
|
|
|
|
|
|
|
|
if (!cfResult.success) {
|
|
|
|
|
|
cfDestroyBrowser = true;
|
|
|
|
|
|
res.status(422).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
code: cfResult.code || 'CF_TURNSTILE_FAILED',
|
|
|
|
|
|
challengeType: cfResult.challengeType || 'turnstile',
|
|
|
|
|
|
stage: cfResult.stage || 'timeout',
|
|
|
|
|
|
error: 'Cloudflare Turnstile 验证失败',
|
|
|
|
|
|
cf: cfLog,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
2026-06-20 15:49:40 +08:00
|
|
|
|
finalPageUrl = page.url();
|
2026-07-02 03:13:27 +08:00
|
|
|
|
applyHuojianBusinessHostHint(antiBot, finalPageUrl);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
touchSessionIfPresent(antiBot.sessionKey);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await awaitPostCfBusinessReady(page, antiBot, waitForUrlSettled, storedSession, navigationUrl);
|
2026-06-29 04:54:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
interceptor = createApiInterceptor(page, apiUrls, interceptTimeout);
|
|
|
|
|
|
interceptorCleanup = interceptor.cleanup;
|
|
|
|
|
|
await attachHttpIpRewriteInterceptor(page, 'auth-intercept-and-paginate', { shareToken });
|
|
|
|
|
|
|
|
|
|
|
|
await executeAuthActionsWithDiagnostics(page, authActions);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
await interceptor.waitForApis(interceptTimeout);
|
|
|
|
|
|
|
|
|
|
|
|
const rawCookies = await page.cookies();
|
|
|
|
|
|
const cookiePayload = rawCookies.map((c) => ({
|
|
|
|
|
|
name: c.name,
|
|
|
|
|
|
value: c.value,
|
|
|
|
|
|
domain: c.domain,
|
|
|
|
|
|
path: c.path,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
if (antiBot.sessionKey) {
|
|
|
|
|
|
let lastHost = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
lastHost = new URL(finalPageUrl).hostname;
|
|
|
|
|
|
} catch (_) { /* ignore */ }
|
|
|
|
|
|
saveSession(antiBot.sessionKey, cookiePayload, SESSION_TTL_MS, {
|
|
|
|
|
|
lastHost,
|
|
|
|
|
|
savedProfile: profile,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let extraPages = [];
|
|
|
|
|
|
let paginationDebug = [];
|
|
|
|
|
|
if (clicksToPerform > 0 && apiUrl && nextBtnSelector) {
|
|
|
|
|
|
let effectiveFirstPageData = firstPageData;
|
|
|
|
|
|
if (!effectiveFirstPageData) {
|
|
|
|
|
|
const matchedKey = Object.keys(interceptor.interceptedApis).find((k) => k === apiUrl || k.includes(apiUrl));
|
|
|
|
|
|
if (matchedKey) {
|
|
|
|
|
|
effectiveFirstPageData = interceptor.interceptedApis[matchedKey].data;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const paginationResult = await runUiPagination(page, {
|
|
|
|
|
|
apiUrl,
|
|
|
|
|
|
nextBtnSelector,
|
|
|
|
|
|
clicksToPerform,
|
|
|
|
|
|
waitMs,
|
|
|
|
|
|
firstPageData: effectiveFirstPageData,
|
|
|
|
|
|
authActions,
|
|
|
|
|
|
executeAuthActions,
|
|
|
|
|
|
});
|
|
|
|
|
|
extraPages = paginationResult.data;
|
|
|
|
|
|
paginationDebug = paginationResult.debugLogs;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
2026-06-20 15:49:40 +08:00
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
pageUrl,
|
|
|
|
|
|
finalPageUrl,
|
|
|
|
|
|
interceptedApis: interceptor.interceptedApis,
|
|
|
|
|
|
cookies: cookiePayload,
|
|
|
|
|
|
extraPages,
|
|
|
|
|
|
debug: paginationDebug,
|
|
|
|
|
|
cf: cfLog,
|
|
|
|
|
|
profile,
|
|
|
|
|
|
browserReused: browserSession.reused,
|
|
|
|
|
|
elapsedMs: Date.now() - taskStarted,
|
|
|
|
|
|
});
|
2026-06-20 04:47:34 +08:00
|
|
|
|
} finally {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
if (interceptorCleanup) interceptorCleanup();
|
2026-07-03 20:12:20 +08:00
|
|
|
|
await closeTaskPage(page);
|
2026-06-20 15:49:40 +08:00
|
|
|
|
if (browserSession) {
|
|
|
|
|
|
await browserSession.release(cfDestroyBrowser);
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, profile);
|
|
|
|
|
|
} catch (error) {
|
2026-06-20 15:49:40 +08:00
|
|
|
|
if (!res.headersSent) sendTaskError(res, error, { cf: cfLog, profile });
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const PORT = parseInt(process.env.PORT || '3001', 10);
|
|
|
|
|
|
const server = app.listen(PORT, '127.0.0.1', () => {
|
|
|
|
|
|
const chromePath = resolveChromeExecutable();
|
|
|
|
|
|
const chromeReady = fs.existsSync(chromePath);
|
|
|
|
|
|
console.log(`🚀 Node.js 自愈型防脱轨引擎已启动 (standard: ${MAX_CONCURRENT_BROWSERS}, real: ${MAX_CONCURRENT_BROWSERS_REAL})`);
|
|
|
|
|
|
console.log(`[chrome] path=${chromePath} exists=${chromeReady}`);
|
|
|
|
|
|
console.log(`[real-browser] ready=${isRealBrowserAvailable()} captcha=${isCaptchaConfigured()}`);
|
|
|
|
|
|
if (!chromeReady) {
|
|
|
|
|
|
console.warn('[chrome] 未找到 Chrome,请在 puppeteer-api 目录执行: npx puppeteer browsers install chrome');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
async function gracefulShutdown(signal) {
|
|
|
|
|
|
console.log(`[shutdown] 收到 ${signal},停止接收新任务...`);
|
|
|
|
|
|
shuttingDown = true;
|
|
|
|
|
|
server.close();
|
|
|
|
|
|
|
|
|
|
|
|
const deadline = Date.now() + 60000;
|
|
|
|
|
|
while (standardLimiter.active > 0 && Date.now() < deadline) {
|
|
|
|
|
|
const { active, queued } = standardLimiter.getStats();
|
|
|
|
|
|
console.log(`[shutdown] 等待在途任务完成... active=${active}, queued=${queued}`);
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 15:49:40 +08:00
|
|
|
|
console.log('[shutdown] 排空 Browser 温池...');
|
|
|
|
|
|
await browserPool.drain();
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
const remaining = standardLimiter.getStats();
|
|
|
|
|
|
if (remaining.active > 0) {
|
|
|
|
|
|
console.warn(`[shutdown] 超时,仍有 ${remaining.active} 个 Browser 在运行,强制退出`);
|
|
|
|
|
|
process.exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log('[shutdown] 所有任务已完成,进程退出');
|
|
|
|
|
|
process.exit(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
|
|
|
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|