后端迁移会话兑换服务
This commit is contained in:
+172
-23
@@ -1,7 +1,137 @@
|
||||
// @ts-nocheck
|
||||
/// <reference lib="dom" />
|
||||
|
||||
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({
|
||||
session,
|
||||
code,
|
||||
@@ -18,14 +148,14 @@ export async function runTencentBrowserRedeem({
|
||||
buildSessionPayload,
|
||||
saveRedeemArtifacts,
|
||||
activityUrl,
|
||||
}) {
|
||||
}: RunTencentBrowserRedeemOptions): Promise<JsonObject> {
|
||||
session.status = 'redeeming'
|
||||
session.notice = '正在识别验证码并提交兑换'
|
||||
session.updatedAt = new Date().toISOString()
|
||||
await persistSessionState(session)
|
||||
|
||||
const attempts = []
|
||||
let finalResult = null
|
||||
const attempts: RedeemAttemptRecord[] = []
|
||||
let finalResult: RedeemAttemptRecord | null = null
|
||||
|
||||
try {
|
||||
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 })
|
||||
|
||||
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 })
|
||||
|
||||
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
|
||||
? `#${activityInfo.form.cdkeyInputId}`
|
||||
: '[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 })
|
||||
await fillRedeemCodeInBrowser(page, code, { activityInfo })
|
||||
}
|
||||
|
||||
export async function refreshPageCaptcha(page, verifyImgId) {
|
||||
export async function refreshPageCaptcha(page: BrowserPageLike, verifyImgId?: string) {
|
||||
if (!verifyImgId) {
|
||||
return
|
||||
}
|
||||
@@ -280,7 +425,7 @@ export async function refreshPageCaptcha(page, verifyImgId) {
|
||||
).catch(() => null)
|
||||
}
|
||||
|
||||
export function isCaptchaRejectedResult(result) {
|
||||
export function isCaptchaRejectedResult(result: TencentRedeemResult): boolean {
|
||||
if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') {
|
||||
return true
|
||||
}
|
||||
@@ -288,7 +433,7 @@ export function isCaptchaRejectedResult(result) {
|
||||
return false
|
||||
}
|
||||
|
||||
export function classifyTencentRedeemResult(result) {
|
||||
export function classifyTencentRedeemResult(result: TencentRedeemResult): TencentRedeemClassification {
|
||||
const retCode = Number(result?.iRet)
|
||||
const message = resolveTencentRedeemMessage(result)
|
||||
const normalized = normalizeTencentRedeemText(message)
|
||||
@@ -323,7 +468,7 @@ export function classifyTencentRedeemResult(result) {
|
||||
return buildTencentRedeemClassification('failed', retCode, message || '兑换失败')
|
||||
}
|
||||
|
||||
function resetRedeemPopup(page) {
|
||||
function resetRedeemPopup(page: BrowserPageLike) {
|
||||
return page.evaluate(() => {
|
||||
const popup = document.querySelector('#pop2')
|
||||
const popupText = document.querySelector('#PopText')
|
||||
@@ -344,7 +489,7 @@ function resetRedeemPopup(page) {
|
||||
})
|
||||
}
|
||||
|
||||
async function dismissRedeemRetryPopup(page) {
|
||||
async function dismissRedeemRetryPopup(page: BrowserPageLike) {
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
if (typeof window.closeDialog === 'function') {
|
||||
@@ -372,7 +517,7 @@ async function dismissRedeemRetryPopup(page) {
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
|
||||
async function normalizeRedeemOverlay(page) {
|
||||
async function normalizeRedeemOverlay(page: BrowserPageLike) {
|
||||
await page.evaluate(() => {
|
||||
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)
|
||||
|
||||
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 appliedValue = await page.evaluate(
|
||||
({ targetSelector, targetValue }) => {
|
||||
const isVisible = (element) => {
|
||||
const isVisible = (element: Element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
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(() => {
|
||||
const popup = document.querySelector('#pop2')
|
||||
const popupText = document.querySelector('#PopText')
|
||||
@@ -511,7 +656,7 @@ async function waitForRedeemPopup(page) {
|
||||
}))
|
||||
}
|
||||
|
||||
function tryParseJson(text) {
|
||||
function tryParseJson(text: string): any {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
@@ -519,7 +664,7 @@ function tryParseJson(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function inferRedeemCodeFromPopup(popup) {
|
||||
function inferRedeemCodeFromPopup(popup: TencentRedeemPopup) {
|
||||
const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim()
|
||||
|
||||
if (!text) {
|
||||
@@ -537,7 +682,7 @@ function inferRedeemCodeFromPopup(popup) {
|
||||
return 1
|
||||
}
|
||||
|
||||
export function resolveTencentRedeemMessage(result) {
|
||||
export function resolveTencentRedeemMessage(result: TencentRedeemResult): string {
|
||||
const candidates = [
|
||||
result?.popup?.detail,
|
||||
result?.popup?.text,
|
||||
@@ -550,7 +695,11 @@ export function resolveTencentRedeemMessage(result) {
|
||||
return candidates[0] || '兑换完成'
|
||||
}
|
||||
|
||||
function buildTencentRedeemClassification(outcome, retCode, message) {
|
||||
function buildTencentRedeemClassification(
|
||||
outcome: TencentRedeemOutcome,
|
||||
retCode: number,
|
||||
message: string,
|
||||
): TencentRedeemClassification {
|
||||
return {
|
||||
outcome,
|
||||
success: outcome === 'success',
|
||||
@@ -561,7 +710,7 @@ function buildTencentRedeemClassification(outcome, retCode, message) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTencentRedeemText(value) {
|
||||
function normalizeTencentRedeemText(value: unknown): string {
|
||||
return String(value || '')
|
||||
.replace(/\s+/g, '')
|
||||
.trim()
|
||||
@@ -19,7 +19,7 @@
|
||||
"node_modules",
|
||||
"data",
|
||||
"src/**/*.test.js",
|
||||
"src/services/session/**",
|
||||
"src/services/session/**/*.js",
|
||||
"src/services/platforms/agiso/**/*.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -389,6 +389,14 @@
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- `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 个用例通过
|
||||
|
||||
## 下一步建议
|
||||
|
||||
|
||||
Reference in New Issue
Block a user