优化了一些文件 增加多店铺
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
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 attemptRecord = {
|
||||
attempt,
|
||||
verifyCode,
|
||||
verifysession: captcha.verifysession,
|
||||
ocrSample: ocr?.data?.saved || null,
|
||||
redeem,
|
||||
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 = String(finalResult.redeem?.sMsg || '兑换完成')
|
||||
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) {
|
||||
const retCode = Number(result?.iRet)
|
||||
|
||||
if (Number.isFinite(retCode) && retCode === -100) {
|
||||
return true
|
||||
}
|
||||
|
||||
const text = [
|
||||
String(result?.sMsg || ''),
|
||||
String(result?.msg || ''),
|
||||
String(result?.popup?.text || ''),
|
||||
String(result?.popup?.detail || ''),
|
||||
]
|
||||
.join(' ')
|
||||
.trim()
|
||||
|
||||
return /验证码|校验码/.test(text)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user