Files
order_site/apps/backend/src/services/session/session-redeem.js
T

568 lines
16 KiB
JavaScript

import fs from 'node:fs/promises'
export async function runTencentBrowserRedeem({
session,
code,
maxAttempts,
ensureLoggedInPresentation,
ensureActivityInfoReady,
fillRedeemCodeInBrowser,
capturePageCaptchaForOcr,
recognizeTencentCaptcha,
submitRedeemInBrowser,
isCaptchaRejectedResult,
refreshPageCaptcha,
persistSessionState,
buildSessionPayload,
saveRedeemArtifacts,
activityUrl,
}) {
session.status = 'redeeming'
session.notice = '正在识别验证码并提交兑换'
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
const attempts = []
let finalResult = null
try {
await ensureLoggedInPresentation(session, { credentialReady: true })
const activityInfo = await ensureActivityInfoReady(session.page, { triggerRender: true })
if (!activityInfo?.role?.ready) {
throw new Error('浏览器会话尚未拿到角色信息,请稍后重试')
}
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
await fillRedeemCodeInBrowser(session.page, code)
const captcha = await capturePageCaptchaForOcr(session.page, {
sessionDir: session.sessionDir,
attempt,
})
const ocr = await recognizeTencentCaptcha({
imageBase64: captcha.imageBuffer.toString('base64'),
imageExtension: captcha.imageExtension,
imageContentType: captcha.contentType,
finalUrl: captcha.imagePath,
saveSample: true,
tag: `browser-session-${session.sessionId}-attempt-${attempt}`,
})
if (ocr?.code !== 0) {
throw new Error(ocr?.msg || 'OCR 识别失败')
}
const verifyCode = String(ocr?.data?.text || ocr?.data?.recognizedText || '').trim()
if (!verifyCode) {
throw new Error('OCR 没有识别出验证码')
}
const redeem = await submitRedeemInBrowser(session.page, {
code,
verifyCode,
})
const classification = classifyTencentRedeemResult(redeem)
const attemptRecord = {
attempt,
verifyCode,
verifysession: captcha.verifysession,
ocrSample: ocr?.data?.saved || null,
redeem,
classification,
role: activityInfo.role,
}
attempts.push(attemptRecord)
finalResult = attemptRecord
if (!isCaptchaRejectedResult(redeem)) {
break
}
await dismissRedeemRetryPopup(session.page)
await refreshPageCaptcha(session.page, captcha.verifyImgId)
}
if (!finalResult) {
throw new Error('浏览器会话兑换没有拿到结果')
}
const artifacts = await saveRedeemArtifacts({
browserContext: session.browserContext,
page: session.page,
sessionDir: session.sessionDir,
sessionId: session.sessionId,
activityUrl,
code,
finalResult,
attempts,
})
session.lastRedeem = {
code,
area: finalResult?.role?.area || '',
attempts,
final: finalResult,
proofMode: artifacts.proofMode,
screenshotPath: artifacts.screenshotPath,
htmlPath: artifacts.htmlPath,
resultPath: artifacts.resultPath,
finishedAt: new Date().toISOString(),
}
session.status = 'redeemed'
session.notice = resolveTencentRedeemMessage(finalResult?.redeem)
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
return {
...buildSessionPayload(session),
redeem: session.lastRedeem,
}
} catch (error) {
session.status = 'failed'
session.notice = error instanceof Error ? error.message : '浏览器会话兑换失败'
session.lastError = session.notice
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
throw error
}
}
export async function capturePageCaptchaForOcr(page, { sessionDir, attempt, ensureActivityInfoReady }) {
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 })
if (!activityInfo?.form?.verifyImgId) {
throw new Error('页面里没有找到验证码图片节点')
}
const verifySelector = `#${activityInfo.form.verifyImgId}`
await page.waitForFunction(
(selector) => {
const element = document.querySelector(selector)
if (!(element instanceof HTMLImageElement)) {
return false
}
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
element.naturalWidth > 0
)
},
verifySelector,
{ timeout: 8_000 },
)
const imagePath = `${sessionDir}/captcha-attempt-${attempt}.png`
await page.locator(verifySelector).screenshot({ path: imagePath })
const verifysession = await page.evaluate(() => {
try {
if (window.Milo && typeof window.Milo.get === 'function') {
return String(window.Milo.get('verifysession') || '')
}
} catch {
// ignore Milo access failures
}
return ''
})
return {
imageBuffer: await fs.readFile(imagePath),
imagePath,
imageExtension: '.png',
contentType: 'image/png',
verifyImgId: activityInfo.form.verifyImgId,
verifysession,
}
}
export async function submitRedeemInBrowser(page, payload, { ensureActivityInfoReady }) {
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 })
if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) {
throw new Error('页面里没有找到兑换输入框或提交按钮')
}
const verifySelector = `#${activityInfo.form.verifyInputId}`
const submitSelector = `#${activityInfo.form.submitId}`
await setFormControlValue(page, verifySelector, payload.verifyCode)
await resetRedeemPopup(page)
await normalizeRedeemOverlay(page)
const responsePromise = page
.waitForResponse(
(response) =>
response.url().includes('dfm.ams.game.qq.com/ide/') &&
response.request().method().toUpperCase() === 'POST',
{ timeout: 10_000 },
)
.catch(() => null)
const popupPromise = waitForRedeemPopup(page).catch(() => null)
await clickRedeemSubmit(page, submitSelector)
const networkResponse = await responsePromise
const popup = await popupPromise
const parsedNetwork = networkResponse ? tryParseJson(await networkResponse.text()) : null
if (parsedNetwork && typeof parsedNetwork === 'object') {
return {
httpStatus: networkResponse.status(),
popup: popup || null,
...parsedNetwork,
}
}
if (!popup) {
throw new Error('页面内兑换没有等到结果弹窗或接口响应')
}
return {
httpStatus: networkResponse?.status?.() || 0,
iRet: inferRedeemCodeFromPopup(popup),
sMsg: popup.text || popup.detail || '兑换完成',
popup,
}
}
export function fillRedeemCodeInBrowser(page, code, { activityInfo }) {
const cdkeySelector = activityInfo?.form?.cdkeyInputId
? `#${activityInfo.form.cdkeyInputId}`
: '[id^="milo_cdkeyInfo_"]'
const verifySelector = activityInfo?.form?.verifyInputId
? `#${activityInfo.form.verifyInputId}`
: '[id^="milo_verifyInput_"]'
return Promise.all([
setFormControlValue(page, cdkeySelector, code),
setFormControlValue(page, verifySelector, ''),
])
}
export async function prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) {
const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 })
await fillRedeemCodeInBrowser(page, code, { activityInfo })
}
export async function refreshPageCaptcha(page, verifyImgId) {
if (!verifyImgId) {
return
}
const selector = `#${verifyImgId}`
const currentSrc = await page.locator(selector).getAttribute('src').catch(() => '')
await page.locator(selector).click({ timeout: 3_000 }).catch(() => null)
await page.waitForFunction(
({ selector: targetSelector, previousSrc }) => {
const element = document.querySelector(targetSelector)
if (!(element instanceof HTMLImageElement)) {
return false
}
return element.naturalWidth > 0 && String(element.getAttribute('src') || '') !== String(previousSrc || '')
},
{ selector, previousSrc: currentSrc || '' },
{ timeout: 5_000 },
).catch(() => null)
}
export function isCaptchaRejectedResult(result) {
if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') {
return true
}
return false
}
export function classifyTencentRedeemResult(result) {
const retCode = Number(result?.iRet)
const message = resolveTencentRedeemMessage(result)
const normalized = normalizeTencentRedeemText(message)
if (Number.isFinite(retCode) && retCode === -100) {
return buildTencentRedeemClassification('captcha_rejected', retCode, message)
}
if (/兑换码已使用|cdk已使用|cdkey已使用|已被使用/.test(normalized)) {
return buildTencentRedeemClassification('code_used', retCode, message || '兑换码已使用')
}
if (
retCode === -165 ||
retCode === -183 ||
/兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确|兑换码不存在|cdk不存在|cdkey不存在|不存在请您确认后输入/.test(normalized)
) {
return buildTencentRedeemClassification('code_invalid', retCode, message || '兑换码错误,请确认兑换码信息是否准确')
}
if (/验证码|校验码/.test(normalized)) {
return buildTencentRedeemClassification('captcha_rejected', retCode, message || '验证码错误,请稍后重试')
}
if (
retCode === 0 ||
/成功|已兑换|领取成功|恭喜您获得了礼包|查看邮件|到账/.test(normalized)
) {
return buildTencentRedeemClassification('success', retCode, message || '兑换成功')
}
return buildTencentRedeemClassification('failed', retCode, message || '兑换失败')
}
function resetRedeemPopup(page) {
return page.evaluate(() => {
const popup = document.querySelector('#pop2')
const popupText = document.querySelector('#PopText')
const popupDetail = document.querySelector('#PopText2')
if (popup instanceof HTMLElement) {
popup.style.display = 'none'
}
if (popupText instanceof HTMLElement) {
popupText.textContent = ''
}
if (popupDetail instanceof HTMLElement) {
popupDetail.classList.add('hide')
popupDetail.textContent = ''
}
})
}
async function dismissRedeemRetryPopup(page) {
await page.evaluate(() => {
try {
if (typeof window.closeDialog === 'function') {
window.closeDialog()
}
} catch {
// ignore page close hook failures
}
const closeButton = document.querySelector('#pop2 .pop_close')
if (closeButton instanceof HTMLElement) {
closeButton.click()
}
const popup = document.querySelector('#pop2')
if (popup instanceof HTMLElement) {
popup.style.display = 'none'
popup.style.visibility = 'hidden'
}
})
await normalizeRedeemOverlay(page)
await page.waitForTimeout(150)
}
async function normalizeRedeemOverlay(page) {
await page.evaluate(() => {
const overlayIds = ['_overlay_', 'overlay_mask', 'overlay']
for (const id of overlayIds) {
const element = document.getElementById(id)
if (!(element instanceof HTMLElement)) {
continue
}
element.style.pointerEvents = 'none'
element.style.display = 'none'
element.style.opacity = '0'
}
})
}
async function clickRedeemSubmit(page, submitSelector) {
const locator = page.locator(submitSelector)
try {
await locator.click({ timeout: 3_000 })
return
} catch {
await normalizeRedeemOverlay(page)
}
try {
await locator.click({ timeout: 3_000, force: true })
return
} catch {
await locator.evaluate((element) => {
if (element instanceof HTMLElement) {
element.click()
}
})
}
}
async function setFormControlValue(page, selector, value) {
const nextValue = String(value ?? '')
const appliedValue = await page.evaluate(
({ targetSelector, targetValue }) => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
element.getClientRects().length > 0
)
}
const matches = [...document.querySelectorAll(targetSelector)]
const element =
matches.find(
(node) =>
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
!node.disabled &&
node.type !== 'hidden' &&
isVisible(node),
) ||
matches.find(
(node) =>
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
!node.disabled &&
node.type !== 'hidden',
) ||
null
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
return {
found: false,
value: '',
}
}
const prototype =
element instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value')
if (descriptor?.set) {
descriptor.set.call(element, targetValue)
} else {
element.value = targetValue
}
element.setAttribute('value', targetValue)
element.focus()
element.dispatchEvent(new Event('input', { bubbles: true }))
element.dispatchEvent(new Event('change', { bubbles: true }))
element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' }))
element.blur()
return {
found: true,
value: String(element.value || ''),
}
},
{ targetSelector: selector, targetValue: nextValue },
)
if (!appliedValue?.found) {
throw new Error(`页面里没有找到可填写的表单节点: ${selector}`)
}
if (String(appliedValue.value || '') !== nextValue) {
throw new Error(`页面表单写值失败: ${selector}`)
}
}
async function waitForRedeemPopup(page) {
await page.waitForFunction(() => {
const popup = document.querySelector('#pop2')
const popupText = document.querySelector('#PopText')
if (!(popup instanceof HTMLElement) || !(popupText instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(popup)
return style.display !== 'none' && String(popupText.textContent || '').trim().length > 0
}, { timeout: 10_000 })
return page.evaluate(() => ({
visible: true,
text: String(document.querySelector('#PopText')?.textContent || '').trim(),
detail: String(document.querySelector('#PopText2')?.textContent || '').trim(),
}))
}
function tryParseJson(text) {
try {
return JSON.parse(text)
} catch {
return null
}
}
function inferRedeemCodeFromPopup(popup) {
const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim()
if (!text) {
return 1
}
if (/验证码|校验码/.test(text)) {
return -100
}
if (/成功|已兑换|领取成功/.test(text)) {
return 0
}
return 1
}
export function resolveTencentRedeemMessage(result) {
const candidates = [
result?.popup?.detail,
result?.popup?.text,
result?.sMsg,
result?.msg,
]
.map((item) => String(item || '').trim())
.filter(Boolean)
return candidates[0] || '兑换完成'
}
function buildTencentRedeemClassification(outcome, retCode, message) {
return {
outcome,
success: outcome === 'success',
retryWithSameCode: outcome === 'captcha_rejected',
retryWithReplacementCode: outcome === 'code_used' || outcome === 'code_invalid',
retCode: Number.isFinite(retCode) ? retCode : null,
message: String(message || '').trim(),
}
}
function normalizeTencentRedeemText(value) {
return String(value || '')
.replace(/\s+/g, '')
.trim()
.toLowerCase()
}