后端迁移会话兑换服务

This commit is contained in:
yml
2026-05-21 15:13:22 +08:00
parent 0da7666aa0
commit 2360323581
3 changed files with 181 additions and 24 deletions
@@ -1,7 +1,137 @@
// @ts-nocheck /// <reference lib="dom" />
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
type JsonObject = Record<string, any>
type BrowserResponseLike = {
url: () => string
request: () => { method: () => string }
text: () => Promise<string>
status: () => number
}
type BrowserLocatorLike = {
screenshot: (options: { path: string }) => Promise<void>
getAttribute: (name: string) => Promise<string | null>
click: (options?: JsonObject) => Promise<void>
evaluate: <T = unknown>(callback: (element: Element) => T | Promise<T>) => Promise<T>
}
type BrowserPageLike = {
waitForFunction: (callback: any, argOrOptions?: any, options?: JsonObject) => Promise<unknown>
waitForResponse: (
predicate: (response: BrowserResponseLike) => boolean,
options?: JsonObject,
) => Promise<BrowserResponseLike>
waitForTimeout: (milliseconds: number) => Promise<void>
locator: (selector: string) => BrowserLocatorLike
evaluate: <T = any>(callback: any, arg?: any) => Promise<T>
}
type ActivityInfo = {
role?: JsonObject & {
ready?: boolean
area?: string
}
form?: {
cdkeyInputId?: string
verifyInputId?: string
verifyImgId?: string
submitId?: string
}
}
type TencentRedeemPopup = {
visible?: boolean
text?: string
detail?: string
}
type TencentRedeemResult = JsonObject & {
iRet?: number | string
sMsg?: string
msg?: string
popup?: TencentRedeemPopup | null
}
type TencentRedeemOutcome = 'success' | 'captcha_rejected' | 'code_used' | 'code_invalid' | 'failed'
type TencentRedeemClassification = {
outcome: TencentRedeemOutcome
success: boolean
retryWithSameCode: boolean
retryWithReplacementCode: boolean
retCode: number | null
message: string
}
type RedeemAttemptRecord = {
attempt: number
verifyCode: string
verifysession: string
ocrSample: unknown
redeem: TencentRedeemResult
classification: TencentRedeemClassification
role: ActivityInfo['role']
}
type TencentBrowserRedeemSession = JsonObject & {
status: string
notice: string
updatedAt: string
lastError?: string
lastRedeem?: JsonObject
page: BrowserPageLike
browserContext: unknown
sessionDir: string
sessionId: string
}
type RunTencentBrowserRedeemOptions = {
session: TencentBrowserRedeemSession
code: string
maxAttempts: number
ensureLoggedInPresentation: (session: TencentBrowserRedeemSession, options?: JsonObject) => Promise<unknown>
ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise<ActivityInfo>
fillRedeemCodeInBrowser: (page: BrowserPageLike, code: string, options?: JsonObject) => Promise<unknown>
capturePageCaptchaForOcr: (
page: BrowserPageLike,
options: { sessionDir: string; attempt: number },
) => Promise<{
imageBuffer: Buffer
imagePath: string
imageExtension: string
contentType: string
verifyImgId: string
verifysession: string
}>
recognizeTencentCaptcha: (payload: JsonObject) => Promise<JsonObject>
submitRedeemInBrowser: (
page: BrowserPageLike,
payload: { code: string; verifyCode: string },
) => Promise<TencentRedeemResult>
isCaptchaRejectedResult: (result: TencentRedeemResult) => boolean
refreshPageCaptcha: (page: BrowserPageLike, verifyImgId?: string) => Promise<void>
persistSessionState: (session: TencentBrowserRedeemSession) => Promise<unknown>
buildSessionPayload: (session: TencentBrowserRedeemSession) => JsonObject
saveRedeemArtifacts: (payload: JsonObject) => Promise<JsonObject>
activityUrl: string
}
type ActivityInfoProvider = {
ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise<ActivityInfo>
}
declare global {
interface Window {
Milo?: {
get?: (key: string) => unknown
}
closeDialog?: () => void
}
}
export async function runTencentBrowserRedeem({ export async function runTencentBrowserRedeem({
session, session,
code, code,
@@ -18,14 +148,14 @@ export async function runTencentBrowserRedeem({
buildSessionPayload, buildSessionPayload,
saveRedeemArtifacts, saveRedeemArtifacts,
activityUrl, activityUrl,
}) { }: RunTencentBrowserRedeemOptions): Promise<JsonObject> {
session.status = 'redeeming' session.status = 'redeeming'
session.notice = '正在识别验证码并提交兑换' session.notice = '正在识别验证码并提交兑换'
session.updatedAt = new Date().toISOString() session.updatedAt = new Date().toISOString()
await persistSessionState(session) await persistSessionState(session)
const attempts = [] const attempts: RedeemAttemptRecord[] = []
let finalResult = null let finalResult: RedeemAttemptRecord | null = null
try { try {
await ensureLoggedInPresentation(session, { credentialReady: true }) await ensureLoggedInPresentation(session, { credentialReady: true })
@@ -133,7 +263,10 @@ export async function runTencentBrowserRedeem({
} }
} }
export async function capturePageCaptchaForOcr(page, { sessionDir, attempt, ensureActivityInfoReady }) { export async function capturePageCaptchaForOcr(
page: BrowserPageLike,
{ sessionDir, attempt, ensureActivityInfoReady }: { sessionDir: string; attempt: number } & ActivityInfoProvider,
) {
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 }) const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 })
if (!activityInfo?.form?.verifyImgId) { if (!activityInfo?.form?.verifyImgId) {
@@ -187,7 +320,11 @@ export async function capturePageCaptchaForOcr(page, { sessionDir, attempt, ensu
} }
} }
export async function submitRedeemInBrowser(page, payload, { ensureActivityInfoReady }) { export async function submitRedeemInBrowser(
page: BrowserPageLike,
payload: { code: string; verifyCode: string },
{ ensureActivityInfoReady }: ActivityInfoProvider,
): Promise<TencentRedeemResult> {
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 }) const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 })
if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) { if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) {
@@ -237,7 +374,11 @@ export async function submitRedeemInBrowser(page, payload, { ensureActivityInfoR
} }
} }
export function fillRedeemCodeInBrowser(page, code, { activityInfo }) { export function fillRedeemCodeInBrowser(
page: BrowserPageLike,
code: string,
{ activityInfo }: { activityInfo?: ActivityInfo } = {},
) {
const cdkeySelector = activityInfo?.form?.cdkeyInputId const cdkeySelector = activityInfo?.form?.cdkeyInputId
? `#${activityInfo.form.cdkeyInputId}` ? `#${activityInfo.form.cdkeyInputId}`
: '[id^="milo_cdkeyInfo_"]' : '[id^="milo_cdkeyInfo_"]'
@@ -252,12 +393,16 @@ export function fillRedeemCodeInBrowser(page, code, { activityInfo }) {
]) ])
} }
export async function prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) { export async function prepareRedeemCodeFill(
page: BrowserPageLike,
code: string,
{ ensureActivityInfoReady }: ActivityInfoProvider,
) {
const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 }) const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 })
await fillRedeemCodeInBrowser(page, code, { activityInfo }) await fillRedeemCodeInBrowser(page, code, { activityInfo })
} }
export async function refreshPageCaptcha(page, verifyImgId) { export async function refreshPageCaptcha(page: BrowserPageLike, verifyImgId?: string) {
if (!verifyImgId) { if (!verifyImgId) {
return return
} }
@@ -280,7 +425,7 @@ export async function refreshPageCaptcha(page, verifyImgId) {
).catch(() => null) ).catch(() => null)
} }
export function isCaptchaRejectedResult(result) { export function isCaptchaRejectedResult(result: TencentRedeemResult): boolean {
if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') { if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') {
return true return true
} }
@@ -288,7 +433,7 @@ export function isCaptchaRejectedResult(result) {
return false return false
} }
export function classifyTencentRedeemResult(result) { export function classifyTencentRedeemResult(result: TencentRedeemResult): TencentRedeemClassification {
const retCode = Number(result?.iRet) const retCode = Number(result?.iRet)
const message = resolveTencentRedeemMessage(result) const message = resolveTencentRedeemMessage(result)
const normalized = normalizeTencentRedeemText(message) const normalized = normalizeTencentRedeemText(message)
@@ -323,7 +468,7 @@ export function classifyTencentRedeemResult(result) {
return buildTencentRedeemClassification('failed', retCode, message || '兑换失败') return buildTencentRedeemClassification('failed', retCode, message || '兑换失败')
} }
function resetRedeemPopup(page) { function resetRedeemPopup(page: BrowserPageLike) {
return page.evaluate(() => { return page.evaluate(() => {
const popup = document.querySelector('#pop2') const popup = document.querySelector('#pop2')
const popupText = document.querySelector('#PopText') const popupText = document.querySelector('#PopText')
@@ -344,7 +489,7 @@ function resetRedeemPopup(page) {
}) })
} }
async function dismissRedeemRetryPopup(page) { async function dismissRedeemRetryPopup(page: BrowserPageLike) {
await page.evaluate(() => { await page.evaluate(() => {
try { try {
if (typeof window.closeDialog === 'function') { if (typeof window.closeDialog === 'function') {
@@ -372,7 +517,7 @@ async function dismissRedeemRetryPopup(page) {
await page.waitForTimeout(150) await page.waitForTimeout(150)
} }
async function normalizeRedeemOverlay(page) { async function normalizeRedeemOverlay(page: BrowserPageLike) {
await page.evaluate(() => { await page.evaluate(() => {
const overlayIds = ['_overlay_', 'overlay_mask', 'overlay'] const overlayIds = ['_overlay_', 'overlay_mask', 'overlay']
@@ -390,7 +535,7 @@ async function normalizeRedeemOverlay(page) {
}) })
} }
async function clickRedeemSubmit(page, submitSelector) { async function clickRedeemSubmit(page: BrowserPageLike, submitSelector: string) {
const locator = page.locator(submitSelector) const locator = page.locator(submitSelector)
try { try {
@@ -412,12 +557,12 @@ async function clickRedeemSubmit(page, submitSelector) {
} }
} }
async function setFormControlValue(page, selector, value) { async function setFormControlValue(page: BrowserPageLike, selector: string, value: unknown) {
const nextValue = String(value ?? '') const nextValue = String(value ?? '')
const appliedValue = await page.evaluate( const appliedValue = await page.evaluate(
({ targetSelector, targetValue }) => { ({ targetSelector, targetValue }) => {
const isVisible = (element) => { const isVisible = (element: Element) => {
if (!(element instanceof HTMLElement)) { if (!(element instanceof HTMLElement)) {
return false return false
} }
@@ -491,7 +636,7 @@ async function setFormControlValue(page, selector, value) {
} }
} }
async function waitForRedeemPopup(page) { async function waitForRedeemPopup(page: BrowserPageLike): Promise<TencentRedeemPopup> {
await page.waitForFunction(() => { await page.waitForFunction(() => {
const popup = document.querySelector('#pop2') const popup = document.querySelector('#pop2')
const popupText = document.querySelector('#PopText') const popupText = document.querySelector('#PopText')
@@ -511,7 +656,7 @@ async function waitForRedeemPopup(page) {
})) }))
} }
function tryParseJson(text) { function tryParseJson(text: string): any {
try { try {
return JSON.parse(text) return JSON.parse(text)
} catch { } catch {
@@ -519,7 +664,7 @@ function tryParseJson(text) {
} }
} }
function inferRedeemCodeFromPopup(popup) { function inferRedeemCodeFromPopup(popup: TencentRedeemPopup) {
const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim() const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim()
if (!text) { if (!text) {
@@ -537,7 +682,7 @@ function inferRedeemCodeFromPopup(popup) {
return 1 return 1
} }
export function resolveTencentRedeemMessage(result) { export function resolveTencentRedeemMessage(result: TencentRedeemResult): string {
const candidates = [ const candidates = [
result?.popup?.detail, result?.popup?.detail,
result?.popup?.text, result?.popup?.text,
@@ -550,7 +695,11 @@ export function resolveTencentRedeemMessage(result) {
return candidates[0] || '兑换完成' return candidates[0] || '兑换完成'
} }
function buildTencentRedeemClassification(outcome, retCode, message) { function buildTencentRedeemClassification(
outcome: TencentRedeemOutcome,
retCode: number,
message: string,
): TencentRedeemClassification {
return { return {
outcome, outcome,
success: outcome === 'success', success: outcome === 'success',
@@ -561,7 +710,7 @@ function buildTencentRedeemClassification(outcome, retCode, message) {
} }
} }
function normalizeTencentRedeemText(value) { function normalizeTencentRedeemText(value: unknown): string {
return String(value || '') return String(value || '')
.replace(/\s+/g, '') .replace(/\s+/g, '')
.trim() .trim()
+1 -1
View File
@@ -19,7 +19,7 @@
"node_modules", "node_modules",
"data", "data",
"src/**/*.test.js", "src/**/*.test.js",
"src/services/session/**", "src/services/session/**/*.js",
"src/services/platforms/agiso/**/*.js" "src/services/platforms/agiso/**/*.js"
] ]
} }
@@ -389,6 +389,14 @@
- `npm run typecheck` - `npm run typecheck`
- `npm run build` - `npm run build`
- `npm test` 共 139 个用例通过 - `npm test` 共 139 个用例通过
71. 腾讯浏览器会话兑换服务迁移到 `.ts`
- `src/services/session/session-redeem.ts`
72. 会话兑换编排、验证码截图 / OCR payload、页面提交、弹窗结果解析、兑换结果分类已补轻量结构类型;`tsconfig.json` 已将 session 新增 `.ts` 文件纳入 `typecheck`,旧 session `.js` 文件继续暂时排除
73. Docker 内验证通过:
- `src/services/session/session-redeem.test.js` 共 6 个用例通过
- `npm run typecheck`
- `npm run build`
- `npm test` 共 139 个用例通过
## 下一步建议 ## 下一步建议