优化了一些文件 增加多店铺

This commit is contained in:
yml
2026-04-09 01:31:47 +08:00
parent c2ec3aa6d2
commit e4ab1f559e
43 changed files with 1839 additions and 91 deletions
+197
View File
@@ -0,0 +1,197 @@
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { runtimeConfig } from '../../config/runtime.js'
const DEFAULT_TIMEOUT_MS = 60_000
export async function recognizeTencentCaptcha(payload) {
return callOcrRequest('recognize', payload)
}
export async function batchRecognizeTencentCaptcha(payload) {
return callOcrRequest('batch', payload, { timeoutMs: 180_000 })
}
export async function warmupLocalOcrWorker() {
await runOcrCommand('healthcheck', { timeoutMs: 30_000 })
}
export async function closeLocalOcrWorker() {
// OCR 改为按次调用 Python 子进程,这里不再维护常驻 worker。
}
async function callOcrRequest(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const response = await runOcrCommand('request', {
timeoutMs,
input: JSON.stringify({
action,
payload: payload || {},
}),
})
if (!response || typeof response !== 'object' || Array.isArray(response)) {
throw new Error('本地 OCR 返回了无效响应')
}
return response
}
async function runOcrCommand(command, { timeoutMs = DEFAULT_TIMEOUT_MS, input = '' } = {}) {
const projectRoot = resolveOcrProjectRoot()
const workerCommand = resolveWorkerCommand(projectRoot)
const env = {
...process.env,
PYTHONUNBUFFERED: '1',
PYTHONPATH: buildPythonPath(path.join(projectRoot, 'src')),
}
return await new Promise((resolve, reject) => {
const child = spawn(workerCommand.command, [...workerCommand.args, command], {
cwd: projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
env,
})
let stdout = ''
let stderr = ''
let settled = false
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
child.kill()
}, timeoutMs)
child.stdout.on('data', (chunk) => {
stdout += String(chunk || '')
})
child.stderr.on('data', (chunk) => {
stderr += String(chunk || '')
})
child.on('error', (error) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
reject(buildWorkerError(error))
})
child.on('close', (code, signal) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
if (timedOut) {
reject(new Error(`本地 OCR 执行超时,请检查 ${projectRoot} 的 Python 依赖是否已经准备完成`))
return
}
if (signal || code !== 0) {
reject(
new Error(
buildWorkerExitMessage({
code,
signal,
projectRoot,
stderrText: stderr.trim(),
}),
),
)
return
}
if (command === 'healthcheck') {
resolve(null)
return
}
const parsed = tryParseJson(stdout.trim())
if (!parsed || typeof parsed !== 'object') {
reject(new Error(`本地 OCR 返回无法解析的结果${stderr.trim() ? `\n${stderr.trim()}` : ''}`))
return
}
resolve(parsed)
})
if (input) {
child.stdin.end(`${input}\n`, 'utf8')
return
}
child.stdin.end()
})
}
function resolveOcrProjectRoot() {
return String(runtimeConfig.ocr.projectRoot || '').trim()
}
function resolveWorkerCommand(projectRoot) {
const venvPython = path.join(projectRoot, '.venv', 'bin', 'python')
if (fs.existsSync(venvPython)) {
return {
command: venvPython,
args: ['-u', '-m', 'ocr_worker.cli'],
}
}
return {
command: 'python3',
args: ['-u', '-m', 'ocr_worker.cli'],
}
}
function buildPythonPath(srcPath) {
const current = String(process.env.PYTHONPATH || '').trim()
return current ? `${srcPath}${path.delimiter}${current}` : srcPath
}
function buildWorkerError(error) {
if (
error instanceof Error &&
(error.message.includes('spawn python3 ENOENT') ||
error.message.includes('spawn ') && error.message.includes('python'))
) {
const projectRoot = resolveOcrProjectRoot()
return new Error(
`无法启动本地 OCR。请检查 Python 是否可用,并确认 OCR_PROJECT_ROOT 指向的目录存在:${projectRoot}`,
)
}
return new Error(
`本地 OCR 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`,
)
}
function buildWorkerExitMessage({ code, signal, projectRoot, stderrText }) {
const reason = signal
? `signal ${signal}`
: typeof code === 'number'
? `exit code ${code}`
: 'unknown reason'
const installHint = `请检查 ${projectRoot} 的 Python 依赖是否已安装;本地源码运行请先执行 uv syncDocker 请重建 backend 镜像`
const stderrHint = stderrText ? `\n${stderrText}` : ''
return `本地 OCR 已退出(${reason})。${installHint}${stderrHint}`
}
function tryParseJson(text) {
try {
return JSON.parse(text)
} catch {
return null
}
}
@@ -0,0 +1,79 @@
import { runtimeConfig } from '../../config/runtime.js'
import { logDebug } from '../../utils/logger.js'
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
export function logBrowserSessionDebug(scope, detail) {
if (!SESSION_DEBUG_ENABLED) {
return
}
logDebug('[browser/session]', scope, detail)
}
export async function waitForFrame(page, predicate, timeoutMs = 15_000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const matched = page.frames().find((frame) => {
try {
return predicate(frame)
} catch {
return false
}
})
if (matched) {
return matched
}
await page.waitForTimeout(300)
}
throw new Error('未找到登录二维码 frame')
}
export async function downloadRemoteQrImage(page, qrUrl, qrImagePath, referer = '', loginType = 'unknown') {
try {
const response = await page.context().request.get(qrUrl, {
failOnStatusCode: false,
headers: {
referer,
},
})
if (!response.ok()) {
logBrowserSessionDebug(`${loginType}.capture.downloadNotOk`, {
qrUrl,
status: response.status(),
})
return false
}
const body = await response.body()
if (!body?.length) {
return false
}
return body
} catch (error) {
logBrowserSessionDebug(`${loginType}.capture.downloadError`, {
qrUrl,
message: error instanceof Error ? error.message : String(error),
})
return null
}
}
export function isRetryableQrCaptureError(error) {
const message = error instanceof Error ? error.message : String(error || '')
return (
message.includes('Frame was detached') ||
message.includes('Execution context was destroyed') ||
message.includes('Target page, context or browser has been closed') ||
message.includes('waiting for') ||
message.includes('Timeout')
)
}
@@ -0,0 +1,500 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { runtimeConfig } from '../../config/runtime.js'
const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4'
const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 }
const BEIJING_TIME_PROOF_CLIP = {
x: 0,
y: 0,
width: BEIJING_TIME_PROOF_VIEWPORT.width,
height: 620,
}
const DEFAULT_REDEEM_PROOF_MODE = 'full'
export async function saveRedeemArtifacts({
browserContext,
page,
sessionDir,
sessionId,
activityUrl,
code,
finalResult,
attempts,
}) {
return writeRedeemArtifactFiles({
browserContext,
page,
outputDir: sessionDir,
sessionId,
activityUrl,
code,
finalResult,
attempts,
proofMode: resolveRedeemProofMode(),
})
}
export async function writeRedeemArtifactFiles({
browserContext,
page,
outputDir,
sessionId = '',
activityUrl,
code,
finalResult,
attempts,
proofMode = DEFAULT_REDEEM_PROOF_MODE,
}) {
const effectiveProofMode = resolveRedeemProofMode(proofMode)
const {
redeemPageScreenshotPath,
beijingTimeScreenshotPath,
screenshotPath,
htmlPath,
resultPath,
} = buildArtifactPaths(outputDir)
if (effectiveProofMode === 'off') {
return {
proofMode: effectiveProofMode,
screenshotPath: '',
htmlPath: '',
resultPath: '',
}
}
await showResultDialog(page, finalResult.redeem?.sMsg || '兑换完成')
if (effectiveProofMode === 'basic') {
await page.screenshot({ path: screenshotPath, fullPage: true })
await writeRedeemResultFile(resultPath, {
proofMode: effectiveProofMode,
sessionId,
activityUrl,
code,
finalResult,
attempts,
screenshotPath,
})
return {
proofMode: effectiveProofMode,
screenshotPath,
htmlPath: '',
resultPath,
}
}
await page.screenshot({ path: redeemPageScreenshotPath, fullPage: true })
let beijingTimeProof = {
screenshotPath: '',
pageUrl: BAIDU_BEIJING_TIME_URL,
error: '',
}
try {
beijingTimeProof = await captureBeijingTimeProof(browserContext, {
screenshotPath: beijingTimeScreenshotPath,
})
await composeProofScreenshot(browserContext, {
outputPath: screenshotPath,
redeemImagePath: redeemPageScreenshotPath,
timeImagePath: beijingTimeProof.screenshotPath,
redeemMessage: String(finalResult.redeem?.sMsg || '兑换完成'),
timePageUrl: beijingTimeProof.pageUrl,
})
} catch (error) {
beijingTimeProof = {
screenshotPath: '',
pageUrl: BAIDU_BEIJING_TIME_URL,
error: error instanceof Error ? error.message : '北京时间截图生成失败',
}
await fs.copyFile(redeemPageScreenshotPath, screenshotPath)
}
await fs.writeFile(htmlPath, await page.content(), 'utf8')
await writeRedeemResultFile(resultPath, {
proofMode: effectiveProofMode,
sessionId,
activityUrl,
code,
finalResult,
attempts,
screenshotPath,
htmlPath,
redeemPageScreenshotPath,
beijingTimeScreenshotPath: beijingTimeProof.screenshotPath || '',
beijingTimePageUrl: beijingTimeProof.pageUrl,
beijingTimeError: beijingTimeProof.error || '',
})
return {
proofMode: effectiveProofMode,
screenshotPath,
htmlPath,
resultPath,
}
}
export function resolveRedeemProofMode(rawMode = runtimeConfig.redeem.proofMode) {
const normalized = String(rawMode || '')
.trim()
.toLowerCase()
if (normalized === 'basic' || normalized === 'off') {
return normalized
}
return DEFAULT_REDEEM_PROOF_MODE
}
function buildArtifactPaths(outputDir) {
return {
redeemPageScreenshotPath: path.join(outputDir, 'redeem-page.png'),
beijingTimeScreenshotPath: path.join(outputDir, 'beijing-time.png'),
screenshotPath: path.join(outputDir, 'redeem-result.png'),
htmlPath: path.join(outputDir, 'page.html'),
resultPath: path.join(outputDir, 'result.json'),
}
}
async function writeRedeemResultFile(
resultPath,
{
proofMode,
sessionId,
activityUrl,
code,
finalResult,
attempts,
screenshotPath,
htmlPath = '',
redeemPageScreenshotPath = '',
beijingTimeScreenshotPath = '',
beijingTimePageUrl = BAIDU_BEIJING_TIME_URL,
beijingTimeError = '',
},
) {
await fs.writeFile(
resultPath,
JSON.stringify(
{
proofMode,
sessionId,
activityUrl,
code,
area: finalResult?.role?.area || '',
final: finalResult,
attempts,
redeemPageScreenshotPath,
beijingTimeScreenshotPath,
beijingTimePageUrl,
beijingTimeError,
screenshotPath,
htmlPath,
},
null,
2,
),
'utf8',
)
}
async function showResultDialog(page, message) {
await page.evaluate((text) => {
document.querySelectorAll('iframe').forEach((element) => element.remove())
document.querySelectorAll('.pop').forEach((element) => {
element.style.display = 'none'
})
let card = document.getElementById('codex-redeem-result')
if (!card) {
card = document.createElement('div')
card.id = 'codex-redeem-result'
card.innerHTML = `
<div class="codex-redeem-result__eyebrow">Tencent Browser Session</div>
<div class="codex-redeem-result__title">兑换结果</div>
<div class="codex-redeem-result__message"></div>
`
document.body.appendChild(card)
}
const messageNode = card.querySelector('.codex-redeem-result__message')
if (messageNode) {
messageNode.textContent = text
}
Object.assign(card.style, {
position: 'fixed',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
zIndex: '99999',
width: 'min(560px, calc(100vw - 80px))',
padding: '32px 36px',
borderRadius: '24px',
background: 'rgba(10, 18, 28, 0.92)',
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)',
border: '1px solid rgba(109, 241, 202, 0.25)',
color: '#f4fbff',
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
})
const styleId = 'codex-redeem-result-style'
if (!document.getElementById(styleId)) {
const style = document.createElement('style')
style.id = styleId
style.textContent = `
#codex-redeem-result .codex-redeem-result__eyebrow {
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6df1ca;
margin-bottom: 12px;
}
#codex-redeem-result .codex-redeem-result__title {
font-size: 34px;
font-weight: 700;
margin-bottom: 16px;
}
#codex-redeem-result .codex-redeem-result__message {
font-size: 24px;
line-height: 1.5;
color: #ffffff;
}
`
document.head.appendChild(style)
}
window.scrollTo(0, 0)
}, message)
await page.waitForTimeout(600)
}
async function captureBeijingTimeProof(browserContext, { screenshotPath }) {
const proofPage = await browserContext.newPage()
try {
await proofPage.setViewportSize(BEIJING_TIME_PROOF_VIEWPORT)
await proofPage.goto(BAIDU_BEIJING_TIME_URL, { waitUntil: 'domcontentloaded' })
await proofPage.waitForTimeout(3_000)
await proofPage.evaluate(() => {
window.scrollTo(0, 0)
})
await proofPage.screenshot({
path: screenshotPath,
clip: BEIJING_TIME_PROOF_CLIP,
})
return {
screenshotPath,
pageUrl: BAIDU_BEIJING_TIME_URL,
}
} finally {
await proofPage.close().catch(() => null)
}
}
async function composeProofScreenshot(
browserContext,
{ outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl },
) {
const [redeemImageBase64, timeImageBase64] = await Promise.all([
fs.readFile(redeemImagePath, 'base64'),
fs.readFile(timeImagePath, 'base64'),
])
const composePage = await browserContext.newPage()
try {
await composePage.setViewportSize({ width: 1520, height: 1900 })
await composePage.setContent(
buildProofHtml({
redeemImageBase64,
timeImageBase64,
redeemMessage,
timePageUrl,
}),
{ waitUntil: 'domcontentloaded' },
)
await composePage.screenshot({ path: outputPath, fullPage: true })
} finally {
await composePage.close().catch(() => null)
}
}
function buildProofHtml({ redeemImageBase64, timeImageBase64, redeemMessage, timePageUrl }) {
const capturedAt = new Date().toLocaleString('zh-CN', {
hour12: false,
timeZone: 'Asia/Shanghai',
})
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>兑换与北京时间凭证</title>
<style>
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(33, 150, 243, 0.16), transparent 28%),
linear-gradient(180deg, #edf4ff 0%, #f6f8fc 52%, #eef1f7 100%);
color: #142033;
}
.sheet {
width: 1480px;
margin: 0 auto;
padding: 30px 24px 28px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-end;
padding: 0 6px 18px;
}
.eyebrow {
margin: 0 0 12px;
color: #1c78d0;
font-size: 13px;
letter-spacing: 0.2em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: 44px;
line-height: 1.08;
}
.summary {
margin: 14px 0 0;
font-size: 18px;
line-height: 1.7;
color: #52627a;
}
.meta {
min-width: 320px;
padding: 18px 20px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(20, 32, 51, 0.08);
box-shadow: 0 18px 48px rgba(29, 55, 90, 0.08);
}
.meta strong,
.meta span {
display: block;
}
.meta strong {
font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #60748d;
}
.meta span {
margin-top: 8px;
font-size: 20px;
color: #142033;
line-height: 1.5;
overflow-wrap: anywhere;
}
.card {
margin-top: 16px;
padding: 20px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(20, 32, 51, 0.08);
box-shadow: 0 24px 80px rgba(24, 41, 72, 0.1);
}
.card h2 {
margin: 0;
font-size: 30px;
}
.card p {
margin: 8px 0 0;
color: #5a6b83;
line-height: 1.65;
font-size: 16px;
}
.preview {
margin-top: 14px;
overflow: hidden;
border-radius: 20px;
border: 1px solid rgba(20, 32, 51, 0.08);
background: #dfe7f3;
}
.preview img {
display: block;
width: 100%;
height: auto;
}
.preview--time {
max-height: 500px;
}
.preview--time img {
object-fit: cover;
object-position: top center;
}
.url {
margin-top: 10px;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 14px;
color: #58708f;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<main class="sheet">
<section class="hero">
<div>
<p class="eyebrow">Tencent Redeem Proof</p>
<h1>兑换截图与北京时间截图</h1>
<p class="summary">用于留存兑换结果与北京时间检索页的组合凭证。</p>
</div>
<div class="meta">
<strong>Captured At</strong>
<span>${escapeHtml(capturedAt)}</span>
</div>
</section>
<section class="card">
<h2>兑换结果截图</h2>
<p>${escapeHtml(redeemMessage || '兑换完成')}</p>
<div class="preview">
<img src="data:image/png;base64,${redeemImageBase64}" alt="兑换结果截图" />
</div>
</section>
<section class="card">
<h2>百度检索“北京时间”截图</h2>
<p>单独标签页打开百度搜索结果并截图。</p>
<div class="url">${escapeHtml(timePageUrl)}</div>
<div class="preview preview--time">
<img src="data:image/png;base64,${timeImageBase64}" alt="北京时间截图" />
</div>
</section>
</main>
</body>
</html>`
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
@@ -0,0 +1,171 @@
import fs from 'node:fs/promises'
import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js'
const QQ_QR_TARGET_SIZE = 144
export async function ensureQqLoginReady(page) {
try {
const frame = await waitForFrame(
page,
(item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'),
10_000,
)
const switcher = frame.locator('#switcher_qlogin')
if (!(await switcher.count())) {
return
}
await switcher.click({ timeout: 3_000 }).catch(() => null)
await frame.waitForTimeout(250)
} catch {
// ignore qq inner tab switch failures; the screenshot retry path will try again
}
}
export async function captureQqQrImage(page, qrImagePath) {
const frame = await waitForFrame(
page,
(item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'),
10_000,
)
const qrUrl = await resolveQqQrImageUrl(frame)
const effectiveQrUrl = upgradeQqQrImageUrl(qrUrl)
logBrowserSessionDebug('qq.capture.qrUrl', {
qrUrl,
effectiveQrUrl,
frameUrl: frame.url(),
})
if (effectiveQrUrl) {
const body = await downloadRemoteQrImage(page, effectiveQrUrl, qrImagePath, frame.url(), 'qq')
logBrowserSessionDebug('qq.capture.downloadResult', {
qrUrl: effectiveQrUrl,
downloaded: Boolean(body?.length),
qrImagePath,
})
if (body?.length) {
await fs.writeFile(qrImagePath, body)
return
}
}
logBrowserSessionDebug('qq.capture.fallbackScreenshot', {
qrImagePath,
})
const qrLocator = await findQqQrLocator(page, frame)
try {
await qrLocator.screenshot({ path: qrImagePath })
return
} catch {
const iframeLocator = page.locator('#milo-qcwx-frame-qc').first()
await iframeLocator.waitFor({ state: 'visible', timeout: 15_000 })
await iframeLocator.screenshot({ path: qrImagePath })
}
}
export async function findQqQrLocator(page, frame = null) {
const effectiveFrame =
frame ||
await waitForFrame(page, (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'))
const locator = effectiveFrame.locator('#qrlogin_img')
await locator.waitFor({ state: 'visible', timeout: 15_000 })
logBrowserSessionDebug('qq.findQrLocator.visibleReady', {
frameUrl: effectiveFrame.url(),
})
return locator
}
export async function extractQqQrState(page) {
const frame = page.frames().find((item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'))
if (!frame) {
return {
visible: false,
scanned: false,
expired: false,
message: '',
}
}
try {
const qrVisible = await frame
.locator('#qrlogin_img')
.first()
.isVisible()
.catch(() => false)
const bodyText = String(await frame.locator('body').textContent()).replace(/\s+/g, ' ').trim()
return {
visible: true,
qrVisible,
scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText),
expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText),
message: bodyText.slice(0, 200),
frameUrl: frame.url(),
}
} catch {
return {
visible: true,
qrVisible: false,
scanned: false,
expired: false,
message: '',
frameUrl: frame.url(),
}
}
}
async function resolveQqQrImageUrl(frame) {
return frame
.evaluate(() => {
const qrImage = document.querySelector('#qrlogin_img')
if (!(qrImage instanceof HTMLImageElement)) {
return ''
}
return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim()
})
.then((src) => {
if (!src) {
return ''
}
try {
return new URL(src, frame.url()).toString()
} catch {
return ''
}
})
.catch(() => '')
}
function upgradeQqQrImageUrl(qrUrl) {
if (!qrUrl) {
return ''
}
try {
const url = new URL(qrUrl)
if (!/xui\.ptlogin2\.qq\.com$/i.test(url.hostname) || !/\/ptqrshow$/i.test(url.pathname)) {
return qrUrl
}
const currentSize = Number(url.searchParams.get('d') || 0)
if (!Number.isFinite(currentSize) || currentSize < QQ_QR_TARGET_SIZE) {
url.searchParams.set('d', String(QQ_QR_TARGET_SIZE))
}
return url.toString()
} catch {
return qrUrl
}
}
@@ -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
}
@@ -0,0 +1,326 @@
import fs from 'node:fs/promises'
import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js'
export async function ensureWxLoginReady(page) {
try {
const frame = await waitForFrame(
page,
(item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'),
10_000,
)
for (let attempt = 1; attempt <= 12; attempt += 1) {
const viewState = await inspectWxLoginView(frame)
logBrowserSessionDebug('wx.ensureQrMode.viewState', {
attempt,
frameUrl: frame.url(),
qrVisible: viewState.qrVisible,
quickLoginVisible: viewState.quickLoginVisible,
switchToNormalVisible: viewState.switchToNormalVisible,
qrCandidates: viewState.qrCandidates,
bodyText: viewState.bodyText.slice(0, 160),
})
if (viewState.qrVisible) {
return
}
if (
viewState.quickLoginVisible ||
viewState.switchToNormalVisible ||
/使用其他头像、昵称或账号|微信快捷登录/.test(viewState.bodyText)
) {
const switched = await switchWxQuickLoginToQr(frame)
logBrowserSessionDebug('wx.ensureQrMode.switchToNormal', {
attempt,
switched,
})
if (switched) {
await frame.waitForTimeout(700)
continue
}
}
await frame.waitForTimeout(400)
}
} catch {
// ignore wx quick-login switch failures; the screenshot retry path will try again
}
}
export async function captureWxQrImage(page, qrImagePath) {
const qrLocator = await findWxQrLocator(page)
const frame = await waitForFrame(
page,
(item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'),
10_000,
)
const qrUrl = await resolveWxQrImageUrl(frame)
logBrowserSessionDebug('wx.capture.qrUrl', {
qrUrl,
frameUrl: frame.url(),
})
if (qrUrl) {
const downloaded = await downloadRemoteQrImage(page, qrUrl, qrImagePath, frame.url(), 'wx')
logBrowserSessionDebug('wx.capture.downloadResult', {
qrUrl,
downloaded: Boolean(downloaded?.length),
qrImagePath,
})
if (downloaded?.length) {
await fs.writeFile(qrImagePath, downloaded)
return
}
}
logBrowserSessionDebug('wx.capture.fallbackScreenshot', {
qrImagePath,
})
await qrLocator.screenshot({ path: qrImagePath })
}
export async function findWxQrLocator(page) {
await ensureWxLoginReady(page)
const frame = await waitForFrame(
page,
(item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'),
)
const viewState = await inspectWxLoginView(frame)
logBrowserSessionDebug('wx.findQrLocator.beforeWait', {
frameUrl: frame.url(),
qrVisible: viewState.qrVisible,
qrCandidates: viewState.qrCandidates,
bodyText: viewState.bodyText.slice(0, 160),
})
const locator = frame.locator('.js_qrcode_img:visible').first()
await locator.waitFor({ state: 'visible', timeout: 15_000 })
logBrowserSessionDebug('wx.findQrLocator.visibleReady', {
frameUrl: frame.url(),
})
return locator
}
export async function extractWxQrState(page) {
const frame = page.frames().find((item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'))
if (!frame) {
return {
visible: false,
scanned: false,
expired: false,
message: '',
}
}
try {
const qrVisible = await frame
.locator('.js_qrcode_img')
.first()
.isVisible()
.catch(() => false)
const bodyText = String(await frame.locator('body').textContent()).replace(/\s+/g, ' ').trim()
return {
visible: true,
qrVisible,
scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText),
expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText),
message: bodyText.slice(0, 200),
frameUrl: frame.url(),
}
} catch {
return {
visible: true,
qrVisible: false,
scanned: false,
expired: false,
message: '',
frameUrl: frame.url(),
}
}
}
async function inspectWxLoginView(frame) {
return frame
.evaluate(() => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
const rect = element.getBoundingClientRect()
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
rect.width > 0 &&
rect.height > 0
)
}
const quickLogin = document.querySelector('.js_quick_login')
const switchToNormal = document.querySelector('.js_switchToNormal')
const qrCandidates = Array.from(document.querySelectorAll('.js_qrcode_img')).map((item, index) => {
if (!(item instanceof HTMLImageElement)) {
return {
index,
visible: false,
src: '',
naturalWidth: 0,
}
}
return {
index,
visible: isVisible(item),
src: String(item.currentSrc || item.src || item.getAttribute('src') || '').trim(),
naturalWidth: Number(item.naturalWidth || 0),
}
})
const visibleQrCandidate = qrCandidates.find((item) => item.visible && item.naturalWidth > 0)
return {
qrVisible: Boolean(visibleQrCandidate),
quickLoginVisible: isVisible(quickLogin),
switchToNormalVisible: isVisible(switchToNormal),
qrCandidates,
bodyText: String(document.body?.textContent || '').replace(/\s+/g, ' ').trim(),
}
})
.catch(() => ({
qrVisible: false,
quickLoginVisible: false,
switchToNormalVisible: false,
qrCandidates: [],
bodyText: '',
}))
}
async function switchWxQuickLoginToQr(frame) {
const switcherCandidates = [
frame.locator('.js_switchToNormal:visible').first(),
frame.locator('.js_switchToNormal').first(),
frame.locator('button:has-text("使用其他头像、昵称或账号")').first(),
frame.locator('text=使用其他头像、昵称或账号').first(),
]
for (const candidate of switcherCandidates) {
const visible = await candidate.isVisible().catch(() => false)
if (!visible) {
continue
}
logBrowserSessionDebug('wx.switchToNormal.candidateVisible', {
candidateIndex: switcherCandidates.indexOf(candidate),
})
const clicked =
(await candidate
.click({ timeout: 2_000 })
.then(() => true)
.catch(() => false)) ||
(await candidate
.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
element.click()
return true
})
.catch(() => false))
if (clicked) {
logBrowserSessionDebug('wx.switchToNormal.clicked', {
candidateIndex: switcherCandidates.indexOf(candidate),
})
return true
}
}
return frame
.evaluate(() => {
const candidates = Array.from(document.querySelectorAll('button, a, div')).filter((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const text = String(element.textContent || '').replace(/\s+/g, ' ').trim()
const style = window.getComputedStyle(element)
const rect = element.getBoundingClientRect()
return (
/使用其他头像、昵称或账号/.test(text) &&
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
rect.width > 0 &&
rect.height > 0
)
})
const target = candidates[0]
if (!(target instanceof HTMLElement)) {
return false
}
target.click()
return true
})
.catch(() => false)
}
async function resolveWxQrImageUrl(frame) {
return frame
.evaluate(() => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
const rect = element.getBoundingClientRect()
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
rect.width > 0 &&
rect.height > 0
)
}
const qrImages = Array.from(document.querySelectorAll('.js_qrcode_img'))
const qrImage =
qrImages.find((item) => item instanceof HTMLImageElement && isVisible(item)) ||
qrImages.find((item) => item instanceof HTMLImageElement) ||
null
if (!(qrImage instanceof HTMLImageElement)) {
return ''
}
return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim()
})
.then((src) => {
if (!src) {
return ''
}
try {
return new URL(src, frame.url()).toString()
} catch {
return ''
}
})
.catch(() => '')
}
File diff suppressed because it is too large Load Diff