import crypto from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' import { runtimeConfig } from '../../config/runtime.js' import { isRetryableQrCaptureError, logBrowserSessionDebug, } from './session-login-shared.js' import { captureQqQrImage, ensureQqLoginReady, extractQqQrState, } from './session-qq.js' import { saveRedeemArtifacts, } from './session-proof.js' import { capturePageCaptchaForOcr, isCaptchaRejectedResult, prepareRedeemCodeFill, refreshPageCaptcha, runTencentBrowserRedeem, submitRedeemInBrowser, } from './session-redeem.js' import { captureWxQrImage, ensureWxLoginReady, extractWxQrState, } from './session-wx.js' import { recognizeTencentCaptcha } from './ocr.js' const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..') const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions') const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html' const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim() const DEFAULT_LOGIN_TYPE = 'qq' const SESSION_TTL_MS = 30 * 60 * 1000 const DEFAULT_VIEWPORT = { width: 1440, height: 1280 } const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) const sessions = new Map() let browserPromise = null class SessionServiceError extends Error { constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) { super(message) this.name = 'SessionServiceError' this.statusCode = statusCode this.errorCode = errorCode } } export async function warmupTencentBrowser() { const browserLaunch = resolveBrowserLaunchOptions() if (!browserLaunch.prewarm) { return { warmed: false, reason: 'disabled', browserLaunch, } } await ensureBrowser() return { warmed: true, reason: 'ready', browserLaunch, } } export async function createTencentBrowserSession(payload = {}) { await cleanupExpiredTencentBrowserSessions() const loginType = normalizeLoginType(payload.loginType || payload.qrType) const browser = await ensureBrowser() const sessionId = createSessionId() const sessionDir = path.join(DATA_ROOT, sessionId) await fs.mkdir(sessionDir, { recursive: true }) const browserContext = await browser.newContext({ viewport: DEFAULT_VIEWPORT, deviceScaleFactor: 1, }) const page = await browserContext.newPage() page.setDefaultTimeout(20_000) const session = { sessionId, loginType, status: 'created', notice: '浏览器会话已创建', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), expiresAt: Date.now() + SESSION_TTL_MS, sessionDir, browserContext, page, qrImageBase64: '', qrImagePath: '', qrUpdatedAt: '', lastState: null, lastRedeem: null, lastReview: null, lastError: '', presentationSyncAttempts: 0, } sessions.set(sessionId, session) try { await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' }) await ensureLoginTab(page, loginType) await captureSessionQr(session, { ensureLoginReady: false }) applyInitialQrSessionState(session) await persistSessionState(session) void refreshTencentBrowserSession(sessionId).catch((error) => { logBrowserSessionDebug('createSession.backgroundRefresh.error', { sessionId, message: error instanceof Error ? error.message : String(error), }) }) return buildSessionPayload(session) } catch (error) { session.status = 'failed' session.notice = error instanceof Error ? error.message : `${getLoginTypeLabel(loginType)} 浏览器会话初始化失败` session.lastError = session.notice session.updatedAt = new Date().toISOString() await persistSessionState(session) throw error } } export async function getTencentBrowserSession(sessionId, options = {}) { return refreshTencentBrowserSession(sessionId, options) } export async function getTencentBrowserSessionSummary(sessionId) { return refreshTencentBrowserSession(sessionId, { includeQrImage: false }) } export async function reloadTencentBrowserSession(sessionId) { await cleanupExpiredTencentBrowserSessions() const session = await getRequiredSession(sessionId) if (session.browserContextClosed) { throw new SessionServiceError('浏览器会话已关闭', { statusCode: 410, errorCode: 'session_closed', }) } try { await reloadActivityPageForPresentation(session.page) const cookieMap = await loadCookieMapFromContext(session.browserContext) const credentialReady = hasRedeemCredential(cookieMap) if (!credentialReady) { session.qrImageBase64 = '' session.qrImagePath = '' session.qrUpdatedAt = '' await ensureLoginTab(session.page, session.loginType) } session.notice = credentialReady ? '后端页面已刷新,正在同步最新状态' : `已刷新${getLoginTypeLabel(session.loginType)}登录页,准备重新抓取二维码` session.updatedAt = new Date().toISOString() return await refreshTencentBrowserSession(sessionId) } catch (error) { session.status = 'failed' session.notice = error instanceof Error ? error.message : '刷新后端页面失败' session.lastError = session.notice session.updatedAt = new Date().toISOString() await persistSessionState(session) throw error } } export async function refreshTencentBrowserSession(sessionId, { includeQrImage = true } = {}) { await cleanupExpiredTencentBrowserSessions() const session = await getRequiredSession(sessionId) if (session.browserContextClosed) { throw new SessionServiceError('浏览器会话已关闭', { statusCode: 410, errorCode: 'session_closed', }) } try { let hostState = await extractHostState(session.page) const cookieMap = await loadCookieMapFromContext(session.browserContext) const credentialReady = hasRedeemCredential(cookieMap) if (credentialReady) { const presentation = await ensureLoggedInPresentation(session, { hostState, credentialReady }) hostState = presentation.hostState } else { session.presentationSyncAttempts = 0 } if (!credentialReady && session.loginType === 'wx') { await ensureWxLoginReady(session.page) } const qrState = await extractQrState(session.page, session.loginType) const activityInfo = credentialReady ? await ensureActivityInfoReady(session.page, { triggerRender: true }) : await extractActivityInfo(session.page) session.lastState = { hostState, qrState, credentialReady, activityInfo, cookieKeys: Object.keys(cookieMap), } session.status = resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }) session.notice = resolveSessionNotice({ hostState, qrState, credentialReady, activityInfo, loginType: session.loginType, }) if (credentialReady && activityInfo?.role?.ready) { await ensureReviewScreenshot(session, activityInfo) } session.updatedAt = new Date().toISOString() session.expiresAt = Date.now() + SESSION_TTL_MS if (!session.qrImageBase64 || session.status === 'expired') { await captureSessionQr(session) if (session.status === 'expired') { session.notice = '二维码已刷新,请重新扫码' session.status = 'waiting_scan' } } await persistSessionState(session) return buildSessionPayload(session, { includeQrImage }) } catch (error) { session.status = 'failed' session.notice = error instanceof Error ? error.message : `查询 ${getLoginTypeLabel(session.loginType)} 浏览器会话状态失败` session.lastError = session.notice session.updatedAt = new Date().toISOString() await persistSessionState(session) return buildSessionPayload(session, { includeQrImage }) } } export async function redeemTencentBrowserSession(sessionId, payload = {}) { const session = await getRequiredSession(sessionId) const code = String(payload.code || payload.sCdKey || '').trim() const maxAttempts = Math.max(1, Math.min(Number(payload.maxAttempts || 6), 12)) if (!code) { throw new SessionServiceError('缺少兑换码', { statusCode: 400, errorCode: 'missing_redeem_code', }) } const refreshed = await refreshTencentBrowserSession(sessionId) if (!['logged_in', 'ready_to_redeem', 'redeemed'].includes(refreshed.status)) { throw new SessionServiceError(`当前会话还不能兑换,状态为 ${refreshed.status}`, { statusCode: 409, errorCode: 'session_not_ready', }) } return runTencentBrowserRedeem({ session, code, maxAttempts, ensureLoggedInPresentation, ensureActivityInfoReady, fillRedeemCodeInBrowser, capturePageCaptchaForOcr: (page, options) => capturePageCaptchaForOcr(page, { ...options, ensureActivityInfoReady }), recognizeTencentCaptcha, submitRedeemInBrowser: (page, redeemPayload) => submitRedeemInBrowser(page, redeemPayload, { ensureActivityInfoReady }), isCaptchaRejectedResult, refreshPageCaptcha, persistSessionState, buildSessionPayload, saveRedeemArtifacts, activityUrl: ACTIVITY_URL, }) } export async function closeTencentBrowserSession(sessionId) { const session = await getRequiredSession(sessionId) await closeSession(session, { markClosed: true }) return { sessionId, closed: true, } } export async function closeAllTencentBrowserSessions({ markClosed = true } = {}) { const activeSessions = [...sessions.values()] await Promise.all(activeSessions.map((session) => closeSession(session, { markClosed }))) return { closedCount: activeSessions.length, } } export async function getTencentBrowserSessionScreenshotPath(sessionId) { const session = await getRequiredSession(sessionId) const screenshotPath = session.lastRedeem?.screenshotPath if (!screenshotPath) { throw new SessionServiceError('当前会话还没有兑换截图', { statusCode: 404, errorCode: 'screenshot_not_ready', }) } return screenshotPath } export async function getTencentBrowserSessionReviewScreenshotPath(sessionId) { const session = await getRequiredSession(sessionId) const screenshotPath = session.lastReview?.screenshotPath if (!screenshotPath) { throw new SessionServiceError('当前会话还没有客服复核截图', { statusCode: 404, errorCode: 'review_screenshot_not_ready', }) } return screenshotPath } async function ensureBrowser() { if (!browserPromise) { const launchOptions = resolveBrowserLaunchOptions() browserPromise = chromium.launch(buildChromiumLaunchOptions(launchOptions)) .then((browser) => { browser.on('disconnected', () => { browserPromise = null }) return browser }) .catch((error) => { browserPromise = null throw error }) } return browserPromise } function resolveBrowserLaunchOptions() { const explicitHeadless = runtimeConfig.browser.headless const explicitDevtools = runtimeConfig.browser.devtools const explicitKeepAlive = runtimeConfig.browser.keepAlive const explicitPrewarm = runtimeConfig.browser.prewarm const explicitSlowMo = runtimeConfig.browser.slowMoMs const isProductionLike = process.env.NODE_ENV === 'production' || process.env.CI === '1' const headless = typeof explicitHeadless === 'boolean' ? explicitHeadless : isProductionLike const devtools = typeof explicitDevtools === 'boolean' ? explicitDevtools : !headless const slowMoMs = Number.isFinite(explicitSlowMo) ? Math.max(0, Math.min(explicitSlowMo, 2_000)) : headless ? 0 : 150 const keepAlive = typeof explicitKeepAlive === 'boolean' ? explicitKeepAlive : !isProductionLike const prewarm = typeof explicitPrewarm === 'boolean' ? explicitPrewarm : keepAlive return { headless, devtools, usesBundledChromium: !CHROME_PATH, keepAlive, prewarm, slowMoMs, } } function buildChromiumLaunchOptions(launchOptions) { const options = { headless: launchOptions.headless, devtools: launchOptions.devtools, slowMo: launchOptions.slowMoMs, } if (CHROME_PATH) { return { ...options, executablePath: CHROME_PATH, } } return options } function createSessionId() { let sessionId = '' do { sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}` } while (sessions.has(sessionId)) return sessionId } function normalizeLoginType(value) { const normalized = String(value || DEFAULT_LOGIN_TYPE).trim().toLowerCase() if (['qq', 'qc'].includes(normalized)) { return 'qq' } if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) { return 'wx' } return DEFAULT_LOGIN_TYPE } function getLoginTypeLabel(loginType) { return loginType === 'wx' ? '微信' : 'QQ' } async function getRequiredSession(sessionId) { const key = String(sessionId || '').trim() if (!key) { throw new SessionServiceError('缺少 sessionId', { statusCode: 400, errorCode: 'missing_session_id', }) } const session = sessions.get(key) if (!session) { throw new SessionServiceError('浏览器会话不存在或已过期', { statusCode: 404, errorCode: 'session_not_found', }) } return session } async function captureSessionQr(session, { ensureLoginReady = true } = {}) { if (ensureLoginReady) { await ensureLoginTab(session.page, session.loginType) } const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`) await captureQrImageWithRetry(session.page, session.loginType, qrImagePath) session.qrImagePath = qrImagePath session.qrImageBase64 = await fs.readFile(qrImagePath, 'base64') session.qrUpdatedAt = new Date().toISOString() session.updatedAt = new Date().toISOString() } async function captureQrImageWithRetry(page, loginType, qrImagePath) { const maxAttempts = loginType === 'qq' ? 4 : 3 let lastError = null for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { logBrowserSessionDebug('captureQrImageWithRetry.start', { loginType, attempt, maxAttempts, qrImagePath, }) if (loginType === 'qq') { await captureQqQrImage(page, qrImagePath) } else { await captureWxQrImage(page, qrImagePath) } logBrowserSessionDebug('captureQrImageWithRetry.success', { loginType, attempt, qrImagePath, }) return } catch (error) { lastError = error logBrowserSessionDebug('captureQrImageWithRetry.error', { loginType, attempt, message: error instanceof Error ? error.message : String(error), }) if (!isRetryableQrCaptureError(error) || attempt === maxAttempts) { throw error } await page.waitForTimeout(500) await ensureLoginTab(page, loginType) } } if (lastError) { throw lastError } throw new Error('二维码截图失败') } async function ensureLoginTab(page, loginType) { const tabSelector = loginType === 'wx' ? '.wx-tab' : '.qc-tab' const loginButton = page.locator('#unlogin') let interacted = false if (await loginButton.count()) { try { await loginButton.first().click({ timeout: 2_000 }) interacted = true } catch { // ignore when the login layer is already open } } const tab = page.locator(tabSelector).first() if (await tab.count()) { try { await tab.click({ timeout: 2_000 }) interacted = true } catch { // ignore tab click failures } } if (interacted) { await page.waitForTimeout(250) } if (loginType === 'qq') { await ensureQqLoginReady(page) return } if (loginType === 'wx') { await ensureWxLoginReady(page) } } function applyInitialQrSessionState(session) { session.status = 'waiting_scan' session.notice = `请使用${getLoginTypeLabel(session.loginType)}扫码登录` session.updatedAt = new Date().toISOString() session.expiresAt = Date.now() + SESSION_TTL_MS } async function extractHostState(page) { return page.evaluate(() => { const isVisible = (element) => { if (!element) { return false } const style = window.getComputedStyle(element) return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' } const unlogin = document.querySelector('#unlogin') const logined = document.querySelector('#logined') return { unloginVisible: isVisible(unlogin), loginedVisible: isVisible(logined), loginText: String(logined?.textContent || '').trim(), } }) } async function ensureLoggedInPresentation( session, { hostState = null, credentialReady = false } = {}, ) { let nextHostState = hostState || (await extractHostState(session.page)) if (!credentialReady || !shouldRefreshLoggedInPresentation(nextHostState)) { return { reloaded: false, hostState: nextHostState, } } if (session.presentationSyncAttempts >= 2) { return { reloaded: false, hostState: nextHostState, } } session.presentationSyncAttempts += 1 await reloadActivityPageForPresentation(session.page) nextHostState = await extractHostState(session.page) return { reloaded: true, hostState: nextHostState, } } function shouldRefreshLoggedInPresentation(hostState) { const loginText = String(hostState?.loginText || '').trim() return Boolean(hostState?.unloginVisible || !hostState?.loginedVisible || !loginText) } async function reloadActivityPageForPresentation(page) { try { await page.reload({ waitUntil: 'domcontentloaded' }) } catch { await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' }) } await page.waitForTimeout(2_500) } async function extractQrState(page, loginType) { if (loginType === 'qq') { return extractQqQrState(page) } return extractWxQrState(page) } async function ensureActivityInfoReady(page, { triggerRender = false, timeoutMs = 8_000 } = {}) { if (triggerRender) { await triggerActivityRoleRender(page) } const startedAt = Date.now() let lastInfo = await extractActivityInfo(page) while (Date.now() - startedAt < timeoutMs) { if (lastInfo?.role?.ready) { return lastInfo } await page.waitForTimeout(400) lastInfo = await extractActivityInfo(page) } return lastInfo } async function triggerActivityRoleRender(page) { try { await page.evaluate(() => { const maybeRender = window.renderCdkey if (typeof maybeRender === 'function') { maybeRender() } }) } catch { // ignore render trigger failures; polling will still inspect the page } } async function extractActivityInfo(page) { try { return await page.evaluate(() => { const asString = (value) => (typeof value === 'string' ? value.trim() : '') const isVisible = (element) => { if (!element) { return false } const style = window.getComputedStyle(element) return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' } const pick = (selector) => document.querySelector(selector) const pickBest = (selector, predicate = null) => { const matches = [...document.querySelectorAll(selector)] const preferred = matches.find((element) => { if (predicate && !predicate(element)) { return false } return isVisible(element) }) if (preferred) { return preferred } if (predicate) { return matches.find((element) => predicate(element)) || null } return matches[0] || null } const textOf = (selector) => asString(pick(selector)?.textContent || '') const inputOf = (selector) => { const element = pickBest( selector, (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement, ) if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { return '' } return asString(element.value || '') } const roleData = window.roleData && typeof window.roleData === 'object' ? window.roleData : {} const roleSelector = pick('[id^="milo_role_selector_"]') const roleNameText = textOf('#role_name') || asString(roleSelector?.selectedOptions?.[0]?.textContent || '') const verifyImg = pickBest('[id^="milo_verifyImg_"]', (node) => node instanceof HTMLImageElement) const popup = pick('#pop2') const cdkeyInput = pickBest( '[id^="milo_cdkeyInfo_"]', (node) => (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && !node.disabled && node.type !== 'hidden', ) const verifyInput = pickBest( '[id^="milo_verifyInput_"]', (node) => (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && !node.disabled && node.type !== 'hidden', ) const submitButton = pickBest( '#milo_cdkey_submit, [id^="milo_cdkey_submit"]', (node) => node instanceof HTMLElement && !node.hasAttribute('disabled'), ) return { nickname: textOf('#login_nickname_span'), role: { ready: Boolean(window.isRole || roleNameText), roleName: roleNameText, roleId: asString(roleData.sRoleId), area: asString(roleData.sArea || '36'), partition: asString(roleData.sPartition), platId: asString(roleData.sPlatId), md5str: asString(roleData.sMd5str || roleData.md5str || roleData.sMdStr), checkparam: asString(roleData.sCheckparam), }, form: { cdkeyInputId: cdkeyInput?.id || '', cdkeyValue: inputOf('[id^="milo_cdkeyInfo_"]'), verifyInputId: verifyInput?.id || '', verifyValue: inputOf('[id^="milo_verifyInput_"]'), verifyImgId: verifyImg?.id || '', submitId: submitButton?.id || '', }, verify: { visible: isVisible(verifyImg), src: asString(verifyImg?.getAttribute('src')), naturalWidth: Number(verifyImg?.naturalWidth || 0), naturalHeight: Number(verifyImg?.naturalHeight || 0), }, popup: { visible: isVisible(popup), text: textOf('#PopText'), detail: textOf('#PopText2'), }, } }) } catch { return { nickname: '', role: { ready: false, roleName: '', roleId: '', area: '', partition: '', platId: '', md5str: '', checkparam: '', }, form: { cdkeyInputId: '', cdkeyValue: '', verifyInputId: '', verifyValue: '', verifyImgId: '', submitId: '', }, verify: { visible: false, src: '', naturalWidth: 0, naturalHeight: 0, }, popup: { visible: false, text: '', detail: '', }, } } } function resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }) { if (hostState.loginedVisible || credentialReady) { if (credentialReady && activityInfo?.role?.ready) { return 'ready_to_redeem' } return 'logged_in' } if (qrState.expired) { return 'expired' } if (qrState.scanned) { return 'scanned' } return 'waiting_scan' } function resolveSessionNotice({ hostState, qrState, credentialReady, activityInfo, loginType }) { const loginTypeLabel = getLoginTypeLabel(loginType) if (credentialReady) { if (activityInfo?.role?.ready && activityInfo.role.roleName) { return `登录已完成,当前角色 ${activityInfo.role.roleName}` } return '登录已完成,正在同步角色信息' } if (hostState.loginedVisible) { return '页面已识别登录态' } if (qrState.expired) { return '二维码已失效,准备刷新' } if (qrState.scanned) { return '二维码已扫描,请在手机上确认登录' } return `请使用${loginTypeLabel}扫码登录` } async function cleanupExpiredTencentBrowserSessions() { const expired = [...sessions.values()].filter((session) => session.expiresAt <= Date.now()) await Promise.all(expired.map((session) => closeSession(session, { markClosed: false }))) } async function closeSession(session, { markClosed }) { sessions.delete(session.sessionId) try { await session.browserContext?.close() } catch { // ignore browser context close failures } session.browserContextClosed = true session.updatedAt = new Date().toISOString() if (markClosed) { session.status = 'closed' session.notice = '浏览器会话已关闭' await persistSessionState(session) } await closeBrowserIfIdle() } async function persistSessionState(session) { await fs.mkdir(session.sessionDir, { recursive: true }) await fs.writeFile( path.join(session.sessionDir, 'session.json'), JSON.stringify(buildSessionPayload(session), null, 2), 'utf8', ) } function buildSessionPayload(session, { includeQrImage = true } = {}) { const activityInfo = session.lastState?.activityInfo || null const payload = { sessionId: session.sessionId, loginType: session.loginType, activityUrl: ACTIVITY_URL, status: session.status, notice: session.notice, createdAt: session.createdAt, updatedAt: session.updatedAt, expiresAt: new Date(session.expiresAt).toISOString(), qrUpdatedAt: session.qrUpdatedAt, credentialReady: Boolean(session.lastState?.credentialReady), activityInfo, review: buildReviewPayload(session.lastReview), redeem: buildRedeemPayload(session.lastRedeem), artifacts: buildArtifactsPayload(session), } if (includeQrImage) { payload.qrImageBase64 = session.qrImageBase64 } if (!SESSION_DEBUG_ENABLED) { return payload } return { ...payload, cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [], state: session.lastState, browserDebug: resolveBrowserLaunchOptions(), qrImagePath: session.qrImagePath, review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }), redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }), artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }), } } function buildReviewPayload(review, { includeInternalPaths = false } = {}) { if (!review) { return null } const payload = { capturedAt: review.capturedAt, roleId: review.roleId || '', roleName: review.roleName || '', } if (!includeInternalPaths) { return payload } return { ...payload, screenshotPath: review.screenshotPath || '', signature: review.signature || '', } } function buildRedeemPayload(redeem, { includeInternalPaths = false } = {}) { if (!redeem) { return null } const payload = { code: redeem.code, area: redeem.area, proofMode: redeem.proofMode || 'full', attempts: Array.isArray(redeem.attempts) ? redeem.attempts.map((attempt) => ({ attempt: attempt.attempt, redeem: attempt.redeem || null, })) : [], final: redeem.final ? { attempt: redeem.final.attempt, redeem: redeem.final.redeem || null, } : null, finishedAt: redeem.finishedAt, } if (!includeInternalPaths) { return payload } return { ...payload, screenshotPath: redeem.screenshotPath || '', htmlPath: redeem.htmlPath || '', resultPath: redeem.resultPath || '', } } function buildArtifactsPayload(session, { includeInternalPaths = false } = {}) { const payload = { hasQrImage: Boolean(session.qrImageBase64), hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath), hasScreenshot: Boolean(session.lastRedeem?.screenshotPath), } if (!includeInternalPaths) { return payload } return { ...payload, sessionDir: session.sessionDir, qrImagePath: session.qrImagePath, screenshotPath: session.lastRedeem?.screenshotPath || '', htmlPath: session.lastRedeem?.htmlPath || '', resultPath: session.lastRedeem?.resultPath || '', reviewScreenshotPath: session.lastReview?.screenshotPath || '', } } async function ensureReviewScreenshot(session, activityInfo) { const roleId = String(activityInfo?.role?.roleId || '').trim() const roleName = String(activityInfo?.role?.roleName || '').trim() const signature = `${session.loginType}|${roleId}|${roleName}` if (session.lastReview?.screenshotPath && session.lastReview?.signature === signature) { return } const screenshotPath = path.join(session.sessionDir, 'role-review.png') try { await session.page.screenshot({ path: screenshotPath, fullPage: true, }) session.lastReview = { screenshotPath, capturedAt: new Date().toISOString(), roleId, roleName, signature, } } catch (error) { logBrowserSessionDebug('reviewScreenshot.capture.error', { sessionId: session.sessionId, message: error instanceof Error ? error.message : String(error), }) } } async function loadCookieMapFromContext(browserContext) { const cookies = await browserContext.cookies([ 'https://df.qq.com', 'https://ssl.captcha.qq.com', 'https://graph.qq.com', 'https://milo.qq.com', ]) const cookieMap = {} for (const cookie of cookies) { if (!cookie?.name) { continue } cookieMap[String(cookie.name)] = String(cookie.value || '') } if (!cookieMap.acctype) { cookieMap.acctype = 'qc' } if (!cookieMap.appid) { cookieMap.appid = '101491592' } if (cookieMap.openid && cookieMap.access_token && !cookieMap.iegams_milo_proxylogin_qc) { cookieMap.iegams_milo_proxylogin_qc = `${cookieMap.appid}_$$_${cookieMap.openid}_$$_${cookieMap.access_token}` } return cookieMap } function hasRedeemCredential(cookieMap) { return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid) } async function fillRedeemCodeInBrowser(page, code) { await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) } async function closeBrowserIfIdle() { if (sessions.size > 0 || !browserPromise) { return } if (resolveBrowserLaunchOptions().keepAlive) { return } try { const browser = await browserPromise await browser.close() } catch { // ignore browser close failures } finally { browserPromise = null } }