// @ts-nocheck import crypto from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' import { runtimeConfig } from '../../config/runtime.js' import { buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase, getLoginTypeLabel, normalizeLoginType, resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase, resolveDefaultViewport, } from './session-browser-config.js' import { isRetryableQrCaptureError, logBrowserSessionDebug, } from './session-login-shared.js' import { buildSessionPayload as buildSessionPayloadBase, } from './session-payload.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' import { hasRedeemCredential, resolveSessionNotice, resolveSessionStatus, resolveTencentSessionExpiresAt, SESSION_TTL_MS, shouldRefreshLoggedInPresentation, } from './session-state.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() export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000 const DEFAULT_VIEWPORT = resolveDefaultViewport() const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) const sessions = new Map() let browserPromise = null export { resolveTencentSessionExpiresAt } 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, autoCloseAt: 0, autoCloseTimer: null, 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) await captureSessionQr(session) } 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 = resolveTencentSessionExpiresAt(session) if (!session.qrImageBase64 || session.status === 'expired') { if (session.status === 'expired') { await reloadActivityPageForPresentation(session.page) await ensureLoginTab(session.page, session.loginType) } 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', }) } resetTencentSessionAutoClose(session) await 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, }) armRedeemedSessionAutoClose(session) await persistSessionState(session) return { ...buildSessionPayload(session), redeem: session.lastRedeem, } } 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() { return resolveBrowserLaunchOptionsBase({ browserConfig: runtimeConfig.browser, chromePath: CHROME_PATH, }) } function buildChromiumLaunchOptions(launchOptions) { return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH }) } function createSessionId() { let sessionId = '' do { sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}` } while (sessions.has(sessionId)) return sessionId } 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) { resetTencentSessionAutoClose(session) session.status = 'waiting_scan' session.notice = `请使用${getLoginTypeLabel(session.loginType)}扫码登录` session.updatedAt = new Date().toISOString() session.expiresAt = Date.now() + SESSION_TTL_MS } function armRedeemedSessionAutoClose(session) { resetTencentSessionAutoClose(session) const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS session.autoCloseAt = autoCloseAt session.expiresAt = autoCloseAt session.autoCloseTimer = setTimeout(() => { void closeSession(session, { markClosed: true }).catch(() => { // ignore delayed auto-close failures }) }, REDEEM_SUCCESS_AUTO_CLOSE_MS) } function resetTencentSessionAutoClose(session) { if (session?.autoCloseTimer) { clearTimeout(session.autoCloseTimer) session.autoCloseTimer = null } if (session) { session.autoCloseAt = 0 } } 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, } } 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: '', }, } } } 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 }) { resetTencentSessionAutoClose(session) 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 } = {}) { return buildSessionPayloadBase(session, { activityUrl: ACTIVITY_URL, includeQrImage, sessionDebugEnabled: SESSION_DEBUG_ENABLED, browserDebug: SESSION_DEBUG_ENABLED ? resolveBrowserLaunchOptions() : null, }) } 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 } 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 } }