拆分腾讯会话活动页采集与展示同步逻辑

This commit is contained in:
yml
2026-05-04 14:23:39 +08:00
parent 635e0138fe
commit 1aac738739
3 changed files with 326 additions and 213 deletions
@@ -0,0 +1,231 @@
// @ts-nocheck
import { extractHostState, reloadActivityPageForPresentation } from './session-page.js'
import { shouldRefreshLoggedInPresentation } from './session-state.js'
export function createEmptyActivityInfo() {
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: '',
},
}
}
export async function ensureLoggedInPresentation(
session,
{
hostState = null,
credentialReady = false,
activityUrl = '',
extractHostState: extractHostStateImpl = extractHostState,
reloadActivityPageForPresentation: reloadActivityPageForPresentationImpl = reloadActivityPageForPresentation,
shouldRefreshLoggedInPresentation: shouldRefreshLoggedInPresentationImpl = shouldRefreshLoggedInPresentation,
} = {},
) {
let nextHostState = hostState || (await extractHostStateImpl(session.page))
if (!credentialReady || !shouldRefreshLoggedInPresentationImpl(nextHostState)) {
return {
reloaded: false,
hostState: nextHostState,
}
}
if (session.presentationSyncAttempts >= 2) {
return {
reloaded: false,
hostState: nextHostState,
}
}
session.presentationSyncAttempts += 1
await reloadActivityPageForPresentationImpl(session.page, activityUrl)
nextHostState = await extractHostStateImpl(session.page)
return {
reloaded: true,
hostState: nextHostState,
}
}
export async function ensureActivityInfoReady(
page,
{
triggerRender = false,
timeoutMs = 8_000,
extractActivityInfo: extractActivityInfoImpl = extractActivityInfo,
triggerActivityRoleRender: triggerActivityRoleRenderImpl = triggerActivityRoleRender,
now = () => Date.now(),
} = {},
) {
if (triggerRender) {
await triggerActivityRoleRenderImpl(page)
}
const startedAt = now()
let lastInfo = await extractActivityInfoImpl(page)
while (now() - startedAt < timeoutMs) {
if (lastInfo?.role?.ready) {
return lastInfo
}
await page.waitForTimeout(400)
lastInfo = await extractActivityInfoImpl(page)
}
return lastInfo
}
export 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
}
}
export 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 createEmptyActivityInfo()
}
}
+10 -209
View File
@@ -19,6 +19,11 @@ import { logBrowserSessionDebug } from './session-login-shared.js'
import {
loadCookieMapFromContext,
} from './session-cookie-map.js'
import {
ensureActivityInfoReady,
ensureLoggedInPresentation,
extractActivityInfo,
} from './session-activity.js'
import {
buildSessionPayload as buildSessionPayloadBase,
} from './session-payload.js'
@@ -54,7 +59,6 @@ import {
resolveSessionStatus,
resolveTencentSessionExpiresAt,
SESSION_TTL_MS,
shouldRefreshLoggedInPresentation,
} from './session-state.js'
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
@@ -235,7 +239,11 @@ export async function refreshTencentBrowserSession(sessionId, { includeQrImage =
const credentialReady = hasRedeemCredential(cookieMap)
if (credentialReady) {
const presentation = await ensureLoggedInPresentation(session, { hostState, credentialReady })
const presentation = await ensureLoggedInPresentation(session, {
hostState,
credentialReady,
activityUrl: ACTIVITY_URL,
})
hostState = presentation.hostState
} else {
session.presentationSyncAttempts = 0
@@ -488,213 +496,6 @@ function resetTencentSessionAutoClose(session) {
}
}
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, ACTIVITY_URL)
nextHostState = await extractHostState(session.page)
return {
reloaded: true,
hostState: nextHostState,
}
}
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())
@@ -6,14 +6,17 @@ import {
parseViewportSpec,
} from './session-browser-config.js'
import { normalizeTencentCookieMap } from './session-cookie-map.js'
import {
createEmptyActivityInfo,
ensureActivityInfoReady,
ensureLoggedInPresentation,
extractActivityInfo,
} from './session-activity.js'
import {
captureQrImageWithRetry,
ensureLoginTab,
} from './session-page.js'
import {
buildArtifactsPayload,
buildRedeemPayload,
} from './session-payload.js'
import { buildArtifactsPayload, buildRedeemPayload } from './session-payload.js'
import { ensureReviewScreenshot } from './session-review.js'
import { resolveInitialQrSessionState } from './session-state.js'
import {
@@ -293,3 +296,81 @@ test('captureQrImageWithRetry retries once after retryable error', async () => {
{ type: 'capture', attempt: 2 },
])
})
test('ensureLoggedInPresentation reloads page when logged-in host state is stale', async () => {
const session = {
page: { marker: 'page' },
presentationSyncAttempts: 0,
}
const calls = []
let hostCall = 0
const result = await ensureLoggedInPresentation(session, {
credentialReady: true,
activityUrl: 'https://example.com/activity',
async extractHostState() {
hostCall += 1
return hostCall === 1
? { unloginVisible: true, loginedVisible: false, loginText: '' }
: { unloginVisible: false, loginedVisible: true, loginText: '已登录' }
},
async reloadActivityPageForPresentation(_page, activityUrl) {
calls.push(activityUrl)
},
})
assert.equal(session.presentationSyncAttempts, 1)
assert.deepEqual(calls, ['https://example.com/activity'])
assert.deepEqual(result, {
reloaded: true,
hostState: { unloginVisible: false, loginedVisible: true, loginText: '已登录' },
})
})
test('ensureActivityInfoReady triggers render and polls until role becomes ready', async () => {
const calls = []
const page = {
async waitForTimeout(ms) {
calls.push({ type: 'wait', ms })
},
}
const snapshots = [
{ role: { ready: false, roleName: '' } },
{ role: { ready: true, roleName: '测试角色' } },
]
let nowValue = 0
const result = await ensureActivityInfoReady(page, {
triggerRender: true,
timeoutMs: 1000,
async triggerActivityRoleRender() {
calls.push({ type: 'trigger' })
},
async extractActivityInfo() {
calls.push({ type: 'extract' })
return snapshots.shift()
},
now() {
nowValue += 300
return nowValue
},
})
assert.deepEqual(calls, [
{ type: 'trigger' },
{ type: 'extract' },
{ type: 'wait', ms: 400 },
{ type: 'extract' },
])
assert.deepEqual(result, { role: { ready: true, roleName: '测试角色' } })
})
test('extractActivityInfo falls back to empty structure when page evaluation fails', async () => {
const page = {
async evaluate() {
throw new Error('page closed')
},
}
assert.deepEqual(await extractActivityInfo(page), createEmptyActivityInfo())
})