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, 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 { saveSession, getSessionDebugInfo, getSessionStats, } = require('./lib/session-store'); const { resolveSessionContext, touchSessionIfPresent } = require('./lib/session-context'); const { getProfileStats, profileExists } = require('./lib/profile-dir'); 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); } function sendTaskError(res, error, extra = {}) { if (error.code === 'QUEUE_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); return res.status(500).json({ success: false, error: error.message, ...extra }); } 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')) 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; } 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, 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 一次 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: '参数错误' }); } const antiBot = normalizeAntiBot(rawAntiBot); 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; const taskStarted = Date.now(); let cfDestroyBrowser = false; try { browserSession = await acquireBrowserSession({ profile, extraArgs: ['--mute-audio'], sessionKey: antiBot.sessionKey || '', }); cfLog.browserReused = !!browserSession.reused; const { storedSession, mergedCookies } = resolveSessionContext(antiBot, pageUrl); const page = await createManagedPage(browserSession.browser, browserSession.page, { userAgent: DEFAULT_UA, cookies: mergedCookies, }); const httpResolvedUrl = await resolveHttpRedirects(pageUrl).catch(() => pageUrl); if (httpResolvedUrl !== pageUrl) { console.log(`[URL解析] HTTP 重定向: ${pageUrl} -> ${httpResolvedUrl}`); } const interceptor = createApiInterceptor(page, apiUrls, interceptTimeout); interceptorCleanup = interceptor.cleanup; let shareToken = resolveShareToken(pageUrl, httpResolvedUrl); await seedShareTokenCookie(page, pageUrl); if (httpResolvedUrl !== pageUrl) { await seedShareTokenCookie(page, httpResolvedUrl); } await attachHttpIpRewriteInterceptor(page, 'auth-and-intercept', { shareToken }); await navigateToPage(page, pageUrl); let finalPageUrl = await waitForUrlSettled(page); if (finalPageUrl !== pageUrl) { console.log(`[URL解析] 浏览器落地: ${pageUrl} -> ${finalPageUrl}`); } if (antiBot.enabled) { const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled, storedSession); 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(); touchSessionIfPresent(antiBot.sessionKey); } const finalShareToken = resolveShareToken(finalPageUrl); if (finalShareToken && finalShareToken !== shareToken) { shareToken = finalShareToken; await seedShareTokenCookie(page, finalPageUrl); } await executeAuthActions(page, authActions); 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(); 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: 'Context Ready' }); } 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 = normalizeAntiBot(rawAntiBot); const profile = antiBot.profile || 'standard'; try { await runBrowserTask('ui-pagination', async () => { let browserSession = null; const debugLogs = []; let cfDestroyBrowser = false; try { browserSession = await acquireBrowserSession({ profile, extraArgs: ['--window-size=1920,1080'], sessionKey: antiBot.sessionKey || '', }); const { storedSession, mergedCookies } = resolveSessionContext(antiBot, navigationUrl, cookies || []); const page = await createManagedPage(browserSession.browser, browserSession.page, { viewport: { width: 1920, height: 1080 }, cookies: mergedCookies, }); 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 attachHttpIpRewriteInterceptor(page, 'ui-pagination', { shareToken }); await navigateToPage(page, navigationUrl); const settledPageUrl = await waitForUrlSettled(page); if (settledPageUrl !== navigationUrl) { log(`[URL解析] 翻页阶段落地: ${navigationUrl} -> ${settledPageUrl}`); } if (antiBot.enabled) { const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled, storedSession); 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); } 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 { 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 = normalizeAntiBot(rawAntiBot); 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; const taskStarted = Date.now(); let cfDestroyBrowser = false; try { browserSession = await acquireBrowserSession({ profile, extraArgs: ['--mute-audio', '--window-size=1920,1080'], sessionKey: antiBot.sessionKey || '', }); cfLog.browserReused = !!browserSession.reused; const { storedSession, mergedCookies } = resolveSessionContext(antiBot, pageUrl); const page = await createManagedPage(browserSession.browser, browserSession.page, { userAgent: DEFAULT_UA, viewport: { width: 1920, height: 1080 }, cookies: mergedCookies, }); const httpResolvedUrl = await resolveHttpRedirects(pageUrl).catch(() => pageUrl); const interceptor = createApiInterceptor(page, apiUrls, interceptTimeout); interceptorCleanup = interceptor.cleanup; let shareToken = resolveShareToken(pageUrl, httpResolvedUrl); await seedShareTokenCookie(page, pageUrl); await attachHttpIpRewriteInterceptor(page, 'auth-intercept-and-paginate', { shareToken }); await navigateToPage(page, pageUrl); let finalPageUrl = await waitForUrlSettled(page); if (antiBot.enabled) { const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled, storedSession); 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(); touchSessionIfPresent(antiBot.sessionKey); } await executeAuthActions(page, authActions); 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; } 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(); 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'));