优化了一些文件 增加多店铺
This commit is contained in:
@@ -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 sync,Docker 请重建 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user