拆分腾讯会话页面交互辅助逻辑

This commit is contained in:
yml
2026-05-04 14:18:55 +08:00
parent 37299a2797
commit 635e0138fe
3 changed files with 285 additions and 157 deletions
@@ -0,0 +1,188 @@
// @ts-nocheck
import fs from 'node:fs/promises'
import path from 'node:path'
import { isRetryableQrCaptureError, logBrowserSessionDebug } from './session-login-shared.js'
import { captureQqQrImage, ensureQqLoginReady, extractQqQrState } from './session-qq.js'
import { captureWxQrImage, ensureWxLoginReady, extractWxQrState } from './session-wx.js'
export async function captureSessionQr(
session,
{
ensureLoginReady = true,
ensureLoginTab: ensureLoginTabImpl = ensureLoginTab,
captureQrImageWithRetry: captureQrImageWithRetryImpl = captureQrImageWithRetry,
readFile = fs.readFile,
now = () => new Date().toISOString(),
} = {},
) {
if (ensureLoginReady) {
await ensureLoginTabImpl(session.page, session.loginType)
}
const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`)
await captureQrImageWithRetryImpl(session.page, session.loginType, qrImagePath, {
ensureLoginTab: ensureLoginTabImpl,
})
session.qrImagePath = qrImagePath
session.qrImageBase64 = await readFile(qrImagePath, 'base64')
session.qrUpdatedAt = now()
session.updatedAt = session.qrUpdatedAt
}
export async function captureQrImageWithRetry(
page,
loginType,
qrImagePath,
{
captureQqQrImage: captureQqQrImageImpl = captureQqQrImage,
captureWxQrImage: captureWxQrImageImpl = captureWxQrImage,
ensureLoginTab: ensureLoginTabImpl = ensureLoginTab,
isRetryableQrCaptureError: isRetryableQrCaptureErrorImpl = isRetryableQrCaptureError,
logBrowserSessionDebug: logBrowserSessionDebugImpl = logBrowserSessionDebug,
} = {},
) {
const maxAttempts = loginType === 'qq' ? 4 : 3
let lastError = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
logBrowserSessionDebugImpl('captureQrImageWithRetry.start', {
loginType,
attempt,
maxAttempts,
qrImagePath,
})
if (loginType === 'qq') {
await captureQqQrImageImpl(page, qrImagePath)
} else {
await captureWxQrImageImpl(page, qrImagePath)
}
logBrowserSessionDebugImpl('captureQrImageWithRetry.success', {
loginType,
attempt,
qrImagePath,
})
return
} catch (error) {
lastError = error
logBrowserSessionDebugImpl('captureQrImageWithRetry.error', {
loginType,
attempt,
message: error instanceof Error ? error.message : String(error),
})
if (!isRetryableQrCaptureErrorImpl(error) || attempt === maxAttempts) {
throw error
}
await page.waitForTimeout(500)
await ensureLoginTabImpl(page, loginType)
}
}
if (lastError) {
throw lastError
}
throw new Error('二维码截图失败')
}
export async function ensureLoginTab(
page,
loginType,
{
ensureQqLoginReady: ensureQqLoginReadyImpl = ensureQqLoginReady,
ensureWxLoginReady: ensureWxLoginReadyImpl = ensureWxLoginReady,
} = {},
) {
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 ensureQqLoginReadyImpl(page)
return
}
if (loginType === 'wx') {
await ensureWxLoginReadyImpl(page)
}
}
export 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(),
}
})
}
export async function reloadActivityPageForPresentation(page, activityUrl) {
try {
await page.reload({ waitUntil: 'domcontentloaded' })
} catch {
await page.goto(activityUrl, { waitUntil: 'domcontentloaded' })
}
await page.waitForTimeout(2_500)
}
export async function extractQrState(
page,
loginType,
{
extractQqQrState: extractQqQrStateImpl = extractQqQrState,
extractWxQrState: extractWxQrStateImpl = extractWxQrState,
} = {},
) {
if (loginType === 'qq') {
return extractQqQrStateImpl(page)
}
return extractWxQrStateImpl(page)
}
+10 -157
View File
@@ -15,10 +15,7 @@ import {
resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase,
resolveDefaultViewport,
} from './session-browser-config.js'
import {
isRetryableQrCaptureError,
logBrowserSessionDebug,
} from './session-login-shared.js'
import { logBrowserSessionDebug } from './session-login-shared.js'
import {
loadCookieMapFromContext,
} from './session-cookie-map.js'
@@ -26,10 +23,12 @@ import {
buildSessionPayload as buildSessionPayloadBase,
} from './session-payload.js'
import {
captureQqQrImage,
ensureQqLoginReady,
extractQqQrState,
} from './session-qq.js'
captureSessionQr,
ensureLoginTab,
extractHostState,
extractQrState,
reloadActivityPageForPresentation,
} from './session-page.js'
import {
saveRedeemArtifacts,
} from './session-proof.js'
@@ -45,9 +44,7 @@ import {
submitRedeemInBrowser,
} from './session-redeem.js'
import {
captureWxQrImage,
ensureWxLoginReady,
extractWxQrState,
} from './session-wx.js'
import { recognizeTencentCaptcha } from './ocr.js'
import {
@@ -191,7 +188,7 @@ export async function reloadTencentBrowserSession(sessionId) {
}
try {
await reloadActivityPageForPresentation(session.page)
await reloadActivityPageForPresentation(session.page, ACTIVITY_URL)
const cookieMap = await loadCookieMapFromContext(session.browserContext)
const credentialReady = hasRedeemCredential(cookieMap)
@@ -278,7 +275,7 @@ export async function refreshTencentBrowserSession(sessionId, { includeQrImage =
if (!session.qrImageBase64 || session.status === 'expired') {
if (session.status === 'expired') {
await reloadActivityPageForPresentation(session.page)
await reloadActivityPageForPresentation(session.page, ACTIVITY_URL)
await ensureLoginTab(session.page, session.loginType)
}
@@ -462,109 +459,6 @@ async function getRequiredSession(sessionId) {
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)
Object.assign(session, resolveInitialQrSessionState(session.loginType))
@@ -594,29 +488,6 @@ function resetTencentSessionAutoClose(session) {
}
}
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 } = {},
@@ -638,7 +509,7 @@ async function ensureLoggedInPresentation(
}
session.presentationSyncAttempts += 1
await reloadActivityPageForPresentation(session.page)
await reloadActivityPageForPresentation(session.page, ACTIVITY_URL)
nextHostState = await extractHostState(session.page)
return {
@@ -647,24 +518,6 @@ async function ensureLoggedInPresentation(
}
}
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)
@@ -6,6 +6,10 @@ import {
parseViewportSpec,
} from './session-browser-config.js'
import { normalizeTencentCookieMap } from './session-cookie-map.js'
import {
captureQrImageWithRetry,
ensureLoginTab,
} from './session-page.js'
import {
buildArtifactsPayload,
buildRedeemPayload,
@@ -206,3 +210,86 @@ test('ensureReviewScreenshot writes screenshot state and skips unchanged signatu
assert.equal(await ensureReviewScreenshot(session, activityInfo), false)
assert.equal(screenshotCalls.length, 1)
})
test('ensureLoginTab clicks login UI and delegates to qq readiness', async () => {
const calls = []
const makeLocator = (name) => ({
async count() {
return 1
},
first() {
const firstLocator = {
async count() {
return 1
},
async click(options) {
calls.push({ type: 'click', name, options })
},
}
return firstLocator
},
})
const page = {
locator(selector) {
if (selector === '#unlogin') {
return makeLocator('loginButton')
}
if (selector === '.qc-tab') {
return makeLocator('qqTab')
}
throw new Error(`unexpected selector: ${selector}`)
},
async waitForTimeout(ms) {
calls.push({ type: 'wait', ms })
},
}
await ensureLoginTab(page, 'qq', {
async ensureQqLoginReady() {
calls.push({ type: 'ensure', name: 'qq' })
},
})
assert.deepEqual(calls, [
{ type: 'click', name: 'loginButton', options: { timeout: 2_000 } },
{ type: 'click', name: 'qqTab', options: { timeout: 2_000 } },
{ type: 'wait', ms: 250 },
{ type: 'ensure', name: 'qq' },
])
})
test('captureQrImageWithRetry retries once after retryable error', async () => {
const calls = []
const page = {
async waitForTimeout(ms) {
calls.push({ type: 'wait', ms })
},
}
let attempt = 0
await captureQrImageWithRetry(page, 'wx', '/tmp/wx-qr.png', {
async captureWxQrImage() {
attempt += 1
calls.push({ type: 'capture', attempt })
if (attempt === 1) {
throw new Error('Timeout while waiting for qr')
}
},
async ensureLoginTab(_page, loginType) {
calls.push({ type: 'ensureLoginTab', loginType })
},
isRetryableQrCaptureError(error) {
return String(error.message).includes('Timeout')
},
logBrowserSessionDebug() {},
})
assert.equal(attempt, 2)
assert.deepEqual(calls, [
{ type: 'capture', attempt: 1 },
{ type: 'wait', ms: 500 },
{ type: 'ensureLoginTab', loginType: 'wx' },
{ type: 'capture', attempt: 2 },
])
})