568 lines
16 KiB
JavaScript
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()
|
|
}
|