拆分腾讯会话纯函数与序列化逻辑
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 crypto from 'node:crypto'
|
||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import process from 'node:process'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { chromium } from 'playwright'
|
import { chromium } from 'playwright'
|
||||||
|
|
||||||
import { runtimeConfig } from '../../config/runtime.js'
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
|
import {
|
||||||
|
buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase,
|
||||||
|
getLoginTypeLabel,
|
||||||
|
normalizeLoginType,
|
||||||
|
resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase,
|
||||||
|
resolveDefaultViewport,
|
||||||
|
} from './session-browser-config.js'
|
||||||
import {
|
import {
|
||||||
isRetryableQrCaptureError,
|
isRetryableQrCaptureError,
|
||||||
logBrowserSessionDebug,
|
logBrowserSessionDebug,
|
||||||
} from './session-login-shared.js'
|
} from './session-login-shared.js'
|
||||||
|
import {
|
||||||
|
buildSessionPayload as buildSessionPayloadBase,
|
||||||
|
} from './session-payload.js'
|
||||||
import {
|
import {
|
||||||
captureQqQrImage,
|
captureQqQrImage,
|
||||||
ensureQqLoginReady,
|
ensureQqLoginReady,
|
||||||
@@ -35,14 +44,20 @@ import {
|
|||||||
extractWxQrState,
|
extractWxQrState,
|
||||||
} from './session-wx.js'
|
} from './session-wx.js'
|
||||||
import { recognizeTencentCaptcha } from './ocr.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 CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..')
|
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..')
|
||||||
const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions')
|
const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions')
|
||||||
const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
|
const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
|
||||||
const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim()
|
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
|
export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000
|
||||||
const DEFAULT_VIEWPORT = resolveDefaultViewport()
|
const DEFAULT_VIEWPORT = resolveDefaultViewport()
|
||||||
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
|
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
|
||||||
@@ -51,6 +66,8 @@ const sessions = new Map()
|
|||||||
|
|
||||||
let browserPromise = null
|
let browserPromise = null
|
||||||
|
|
||||||
|
export { resolveTencentSessionExpiresAt }
|
||||||
|
|
||||||
class SessionServiceError extends Error {
|
class SessionServiceError extends Error {
|
||||||
constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) {
|
constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) {
|
||||||
super(message)
|
super(message)
|
||||||
@@ -396,83 +413,14 @@ async function ensureBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveBrowserLaunchOptions() {
|
function resolveBrowserLaunchOptions() {
|
||||||
const explicitHeadless = runtimeConfig.browser.headless
|
return resolveBrowserLaunchOptionsBase({
|
||||||
const explicitDevtools = runtimeConfig.browser.devtools
|
browserConfig: runtimeConfig.browser,
|
||||||
const explicitKeepAlive = runtimeConfig.browser.keepAlive
|
chromePath: CHROME_PATH,
|
||||||
const explicitPrewarm = runtimeConfig.browser.prewarm
|
})
|
||||||
const explicitSlowMo = runtimeConfig.browser.slowMoMs
|
|
||||||
const isProductionLike = process.env.NODE_ENV === 'production' || process.env.CI === '1'
|
|
||||||
|
|
||||||
const headless = typeof explicitHeadless === 'boolean' ? explicitHeadless : isProductionLike
|
|
||||||
|
|
||||||
const devtools = typeof explicitDevtools === 'boolean' ? explicitDevtools : !headless
|
|
||||||
|
|
||||||
const slowMoMs = Number.isFinite(explicitSlowMo)
|
|
||||||
? Math.max(0, Math.min(explicitSlowMo, 2_000))
|
|
||||||
: headless
|
|
||||||
? 0
|
|
||||||
: 150
|
|
||||||
|
|
||||||
const keepAlive = typeof explicitKeepAlive === 'boolean' ? explicitKeepAlive : !isProductionLike
|
|
||||||
|
|
||||||
const prewarm = typeof explicitPrewarm === 'boolean' ? explicitPrewarm : keepAlive
|
|
||||||
|
|
||||||
return {
|
|
||||||
headless,
|
|
||||||
devtools,
|
|
||||||
usesBundledChromium: !CHROME_PATH,
|
|
||||||
keepAlive,
|
|
||||||
prewarm,
|
|
||||||
slowMoMs,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChromiumLaunchOptions(launchOptions) {
|
function buildChromiumLaunchOptions(launchOptions) {
|
||||||
const options = {
|
return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH })
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionId() {
|
function createSessionId() {
|
||||||
@@ -485,21 +433,6 @@ function createSessionId() {
|
|||||||
return sessionId
|
return sessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLoginType(value) {
|
|
||||||
const normalized = String(value || DEFAULT_LOGIN_TYPE).trim().toLowerCase()
|
|
||||||
if (['qq', 'qc'].includes(normalized)) {
|
|
||||||
return 'qq'
|
|
||||||
}
|
|
||||||
if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) {
|
|
||||||
return 'wx'
|
|
||||||
}
|
|
||||||
return DEFAULT_LOGIN_TYPE
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLoginTypeLabel(loginType) {
|
|
||||||
return loginType === 'wx' ? '微信' : 'QQ'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRequiredSession(sessionId) {
|
async function getRequiredSession(sessionId) {
|
||||||
const key = String(sessionId || '').trim()
|
const key = String(sessionId || '').trim()
|
||||||
|
|
||||||
@@ -633,15 +566,6 @@ function applyInitialQrSessionState(session) {
|
|||||||
session.expiresAt = Date.now() + SESSION_TTL_MS
|
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) {
|
function armRedeemedSessionAutoClose(session) {
|
||||||
resetTencentSessionAutoClose(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) {
|
async function reloadActivityPageForPresentation(page) {
|
||||||
try {
|
try {
|
||||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
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() {
|
async function cleanupExpiredTencentBrowserSessions() {
|
||||||
const expired = [...sessions.values()].filter((session) => session.expiresAt <= Date.now())
|
const expired = [...sessions.values()].filter((session) => session.expiresAt <= Date.now())
|
||||||
|
|
||||||
@@ -1004,122 +876,12 @@ async function persistSessionState(session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionPayload(session, { includeQrImage = true } = {}) {
|
function buildSessionPayload(session, { includeQrImage = true } = {}) {
|
||||||
const activityInfo = session.lastState?.activityInfo || null
|
return buildSessionPayloadBase(session, {
|
||||||
const payload = {
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
loginType: session.loginType,
|
|
||||||
activityUrl: ACTIVITY_URL,
|
activityUrl: ACTIVITY_URL,
|
||||||
status: session.status,
|
includeQrImage,
|
||||||
notice: session.notice,
|
sessionDebugEnabled: SESSION_DEBUG_ENABLED,
|
||||||
createdAt: session.createdAt,
|
browserDebug: SESSION_DEBUG_ENABLED ? resolveBrowserLaunchOptions() : null,
|
||||||
updatedAt: session.updatedAt,
|
})
|
||||||
expiresAt: new Date(session.expiresAt).toISOString(),
|
|
||||||
qrUpdatedAt: session.qrUpdatedAt,
|
|
||||||
credentialReady: Boolean(session.lastState?.credentialReady),
|
|
||||||
activityInfo,
|
|
||||||
review: buildReviewPayload(session.lastReview),
|
|
||||||
redeem: buildRedeemPayload(session.lastRedeem),
|
|
||||||
artifacts: buildArtifactsPayload(session),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeQrImage) {
|
|
||||||
payload.qrImageBase64 = session.qrImageBase64
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!SESSION_DEBUG_ENABLED) {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [],
|
|
||||||
state: session.lastState,
|
|
||||||
browserDebug: resolveBrowserLaunchOptions(),
|
|
||||||
qrImagePath: session.qrImagePath,
|
|
||||||
review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }),
|
|
||||||
redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }),
|
|
||||||
artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildReviewPayload(review, { includeInternalPaths = false } = {}) {
|
|
||||||
if (!review) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
capturedAt: review.capturedAt,
|
|
||||||
roleId: review.roleId || '',
|
|
||||||
roleName: review.roleName || '',
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!includeInternalPaths) {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
screenshotPath: review.screenshotPath || '',
|
|
||||||
signature: review.signature || '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRedeemPayload(redeem, { includeInternalPaths = false } = {}) {
|
|
||||||
if (!redeem) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
code: redeem.code,
|
|
||||||
area: redeem.area,
|
|
||||||
proofMode: redeem.proofMode || 'full',
|
|
||||||
attempts: Array.isArray(redeem.attempts)
|
|
||||||
? redeem.attempts.map((attempt) => ({
|
|
||||||
attempt: attempt.attempt,
|
|
||||||
redeem: attempt.redeem || null,
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
final: redeem.final
|
|
||||||
? {
|
|
||||||
attempt: redeem.final.attempt,
|
|
||||||
redeem: redeem.final.redeem || null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
finishedAt: redeem.finishedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!includeInternalPaths) {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
screenshotPath: redeem.screenshotPath || '',
|
|
||||||
htmlPath: redeem.htmlPath || '',
|
|
||||||
resultPath: redeem.resultPath || '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildArtifactsPayload(session, { includeInternalPaths = false } = {}) {
|
|
||||||
const payload = {
|
|
||||||
hasQrImage: Boolean(session.qrImageBase64),
|
|
||||||
hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath),
|
|
||||||
hasScreenshot: Boolean(session.lastRedeem?.screenshotPath),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!includeInternalPaths) {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
sessionDir: session.sessionDir,
|
|
||||||
qrImagePath: session.qrImagePath,
|
|
||||||
screenshotPath: session.lastRedeem?.screenshotPath || '',
|
|
||||||
htmlPath: session.lastRedeem?.htmlPath || '',
|
|
||||||
resultPath: session.lastRedeem?.resultPath || '',
|
|
||||||
reviewScreenshotPath: session.lastReview?.screenshotPath || '',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureReviewScreenshot(session, activityInfo) {
|
async function ensureReviewScreenshot(session, activityInfo) {
|
||||||
@@ -1187,10 +949,6 @@ async function loadCookieMapFromContext(browserContext) {
|
|||||||
return cookieMap
|
return cookieMap
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRedeemCredential(cookieMap) {
|
|
||||||
return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fillRedeemCodeInBrowser(page, code) {
|
async function fillRedeemCodeInBrowser(page, code) {
|
||||||
await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady })
|
await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeLoginType,
|
||||||
|
parseViewportSpec,
|
||||||
|
} from './session-browser-config.js'
|
||||||
|
import {
|
||||||
|
buildArtifactsPayload,
|
||||||
|
buildRedeemPayload,
|
||||||
|
} from './session-payload.js'
|
||||||
import {
|
import {
|
||||||
REDEEM_SUCCESS_AUTO_CLOSE_MS,
|
REDEEM_SUCCESS_AUTO_CLOSE_MS,
|
||||||
resolveTencentSessionExpiresAt,
|
resolveTencentSessionExpiresAt,
|
||||||
@@ -23,3 +31,110 @@ test('resolveTencentSessionExpiresAt keeps redeemed auto-close deadline once arm
|
|||||||
autoCloseAt,
|
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