拆分腾讯会话纯函数与序列化逻辑

This commit is contained in:
yml
2026-05-04 13:38:58 +08:00
parent 899d898a8e
commit ab33272cac
5 changed files with 446 additions and 272 deletions
@@ -0,0 +1,101 @@
import process from 'node:process'
export const DEFAULT_LOGIN_TYPE = 'qq'
export function resolveBrowserLaunchOptions(options = {}) {
const browserConfig = options.browserConfig || {}
const chromePath = options.chromePath || ''
const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV
const ci = options.ci ?? process.env.CI
const explicitHeadless = browserConfig.headless
const explicitDevtools = browserConfig.devtools
const explicitKeepAlive = browserConfig.keepAlive
const explicitPrewarm = browserConfig.prewarm
const explicitSlowMo = browserConfig.slowMoMs
const isProductionLike = nodeEnv === 'production' || 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: !chromePath,
keepAlive,
prewarm,
slowMoMs,
}
}
export function buildChromiumLaunchOptions(launchOptions, { chromePath = '' } = {}) {
const options = {
headless: launchOptions.headless,
devtools: launchOptions.devtools,
slowMo: launchOptions.slowMoMs,
}
if (chromePath) {
return {
...options,
executablePath: chromePath,
}
}
return options
}
export function resolveDefaultViewport(rawValue = process.env.TENCENT_BROWSER_VNC_RESOLUTION) {
const fromVncResolution = parseViewportSpec(rawValue)
if (fromVncResolution) {
return fromVncResolution
}
return { width: 1600, height: 900 }
}
export function parseViewportSpec(rawValue) {
const text = String(rawValue || '').trim()
if (!text) {
return null
}
const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i)
if (!match) {
return null
}
const width = Number(match[1])
const height = Number(match[2])
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) {
return null
}
return { width, height }
}
export function normalizeLoginType(value, fallback = DEFAULT_LOGIN_TYPE) {
const normalized = String(value || fallback).trim().toLowerCase()
if (['qq', 'qc'].includes(normalized)) {
return 'qq'
}
if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) {
return 'wx'
}
return fallback
}
export function getLoginTypeLabel(loginType) {
return loginType === 'wx' ? '微信' : 'QQ'
}
@@ -0,0 +1,126 @@
export function buildSessionPayload(
session,
{
activityUrl = '',
includeQrImage = true,
sessionDebugEnabled = false,
browserDebug = null,
} = {},
) {
const activityInfo = session.lastState?.activityInfo || null
const payload = {
sessionId: session.sessionId,
loginType: session.loginType,
activityUrl,
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 (!sessionDebugEnabled) {
return payload
}
return {
...payload,
cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [],
state: session.lastState,
browserDebug,
qrImagePath: session.qrImagePath,
review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }),
redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }),
artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }),
}
}
export 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 || '',
}
}
export 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 || '',
}
}
export 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 || '',
}
}
@@ -0,0 +1,74 @@
import { getLoginTypeLabel } from './session-browser-config.js'
export const SESSION_TTL_MS = 30 * 60 * 1000
export function resolveTencentSessionExpiresAt(session, nowMs = Date.now(), sessionTtlMs = SESSION_TTL_MS) {
const autoCloseAt = Number(session?.autoCloseAt || 0)
if (autoCloseAt > 0) {
return autoCloseAt
}
return nowMs + sessionTtlMs
}
export function shouldRefreshLoggedInPresentation(hostState) {
const loginText = String(hostState?.loginText || '').trim()
return Boolean(hostState?.unloginVisible || !hostState?.loginedVisible || !loginText)
}
export 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'
}
export 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}扫码登录`
}
export function hasRedeemCredential(cookieMap) {
return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid)
}
+30 -272
View File
@@ -3,16 +3,25 @@
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 {
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,
@@ -35,14 +44,20 @@ import {
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()
const DEFAULT_LOGIN_TYPE = 'qq'
const SESSION_TTL_MS = 30 * 60 * 1000
export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000
const DEFAULT_VIEWPORT = resolveDefaultViewport()
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
@@ -51,6 +66,8 @@ const sessions = new Map()
let browserPromise = null
export { resolveTencentSessionExpiresAt }
class SessionServiceError extends Error {
constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) {
super(message)
@@ -396,83 +413,14 @@ async function ensureBrowser() {
}
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,
}
return resolveBrowserLaunchOptionsBase({
browserConfig: runtimeConfig.browser,
chromePath: CHROME_PATH,
})
}
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 resolveDefaultViewport() {
const fromVncResolution = parseViewportSpec(process.env.TENCENT_BROWSER_VNC_RESOLUTION)
if (fromVncResolution) {
return fromVncResolution
}
return { width: 1600, height: 900 }
}
function parseViewportSpec(rawValue) {
const text = String(rawValue || '').trim()
if (!text) {
return null
}
const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i)
if (!match) {
return null
}
const width = Number(match[1])
const height = Number(match[2])
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) {
return null
}
return { width, height }
return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH })
}
function createSessionId() {
@@ -485,21 +433,6 @@ function createSessionId() {
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()
@@ -633,15 +566,6 @@ function applyInitialQrSessionState(session) {
session.expiresAt = Date.now() + SESSION_TTL_MS
}
export function resolveTencentSessionExpiresAt(session, nowMs = Date.now()) {
const autoCloseAt = Number(session?.autoCloseAt || 0)
if (autoCloseAt > 0) {
return autoCloseAt
}
return nowMs + SESSION_TTL_MS
}
function armRedeemedSessionAutoClose(session) {
resetTencentSessionAutoClose(session)
@@ -719,12 +643,6 @@ async function ensureLoggedInPresentation(
}
}
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' })
@@ -920,52 +838,6 @@ async function extractActivityInfo(page) {
}
}
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())
@@ -1004,122 +876,12 @@ async function persistSessionState(session) {
}
function buildSessionPayload(session, { includeQrImage = true } = {}) {
const activityInfo = session.lastState?.activityInfo || null
const payload = {
sessionId: session.sessionId,
loginType: session.loginType,
return buildSessionPayloadBase(session, {
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 || '',
}
includeQrImage,
sessionDebugEnabled: SESSION_DEBUG_ENABLED,
browserDebug: SESSION_DEBUG_ENABLED ? resolveBrowserLaunchOptions() : null,
})
}
async function ensureReviewScreenshot(session, activityInfo) {
@@ -1187,10 +949,6 @@ async function loadCookieMapFromContext(browserContext) {
return cookieMap
}
function hasRedeemCredential(cookieMap) {
return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid)
}
async function fillRedeemCodeInBrowser(page, code) {
await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady })
}
@@ -1,6 +1,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
normalizeLoginType,
parseViewportSpec,
} from './session-browser-config.js'
import {
buildArtifactsPayload,
buildRedeemPayload,
} from './session-payload.js'
import {
REDEEM_SUCCESS_AUTO_CLOSE_MS,
resolveTencentSessionExpiresAt,
@@ -23,3 +31,110 @@ test('resolveTencentSessionExpiresAt keeps redeemed auto-close deadline once arm
autoCloseAt,
)
})
test('normalizeLoginType collapses aliases and falls back to qq', () => {
assert.equal(normalizeLoginType(' qc '), 'qq')
assert.equal(normalizeLoginType('wechat'), 'wx')
assert.equal(normalizeLoginType('unknown'), 'qq')
assert.equal(normalizeLoginType(''), 'qq')
})
test('parseViewportSpec accepts valid viewport strings and rejects invalid values', () => {
assert.deepEqual(parseViewportSpec('1920x1080'), { width: 1920, height: 1080 })
assert.deepEqual(parseViewportSpec('1920x1080x24'), { width: 1920, height: 1080 })
assert.equal(parseViewportSpec('799x600'), null)
assert.equal(parseViewportSpec('broken'), null)
})
test('buildRedeemPayload strips internal paths unless explicitly requested', () => {
const redeem = {
code: 'ABC123',
area: '微信区',
proofMode: 'basic',
attempts: [
{
attempt: 1,
redeem: { success: false, message: '验证码错误' },
},
],
final: {
attempt: 2,
redeem: { success: true, message: '兑换成功' },
},
finishedAt: '2026-04-14T09:40:00.000Z',
screenshotPath: '/tmp/redeem.png',
htmlPath: '/tmp/redeem.html',
resultPath: '/tmp/redeem.json',
}
assert.deepEqual(buildRedeemPayload(redeem), {
code: 'ABC123',
area: '微信区',
proofMode: 'basic',
attempts: [
{
attempt: 1,
redeem: { success: false, message: '验证码错误' },
},
],
final: {
attempt: 2,
redeem: { success: true, message: '兑换成功' },
},
finishedAt: '2026-04-14T09:40:00.000Z',
})
assert.deepEqual(buildRedeemPayload(redeem, { includeInternalPaths: true }), {
code: 'ABC123',
area: '微信区',
proofMode: 'basic',
attempts: [
{
attempt: 1,
redeem: { success: false, message: '验证码错误' },
},
],
final: {
attempt: 2,
redeem: { success: true, message: '兑换成功' },
},
finishedAt: '2026-04-14T09:40:00.000Z',
screenshotPath: '/tmp/redeem.png',
htmlPath: '/tmp/redeem.html',
resultPath: '/tmp/redeem.json',
})
})
test('buildArtifactsPayload only exposes internal artifact paths in debug mode', () => {
const session = {
sessionDir: '/tmp/session',
qrImagePath: '/tmp/session/qq-qr.png',
qrImageBase64: 'base64data',
lastReview: {
screenshotPath: '/tmp/session/review.png',
},
lastRedeem: {
screenshotPath: '/tmp/session/redeem.png',
htmlPath: '/tmp/session/redeem.html',
resultPath: '/tmp/session/redeem.json',
},
}
assert.deepEqual(buildArtifactsPayload(session), {
hasQrImage: true,
hasReviewScreenshot: true,
hasScreenshot: true,
})
assert.deepEqual(buildArtifactsPayload(session, { includeInternalPaths: true }), {
hasQrImage: true,
hasReviewScreenshot: true,
hasScreenshot: true,
sessionDir: '/tmp/session',
qrImagePath: '/tmp/session/qq-qr.png',
screenshotPath: '/tmp/session/redeem.png',
htmlPath: '/tmp/session/redeem.html',
resultPath: '/tmp/session/redeem.json',
reviewScreenshotPath: '/tmp/session/review.png',
})
})