Files
links/patches/puppeteer-api/server.js
T
2026-07-05 22:50:41 +08:00

2551 lines
99 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
process.on('uncaughtException', (err) => {
console.error('[FATAL] uncaughtException:', err);
});
process.on('unhandledRejection', (reason) => {
console.error('[FATAL] unhandledRejection:', reason);
});
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,
NAVIGATION_TIMEOUT_MS,
NAVIGATION_WAIT_UNTIL,
NAVIGATION_MAX_RETRIES,
PAGE_DEFAULT_TIMEOUT_MS,
SESSION_TTL_MS,
PERSIST_BROWSER_PROFILE,
POOL_IDLE_MS,
MAX_POOLED_BROWSERS,
} = require('./lib/constants');
const {
normalizeAntiBot,
createBrowserSession,
acquireBrowserSession,
runWithProfileLimit,
createManagedPage,
getFactoryStats,
standardLimiter,
browserPool,
} = require('./lib/browser-factory');
const { launchStandardBrowser } = require('./lib/launch-standard');
const { isRealBrowserAvailable } = require('./lib/launch-real-browser');
const { handleCloudflareChallenge, isCaptchaConfigured } = require('./lib/cf-handler');
const { detectCloudflare, urlIndicatesCfChallenge } = require('./lib/cf-detector');
const { runCaptchaApiFallback, shouldPreferManagedCloudflareSolver } = require('./lib/cf-api-fallback');
const {
getCaptchaProxyChromeArgs,
applyCaptchaProxyToPage,
parseCaptchaProxy,
hasCaptchaProxy,
} = require('./lib/captcha-proxy');
const { attachSitekeyCapture } = require('./lib/sitekey-capture');
const {
saveSession,
getSessionDebugInfo,
getSessionStats,
isSessionLikelyValid,
loadSession,
} = require('./lib/session-store');
const { resolveSessionContext, touchSessionIfPresent } = require('./lib/session-context');
const { getProfileStats, profileExists } = require('./lib/profile-dir');
const { getProfileStats, profileExists } = require('./lib/profile-dir');
const {
isHuojianLongLinkUrl,
isHuojianShortEntryUrl,
applyHuojianBusinessHostHint,
} = require('./lib/huojian-url');
const { runUiPagination } = require('./lib/ui-pagination-runner');
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);
}
/** PHP antiBot.purgeSession:清除磁盘 session 文件(不修改 session-context 模块) */
function purgeStoredSessionIfRequested(antiBot) {
const sessionKey = antiBot?.sessionKey || '';
if (!sessionKey || antiBot?.purgeSession !== true) {
return false;
}
const crypto = require('crypto');
const path = require('path');
const hash = crypto.createHash('md5').update(sessionKey).digest('hex');
const filePath = path.join(RUNTIME_DIR, 'sessions', `${hash}.json`);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
console.log(`[Session] purgeSession 已清除 sessionKey=${sessionKey}`);
return true;
}
} catch (_) { /* ignore */ }
return false;
}
function sendTaskError(res, error, extra = {}) {
if (error.code === 'QUEUE_TIMEOUT' || error.message === 'POOL_ACQUIRE_TIMEOUT') {
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);
const payload = { success: false, error: error.message, ...extra };
if (error.pageDiagnostics) {
payload.page = error.pageDiagnostics;
}
return res.status(500).json(payload);
}
async function navigateToPage(page, url) {
await withExponentialBackoff(
() => page.goto(url, { waitUntil: NAVIGATION_WAIT_UNTIL, timeout: NAVIGATION_TIMEOUT_MS }),
{ maxRetries: NAVIGATION_MAX_RETRIES, baseDelayMs: 1000 }
);
}
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';
if (msg.includes('navigation') || msg.includes('frame was detached')
|| msg.includes('detached frame') || msg.includes('attempted to use detached')) return 'navigation';
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;
}
/** 合并 antiBot 扩展字段(postCfReady* 由 PHP 按工单类型传入,未配置则跳过) */
function resolveAntiBot(rawAntiBot) {
const base = normalizeAntiBot(rawAntiBot);
if (!rawAntiBot || !base.enabled) {
return base;
}
return {
...base,
postCfReadyUrlContains: rawAntiBot.postCfReadyUrlContains || '',
postCfReadySelector: rawAntiBot.postCfReadySelector || '',
postCfReadyApiUrl: rawAntiBot.postCfReadyApiUrl || '',
postCfReadyTimeoutMs: rawAntiBot.postCfReadyTimeoutMs || 0,
postCfSettleMs: rawAntiBot.postCfSettleMs || 0,
postCfSpaWaitMs: rawAntiBot.postCfSpaWaitMs || 0,
cfClearanceRequired: rawAntiBot.cfClearanceRequired !== false,
businessHostHint: rawAntiBot.businessHostHint || '',
landingUrlHint: rawAntiBot.landingUrlHint || '',
turnstileSitekey: rawAntiBot.turnstileSitekey || '',
cfCloudflareSolver: rawAntiBot.cfCloudflareSolver !== false,
cfChallengeMode: rawAntiBot.cfChallengeMode || '',
};
}
/**
* 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 || '');
const isWhatshubHost = parsed.hostname.includes('whatshub.cc');
const isShortLink = parsed.pathname.includes('/m/');
const isLongLink = pageUrl.includes('work-order-sharing');
if (!isWhatshubHost || (!isShortLink && !isLongLink)) {
return antiBot;
}
console.log('[CF] Whatshub 自动启用 postCfReady 默认配置');
return {
...antiBot,
postCfReadyUrlContains: 'work-order-sharing',
postCfReadySelector: '.vxe-table, .vxe-grid',
postCfReadySelectorOptional: true,
postCfReadyTimeoutMs: antiBot.postCfReadyTimeoutMs || 60000,
postCfSettleMs: antiBot.postCfSettleMs || 500,
postCfSpaWaitMs: antiBot.postCfSpaWaitMs || 4000,
skipShortLinkCfPurgeOnReuse: antiBot.skipShortLinkCfPurgeOnReuse !== false,
};
} catch (_) {
return antiBot;
}
}
/**
* A2C 短链 → user.a2c.chatPHP 未传 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',
postCfReadySelector: '.el-table',
postCfReadyApiUrl: antiBot.postCfReadyApiUrl || '/api/talk/counter/share/record/list',
postCfReadyTimeoutMs: antiBot.postCfReadyTimeoutMs || 60000,
postCfSettleMs: antiBot.postCfSettleMs || 500,
postCfSpaWaitMs: antiBot.postCfSpaWaitMs || 8000,
cfClearanceRequired: antiBot.cfClearanceRequired === undefined ? false : antiBot.cfClearanceRequired,
solverFallback: antiBot.solverFallback === undefined ? true : antiBot.solverFallback,
cfCloudflareSolver: antiBot.cfCloudflareSolver !== false,
cfChallengeMode: antiBot.cfChallengeMode || 'managed',
};
}
/**
* 火箭短链 → 动态长链域 /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 */
function resolveAntiBotForPage(rawAntiBot, pageUrl) {
return applyHuojianPostCfDefaults(
applyA2cPostCfDefaults(
applyWhatshubPostCfDefaults(resolveAntiBot(rawAntiBot), pageUrl),
pageUrl
),
pageUrl
);
}
function isBusinessUrlReady(page, urlNeedle) {
return !urlNeedle || page.url().includes(urlNeedle);
}
/** 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 });
}
/** 可选 selector:URL 已到业务页时允许降级继续(Whatshub session 复用) */
async function waitForPostCfSelectorOptional(page, selector, timeoutMs, antiBot) {
const optional = antiBot?.postCfReadySelectorOptional === true;
if (!optional) {
await waitForPostCfSelector(page, selector, timeoutMs);
return;
}
const shortTimeout = Math.min(8000, timeoutMs);
try {
await waitForPostCfSelector(page, selector, shortTimeout);
} catch (err) {
const urlContains = (antiBot.postCfReadyUrlContains || '').trim();
if (urlContains && page.url().includes(urlContains)) {
console.log(`[CF] 可选 selector "${selector}" 未就绪但 URL 已到业务页,继续`);
return;
}
throw err;
}
}
/** 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 域名判定 */
function isWhatshubHost(url) {
try {
return new URL(url || '').hostname.includes('whatshub.cc');
} catch (_) {
return false;
}
}
/** Whatshub 密码 UI 是否可见(vxe-form / vxe-modal + Element dialog */
async function isWhatshubPasswordDialogVisible(page) {
return page.evaluate(() => {
const isVisible = (el) => {
if (!el) return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
};
const hasVisiblePasswordInput = (root) => {
const pwd = root.querySelector('.vxe-form .el-input__inner, .el-input__inner[placeholder*="密码"]');
return pwd && isVisible(pwd);
};
const roots = document.querySelectorAll(
'form.vxe-form, .vxe-modal--wrapper, .vxe-modal, '
+ '.el-message-box, .el-overlay-message-box, .el-dialog, .el-overlay-dialog'
);
for (const root of roots) {
if (!isVisible(root)) continue;
if (hasVisiblePasswordInput(root)) return true;
}
const standaloneForm = document.querySelector('form.vxe-form');
if (standaloneForm && isVisible(standaloneForm) && hasVisiblePasswordInput(standaloneForm)) {
return true;
}
return false;
}).catch(() => false);
}
/** statistics-only 等场景:导航后立即挂 API 拦截器,避免 session 复用时响应早于监听 */
function shouldUseEarlyApiIntercept(antiBot, apiUrls) {
if (antiBot?.earlyApiIntercept === true) {
return true;
}
if (!Array.isArray(apiUrls) || apiUrls.length === 0) {
return false;
}
return apiUrls.every((u) => String(u).includes('/open/statistics'));
}
/** statistics-only 拦截时不应在密码前等待业务 API */
function shouldSkipPostCfReadyApiWait(antiBot, apiUrls = null) {
if (antiBot?.skipPostCfReadyApi) {
return true;
}
if (Array.isArray(apiUrls) && apiUrls.length > 0) {
return apiUrls.every((u) => String(u).includes('/open/statistics'));
}
return false;
}
/** Whatshub 使用 PHP 配置的 postCfSpaWaitMsA2C 等仍保底 8s */
function resolveSpaWaitMs(antiBot, onA2cDirect, pageUrl = '') {
const configured = antiBot?.postCfSpaWaitMs || 0;
if (onA2cDirect) {
return Math.max(8000, configured || 8000);
}
if (isWhatshubShortLinkPath(pageUrl) || isWhatshubHost(pageUrl)) {
return Math.max(2000, configured || 4000);
}
return Math.max(8000, configured || 8000);
}
/** authActions 选择器超时(Whatshub session 复用时可放宽) */
function resolveAuthSelectorTimeoutMs(antiBot) {
const ms = antiBot?.authSelectorTimeoutMs;
return typeof ms === 'number' && ms > 0 ? ms : 15000;
}
/**
* 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;
}
/**
* 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
*/
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 || '',
});
tagBrowserSession(browserSession, profile, antiBot.sessionKey || '', extraArgs);
browserSession.captchaProxyActive = true;
const proxySessionRef = { current: browserSession };
page = await createTaskPage(
browserSession.browser,
browserSession.reused ? null : browserSession.page,
navigationUrl,
antiBot,
mergedCookies.length > 0 ? mergedCookies : snapshotCookies,
{
...pageExtraOpts,
useCaptchaProxy: true,
browserSession,
browserSessionRef: proxySessionRef,
}
);
browserSession = proxySessionRef.current;
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;
}
/**
* 为温池复用 / 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 */
async function closeTaskPage(page) {
if (!page) {
return;
}
try {
if (typeof page.isClosed === 'function' && page.isClosed()) {
return;
}
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();
} catch (_) {
/* ignore */
}
}
/**
* 配置任务页(UA / Cookie / 事件监听)
* @param {import('puppeteer').Page} page
* @param {import('puppeteer').Browser} browser
* @param {object} opts
* @param {object} extraOpts
* @param {object|null|undefined} antiBot
*/
async function configureTaskPage(page, browser, opts, extraOpts, antiBot) {
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);
}
if (extraOpts.useCaptchaProxy && hasCaptchaProxy()) {
await applyCaptchaProxyToPage(page);
}
page.on('error', (err) => console.error('[Page Error]', err.message));
page.on('pageerror', (err) => console.error('[Page JS Error]', err.message));
if (antiBot?.enabled) {
attachSitekeyCapture(page, browser);
}
}
/**
* 任务页创建(含 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;
}
}
if (!page) {
throw new Error('createTaskPage: 未能打开任务 Tab');
}
await configureTaskPage(page, currentBrowser, opts, extraOpts, antiBot);
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;
}
}
if (urlNeedle && urlNeedle.includes('/gds')) {
return !url.includes('/gds');
}
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();
const onA2cDirect = isA2cDirectSharePage(page, urlNeedle);
if (!urlNeedle || (!isOnShortLinkNotBusiness(page, urlNeedle) && !onA2cDirect)) {
return isBusinessUrlReady(page, urlNeedle);
}
if (!(await isCfTrulyComplete(page, antiBot))) {
return false;
}
const spaWaitMs = resolveSpaWaitMs(antiBot, onA2cDirect, targetUrl);
const isWhatshub = isWhatshubShortLinkPath(targetUrl) || isWhatshubHost(targetUrl);
console.log(
`[CF] CF 已完成,等待 SPA ${onA2cDirect ? '直链挂载' : '跳转'} settle=${spaWaitMs}ms current=${page.url()}`
);
await new Promise((resolve) => setTimeout(resolve, spaWaitMs));
if (typeof waitForUrlSettledFn === 'function') {
await waitForUrlSettledFn(page).catch(() => {});
}
await page.waitForNetworkIdle({ idleTime: 500, timeout: 20000 }).catch(() => {});
if (onA2cDirect) {
await waitForPostCfListApi(page, antiBot, Math.max(15000, antiBot.postCfReadyTimeoutMs || 60000)).catch(() => {});
}
if (isBusinessUrlReady(page, urlNeedle)) {
console.log(`[CF] SPA 已跳转至业务页 url=${page.url()}`);
return true;
}
if (isWhatshub && isWhatshubShortLinkPath(targetUrl)) {
console.log(`[CF] Whatshub 二次 goto 触发 SPA 路由(保留 clearanceurl=${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 场景:软 reloadWhatshub 短链改用二次 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(保留 clearanceurl=${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_clearanceA2C 等 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;
}
/** 轮询直到 CF 真正完成;并行扫描 sitekey 供 CapSolver 提前使用 */
async function waitForCfTrulyComplete(page, timeoutMs, pollMs = 500, antiBot = null) {
const { getSitekeyCapture } = require('./lib/sitekey-capture');
const capture = getSitekeyCapture(page);
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (await isCfTrulyComplete(page, antiBot)) {
return { success: true, elapsedMs: Date.now() - started };
}
if (capture) {
await capture.scanNow(page, antiBot).catch(() => {});
}
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},继续过盾`);
}
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,
};
}
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(() => {});
}
await runCaptchaApiFallback(page, {
antiBot,
navUrl,
waitForUrlSettled,
timeoutMs,
});
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 生命周期
* session 复用时跳过 purge,保留 clearance 加速二次同步
*/
async function prepareWhatshubShortLinkBeforeNav(page, pageUrl, antiBot, storedSession = null) {
if (!antiBot?.enabled || antiBot.profile !== 'real' || !isWhatshubShortLinkPath(pageUrl)) {
return;
}
if (antiBot.skipShortLinkCfPurgeOnReuse) {
let session = storedSession;
if (!session && antiBot.sessionKey) {
session = loadSession(antiBot.sessionKey);
}
if (session && isSessionLikelyValid(session)) {
console.log('[Whatshub] 有效 session 复用,跳过短链 cf_clearance 清除');
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
*/
async function runCloudflareWithBusinessGuard(page, antiBot, waitForUrlSettled, storedSession, pageUrl, browserCtx = null) {
const urlNeedle = (antiBot.postCfReadyUrlContains || '').trim();
const hasStoredSession = !!(storedSession && isSessionLikelyValid(storedSession));
async function maybeEnsureProxy(currentPage) {
if (!browserCtx) {
return currentPage;
}
browserCtx.page = currentPage;
await ensureCaptchaProxyBrowserIfNeeded(browserCtx);
return browserCtx.page;
}
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(() => {});
}
page = await maybeEnsureProxy(page);
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) {
page = await maybeEnsureProxy(page);
cfResult = await handleCloudflareChallengeSafe(page, antiBot, waitForUrlSettled, storedSession);
}
// 短链跨域落地(如 yyk.ink → user.a2c.chat、Whatshub /m/ → work-order-sharing)后,业务域可能仍有 CF
if (cfResult.success && !(await isCfTrulyComplete(page, antiBot))) {
const crossState = await detectCloudflare(page, antiBot);
if (crossState.blocked || urlIndicatesCfChallenge(page.url())) {
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()}`);
}
page = await maybeEnsureProxy(page);
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}`);
page = await maybeEnsureProxy(page);
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;
}
}
if (browserCtx) {
browserCtx.page = page;
}
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,
vxeForm: document.querySelectorAll('form.vxe-form').length,
vxePasswordInput: document.querySelectorAll('.vxe-form .el-input__inner').length,
vxeConfirmBtn: document.querySelectorAll('.vxe-form button.theme--primary').length,
elTable: document.querySelectorAll('.el-table').length,
vxeTable: document.querySelectorAll('.vxe-table, .vxe-grid').length,
btnNext: document.querySelectorAll('.btn-next').length,
elPagination: document.querySelectorAll('.el-pagination').length,
})).catch(() => null);
return snapshot || { url, title: '', bodyText: '', elInputInner: 0 };
}
async function awaitPostCfBusinessReady(page, antiBot, waitForUrlSettledFn, storedSession = null, pageUrl = '', apiUrls = null) {
if (!antiBot || !antiBot.enabled) {
return;
}
const urlContains = (antiBot.postCfReadyUrlContains || '').trim();
const selector = (antiBot.postCfReadySelector || '').trim();
if (urlContains === '' && selector === '') {
return;
}
const navUrl = pageUrl || page.url();
const skipApiWait = shouldSkipPostCfReadyApiWait(antiBot, apiUrls);
await ensureCfCompleteBeforeBusinessReady(page, antiBot, waitForUrlSettledFn, storedSession);
const onA2cDirect = isA2cDirectSharePage(page, urlContains);
const whatshubHost = isWhatshubHost(navUrl) || isWhatshubHost(page.url());
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;
}
}
// Whatshub session 复用:已在 work-order-sharing 且无密码弹窗时跳过 SPA 固定等待
if (!skipSpaWait && whatshubHost && urlContains && page.url().includes(urlContains)) {
if (await isCfTrulyComplete(page, antiBot)) {
const pwdVisible = await isWhatshubPasswordDialogVisible(page);
const tableReady = await page.$('.vxe-table, .vxe-grid').catch(() => null);
if (!pwdVisible && (tableReady || (storedSession && isSessionLikelyValid(storedSession)))) {
console.log('[CF] Whatshub 业务页已就绪(session 复用),跳过 SPA 等待');
skipSpaWait = true;
}
}
}
if (!skipSpaWait) {
await waitForSpaBusinessNavigation(page, antiBot, waitForUrlSettledFn, navUrl);
}
const timeoutMs = Math.max(5000, antiBot.postCfReadyTimeoutMs || 30000);
console.log(
`[CF] 等待业务页就绪 current=${page.url()} urlContains="${urlContains}" `
+ `selector="${selector}" timeout=${timeoutMs}ms skipApiWait=${skipApiWait}`
);
try {
if (urlContains !== '') {
await page.waitForFunction(
(needle) => window.location.href.includes(needle),
{ timeout: timeoutMs, polling: 200 },
urlContains
);
}
if (!skipApiWait) {
await waitForPostCfListApi(page, antiBot, timeoutMs);
} else {
console.log('[CF] 跳过 pre-auth 业务 API 等待(statistics 在密码验证后触发)');
}
if (selector !== '') {
await waitForPostCfSelectorOptional(page, selector, timeoutMs, antiBot);
}
} catch (err) {
const retryMs = Math.min(30000, timeoutMs);
const needsRetry = urlContains !== '' && !page.url().includes(urlContains);
const needsDomRetry = urlContains !== '' && page.url().includes(urlContains) && isA2cDirectSharePage(page, urlContains);
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 !== '') {
await waitForPostCfSelectorOptional(page, selector, retryMs, antiBot);
}
} 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
);
}
if (!skipApiWait) {
await waitForPostCfListApi(page, antiBot, retryMs);
}
if (selector !== '') {
await waitForPostCfSelectorOptional(page, selector, retryMs, antiBot);
}
} catch (retryErr) {
retryErr.pageDiagnostics = await collectPageDiagnostics(page);
throw retryErr;
}
}
} 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);
if (!skipApiWait) {
await waitForPostCfListApi(page, antiBot, retryMs);
}
if (selector !== '') {
await waitForPostCfSelectorOptional(page, selector, retryMs, antiBot);
}
} catch (retryErr) {
retryErr.pageDiagnostics = await collectPageDiagnostics(page);
throw retryErr;
}
} 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, antiBot = null) {
try {
await executeAuthActions(page, authActions, antiBot);
} catch (err) {
err.pageDiagnostics = await collectPageDiagnostics(page);
throw err;
}
}
async function shouldSkipWhatshubAuthActions(page, antiBot) {
if (!antiBot?.authSkipIfNoPasswordDialog) {
return false;
}
const urlContains = (antiBot.postCfReadyUrlContains || 'work-order-sharing').trim();
try {
const onBusiness = !urlContains || page.url().includes(urlContains);
if (!onBusiness) {
return false;
}
const pwdVisible = await isWhatshubPasswordDialogVisible(page);
if (!pwdVisible) {
return true;
}
} catch (_) {
/* ignore */
}
return false;
}
async function waitForWhatshubPasswordFormIfNeeded(page, antiBot) {
if (!antiBot || !(isWhatshubHost(page.url()) || page.url().includes('work-order-sharing'))) {
return;
}
if (await shouldSkipWhatshubAuthActions(page, antiBot)) {
return;
}
const timeout = resolveAuthSelectorTimeoutMs(antiBot);
console.log('[Whatshub] 等待 vxe 密码表单出现...');
await page.waitForSelector('.vxe-form .el-input__inner', { visible: true, timeout }).catch(() => null);
}
async function executeAuthActions(page, authActions, antiBot = null) {
if (!Array.isArray(authActions)) return;
if (await shouldSkipWhatshubAuthActions(page, antiBot)) {
console.log('[Whatshub] Session 已登录且无密码弹窗,跳过 authActions');
return;
}
await waitForWhatshubPasswordFormIfNeeded(page, antiBot);
const selectorTimeout = resolveAuthSelectorTimeoutMs(antiBot);
for (const action of authActions) {
if (action.type === 'type') {
await page.waitForSelector(action.selector, { timeout: selectorTimeout });
await page.type(action.selector, action.value, { delay: 100 });
} else if (action.type === 'click') {
await page.waitForSelector(action.selector, { timeout: selectorTimeout });
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: selectorTimeout });
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: selectorTimeout });
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,
pool: factoryStats.pool,
sessions: getSessionStats(),
profiles: getProfileStats(),
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,
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'),
},
profilesDir: profileStandard.profilesDir,
});
});
/** 运维自检:实际尝试 launch 一次 Browserprofile=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: '参数错误' });
}
const antiBot = resolveAntiBotForPage(rawAntiBot, pageUrl);
const profile = antiBot.profile || 'standard';
const interceptTimeout = resolveInterceptTimeout(antiBot);
const cfLog = { profile, cfDetected: false, turnstileStage: null, solverUsed: false, elapsedMs: 0, browserReused: false };
try {
await runBrowserTask('auth-and-intercept', async () => {
let browserSession = null;
let interceptorCleanup = null;
let page = null;
const taskStarted = Date.now();
let cfDestroyBrowser = false;
try {
browserSession = await acquireBrowserSession({
profile,
extraArgs: ['--mute-audio'],
sessionKey: antiBot.sessionKey || '',
});
tagBrowserSession(browserSession, profile, antiBot.sessionKey || '', ['--mute-audio']);
cfLog.browserReused = !!browserSession.reused;
purgeStoredSessionIfRequested(antiBot);
const { storedSession, mergedCookies } = resolveSessionContext(antiBot, pageUrl);
const navigationUrl = resolveNavigationUrl(pageUrl, antiBot);
const authSessionRef = { current: browserSession };
page = await createTaskPage(
browserSession.browser,
browserSession.reused ? null : browserSession.page,
navigationUrl,
antiBot,
mergedCookies,
{ browserSession, browserSessionRef: authSessionRef }
);
browserSession = authSessionRef.current;
const httpResolvedUrl = await resolveHttpRedirects(pageUrl).catch(() => pageUrl);
if (httpResolvedUrl !== pageUrl) {
console.log(`[URL解析] HTTP 重定向: ${pageUrl} -> ${httpResolvedUrl}`);
}
let shareToken = resolveShareToken(pageUrl, httpResolvedUrl);
await seedShareTokenCookie(page, navigationUrl);
if (httpResolvedUrl !== pageUrl) {
await seedShareTokenCookie(page, httpResolvedUrl);
}
await prepareWhatshubShortLinkBeforeNav(page, navigationUrl, antiBot, storedSession);
let interceptor = null;
const useEarlyIntercept = shouldUseEarlyApiIntercept(antiBot, apiUrls);
if (useEarlyIntercept) {
interceptor = createApiInterceptor(page, apiUrls, interceptTimeout);
interceptorCleanup = interceptor.cleanup;
console.log('[Whatshub] statistics 早挂 API 拦截器(session 复用防漏包)');
}
await navigateToPage(page, navigationUrl);
let finalPageUrl = await waitForUrlSettled(page);
applyHuojianBusinessHostHint(antiBot, finalPageUrl);
if (finalPageUrl !== pageUrl) {
console.log(`[URL解析] 浏览器落地: ${pageUrl} -> ${finalPageUrl}`);
}
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;
if (antiBot.enabled) {
const cfResult = await runCloudflareWithBusinessGuard(
page, antiBot, waitForUrlSettled, storedSession, pageUrl, browserCtx
);
page = browserCtx.page;
browserSession = browserCtx.browserSession;
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;
}
finalPageUrl = page.url();
applyHuojianBusinessHostHint(antiBot, finalPageUrl);
touchSessionIfPresent(antiBot.sessionKey);
await awaitPostCfBusinessReady(page, antiBot, waitForUrlSettled, storedSession, pageUrl, apiUrls);
}
const finalShareToken = resolveShareToken(finalPageUrl);
if (finalShareToken && finalShareToken !== shareToken) {
shareToken = finalShareToken;
await seedShareTokenCookie(page, finalPageUrl);
}
if (!interceptor) {
interceptor = createApiInterceptor(page, apiUrls, interceptTimeout);
interceptorCleanup = interceptor.cleanup;
}
await attachHttpIpRewriteInterceptor(page, 'auth-and-intercept', { shareToken });
await executeAuthActionsWithDiagnostics(page, authActions, antiBot);
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,
});
}
res.json({
success: true,
pageUrl,
finalPageUrl,
interceptedApis: interceptor.interceptedApis,
cookies: cookiePayload,
cf: cfLog,
profile,
browserReused: browserSession.reused,
elapsedMs: Date.now() - taskStarted,
});
} finally {
if (interceptorCleanup) interceptorCleanup();
await closeTaskPage(page);
if (browserSession) {
await browserSession.release(cfDestroyBrowser);
}
}
}, 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 () => {
let browserSession = null;
try {
browserSession = await acquireBrowserSession({
profile,
extraArgs: ['--disable-web-security'],
sessionKey: antiBot.sessionKey || '',
});
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;
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();
}
});
await navigateToPage(page, firstOrigin);
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);
res.json({ success: true, results: fetchResults, profile, browserReused: browserSession.reused });
} finally {
if (browserSession) {
await browserSession.release(false);
}
}
}, 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;
const antiBot = resolveAntiBotForPage(rawAntiBot, pageUrl || navigationUrl);
const profile = antiBot.profile || 'standard';
try {
await runBrowserTask('ui-pagination', async () => {
let browserSession = null;
const debugLogs = [];
let page = null;
let cfDestroyBrowser = false;
try {
browserSession = await acquireBrowserSession({
profile,
extraArgs: ['--window-size=1920,1080'],
sessionKey: antiBot.sessionKey || '',
});
tagBrowserSession(browserSession, profile, antiBot.sessionKey || '', ['--window-size=1920,1080']);
const { storedSession, mergedCookies } = resolveSessionContext(antiBot, navigationUrl, cookies || []);
const uiSessionRef = { current: browserSession };
page = await createTaskPage(
browserSession.browser,
browserSession.reused ? null : browserSession.page,
navigationUrl,
antiBot,
mergedCookies,
{
viewport: { width: 1920, height: 1080 },
browserSession,
browserSessionRef: uiSessionRef,
}
);
browserSession = uiSessionRef.current;
const log = (msg) => { console.log(msg); debugLogs.push(msg); };
log(`[启动] 准备访问页面: ${navigationUrl}`);
if (finalPageUrl && finalPageUrl !== pageUrl) {
log(`[URL解析] 使用 auth 阶段落地地址,跳过短链重定向: ${pageUrl} -> ${finalPageUrl}`);
}
const shareToken = resolveShareToken(finalPageUrl, pageUrl);
await seedShareTokenCookie(page, navigationUrl);
await prepareWhatshubShortLinkBeforeNav(page, navigationUrl, antiBot, storedSession);
await navigateToPage(page, navigationUrl);
const settledPageUrl = await waitForUrlSettled(page);
if (settledPageUrl !== navigationUrl) {
log(`[URL解析] 翻页阶段落地: ${navigationUrl} -> ${settledPageUrl}`);
}
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;
if (antiBot.enabled) {
const cfResult = await runCloudflareWithBusinessGuard(
page, antiBot, waitForUrlSettled, storedSession, navigationUrl, browserCtx
);
page = browserCtx.page;
browserSession = browserCtx.browserSession;
if (!cfResult.success) {
cfDestroyBrowser = true;
res.status(422).json({
success: false,
code: cfResult.code || 'CF_TURNSTILE_FAILED',
challengeType: cfResult.challengeType,
stage: cfResult.stage,
error: 'Cloudflare Turnstile 验证失败(翻页阶段)',
debug: debugLogs,
});
return;
}
touchSessionIfPresent(antiBot.sessionKey);
await awaitPostCfBusinessReady(page, antiBot, waitForUrlSettled, storedSession, navigationUrl, apiUrl ? [apiUrl] : null);
await attachHttpIpRewriteInterceptor(page, 'ui-pagination', { shareToken });
} else {
await attachHttpIpRewriteInterceptor(page, 'ui-pagination', { shareToken });
}
const paginationResult = await runUiPagination(page, {
apiUrl,
nextBtnSelector,
clicksToPerform,
waitMs,
firstPageData,
authActions,
executeAuthActions: (p, actions) => executeAuthActions(p, actions, antiBot),
});
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 {
await closeTaskPage(page);
if (browserSession) {
await browserSession.release(cfDestroyBrowser);
}
}
}, profile);
} catch (error) {
if (!res.headersSent) sendTaskError(res, error, { profile });
}
});
/** 接口四:单 Browser 内 auth + 拦截 + UI 翻页(降低 CF 二次触发) */
app.post('/api/auth-intercept-and-paginate', async (req, res) => {
const {
pageUrl,
apiUrls,
authActions,
antiBot: rawAntiBot,
pagination,
} = req.body;
if (!pageUrl || !Array.isArray(apiUrls) || !pagination) {
return res.status(400).json({ success: false, error: '参数错误:需要 pageUrl、apiUrls、pagination' });
}
const {
apiUrl,
nextBtnSelector,
clicksToPerform = 0,
waitMs = 2000,
firstPageData,
} = pagination;
const antiBot = resolveAntiBotForPage(rawAntiBot, pageUrl);
const profile = antiBot.profile || 'standard';
const interceptTimeout = resolveInterceptTimeout(antiBot);
const cfLog = { profile, cfDetected: false, turnstileStage: null, solverUsed: false, elapsedMs: 0, browserReused: false };
try {
await runBrowserTask('auth-intercept-and-paginate', async () => {
let browserSession = null;
let interceptorCleanup = null;
let page = null;
const taskStarted = Date.now();
let cfDestroyBrowser = false;
try {
browserSession = await acquireBrowserSession({
profile,
extraArgs: ['--mute-audio', '--window-size=1920,1080'],
sessionKey: antiBot.sessionKey || '',
});
tagBrowserSession(
browserSession,
profile,
antiBot.sessionKey || '',
['--mute-audio', '--window-size=1920,1080']
);
cfLog.browserReused = !!browserSession.reused;
const { storedSession, mergedCookies } = resolveSessionContext(antiBot, pageUrl);
const navigationUrl = resolveNavigationUrl(pageUrl, antiBot);
const mergedSessionRef = { current: browserSession };
page = await createTaskPage(
browserSession.browser,
browserSession.reused ? null : browserSession.page,
navigationUrl,
antiBot,
mergedCookies,
{
viewport: { width: 1920, height: 1080 },
browserSession,
browserSessionRef: mergedSessionRef,
}
);
browserSession = mergedSessionRef.current;
const httpResolvedUrl = await resolveHttpRedirects(pageUrl).catch(() => pageUrl);
let shareToken = resolveShareToken(pageUrl, httpResolvedUrl);
await seedShareTokenCookie(page, navigationUrl);
await prepareWhatshubShortLinkBeforeNav(page, navigationUrl, antiBot, storedSession);
await navigateToPage(page, navigationUrl);
let finalPageUrl = await waitForUrlSettled(page);
applyHuojianBusinessHostHint(antiBot, finalPageUrl);
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;
let interceptor;
if (antiBot.enabled) {
const cfResult = await runCloudflareWithBusinessGuard(
page, antiBot, waitForUrlSettled, storedSession, navigationUrl, browserCtx
);
page = browserCtx.page;
browserSession = browserCtx.browserSession;
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;
}
finalPageUrl = page.url();
applyHuojianBusinessHostHint(antiBot, finalPageUrl);
touchSessionIfPresent(antiBot.sessionKey);
await awaitPostCfBusinessReady(page, antiBot, waitForUrlSettled, storedSession, navigationUrl, apiUrls);
}
interceptor = createApiInterceptor(page, apiUrls, interceptTimeout);
interceptorCleanup = interceptor.cleanup;
await attachHttpIpRewriteInterceptor(page, 'auth-intercept-and-paginate', { shareToken });
await executeAuthActionsWithDiagnostics(page, authActions, antiBot);
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: (p, actions) => executeAuthActions(p, actions, antiBot),
});
extraPages = paginationResult.data;
paginationDebug = paginationResult.debugLogs;
}
res.json({
success: true,
pageUrl,
finalPageUrl,
interceptedApis: interceptor.interceptedApis,
cookies: cookiePayload,
extraPages,
debug: paginationDebug,
cf: cfLog,
profile,
browserReused: browserSession.reused,
elapsedMs: Date.now() - taskStarted,
});
} finally {
if (interceptorCleanup) interceptorCleanup();
await closeTaskPage(page);
if (browserSession) {
await browserSession.release(cfDestroyBrowser);
}
}
}, profile);
} catch (error) {
if (!res.headersSent) sendTaskError(res, error, { cf: cfLog, profile });
}
});
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));
}
console.log('[shutdown] 排空 Browser 温池...');
await browserPool.drain();
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'));