974 lines
26 KiB
JavaScript
974 lines
26 KiB
JavaScript
// @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
|
|
}
|
|
}
|