拆分腾讯会话纯函数与序列化逻辑
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user