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() }