Compare commits

...
3 Commits
Author SHA1 Message Date
fenghp d550d7fc71 feat: capture page DOM snapshot on step failure
步骤失败时除截图外,额外抓取目标页清洗+截断后的 DOM 一并写入失败 payload,
随步骤日志/任务结果上报后端(payload_json/result_json 均为 TEXT)。
DOM 经页内清洗(去 script/style/内联事件)后截断,并分片经 titleUpdate 取回,
规避不同机型 title 长度限制。便于远程定位“元素存在却检测不到”类问题。
2026-06-20 09:55:32 +08:00
fenghp dee5aa0821 feat: add JS bridge self-test before recharge steps
执行步骤前用不依赖页面 DOM 的探针(1+1 与 document.readyState)验证
evalJS titleUpdate 取值回路是否可用,结果写入本地步骤日志与后端充值日志。
便于远程区分“元素检测全部超时”是元素/命中测试问题还是取值桥失效,
不阻断主流程。
2026-06-20 09:45:00 +08:00
fenghp c30cd0d96d fix: relax WAIT_READY locator interactable check
WAIT_READY 仅作页面稳定门槛,原先默认要求元素可点击(elementFromPoint 命中测试),
在不同机型/屏幕尺寸下会因折叠线或命中到兄弟元素而误判,导致首步即超时。
改为 requireInteractable: false,只要 DOM 中存在“切换账号”文本即视为页面稳定。
2026-06-20 09:38:59 +08:00
4 changed files with 168 additions and 7 deletions
+2 -1
View File
@@ -11,7 +11,8 @@ export default [
code: 'WAIT_READY', code: 'WAIT_READY',
name: '等待页面稳定', name: '等待页面稳定',
action: 'wait', action: 'wait',
locator: { type: 'text', value: '切换账号' }, // 仅作页面稳定门槛,不要求元素可点击,避免不同机型 elementFromPoint 命中测试误判超时
locator: { type: 'text', value: '切换账号', requireInteractable: false },
waitTimeout: 12000, waitTimeout: 12000,
delay: 0 delay: 0
}, },
+56 -1
View File
@@ -124,7 +124,7 @@
<script> <script>
import appConfig from '../../config/index' import appConfig from '../../config/index'
import { openRechargeWebview, closeRechargeWebview, clearPlatformWebviewState } from '../../services/webview' import { openRechargeWebview, closeRechargeWebview, clearPlatformWebviewState } from '../../services/webview'
import { openWebview, closeWebview, waitForWebviewLoaded } from '../../services/webviewManager' import { openWebview, closeWebview, waitForWebviewLoaded, evalJSOnce } from '../../services/webviewManager'
import { startTask, reportTaskResult, reportTaskStep } from '../../api/task' import { startTask, reportTaskResult, reportTaskStep } from '../../api/task'
import { updateDeviceWorkStatus } from '../../api/device' import { updateDeviceWorkStatus } from '../../api/device'
// dryRun 由 appConfig.dryRun 控制,true 时跳过所有后端上报 // dryRun 由 appConfig.dryRun 控制,true 时跳过所有后端上报
@@ -349,6 +349,9 @@ export default {
return return
} }
// 步骤执行前先做一次 JS 桥自检,便于远程区分“元素问题”还是“取值桥失效”
await this.runBridgeSelfTest()
// 运行步骤引擎 // 运行步骤引擎
const context = { const context = {
amount: task.amount, amount: task.amount,
@@ -477,6 +480,58 @@ export default {
return String(failReason || '').indexOf('账号不存在') > -1 return String(failReason || '').indexOf('账号不存在') > -1
}, },
/**
* WebView JS 桥自检。
* 用不依赖页面 DOM 的探针验证 evalJS(titleUpdate 取值回路)是否可用,
* 结果同时写入本地步骤日志与后端充值日志,便于远程区分
* “元素检测全部超时”到底是元素/命中测试问题,还是取值桥本身失效。
* 仅记录日志,不阻断主流程。
*
* @returns {Promise<boolean>} 取值桥是否可用
*/
async runBridgeSelfTest() {
let ping = null
let readyState = null
try {
ping = await evalJSOnce('return "AF_PING_" + (1 + 1)', 3000)
readyState = await evalJSOnce('return document.readyState', 3000)
} catch (error) {
console.warn('[recharge] bridge self-test error:', error)
}
const bridgeOk = ping === 'AF_PING_2'
const message = bridgeOk
? `JS桥正常 (readyState=${readyState || 'unknown'})`
: `JS桥异常:未取到探针返回值 (ping=${JSON.stringify(ping)}, readyState=${JSON.stringify(readyState)})`
console.log('[recharge] bridge self-test:', message)
this.$store.commit('pushStepLog', {
stepCode: 'BRIDGE_SELFTEST',
stepName: 'JS桥自检',
stepStatus: bridgeOk ? 'SUCCESS' : 'FAILED',
message
})
if (appConfig.dryRun || !this.taskId) {
return bridgeOk
}
try {
await reportTaskStep({
taskId: this.taskId,
stepCode: 'BRIDGE_SELFTEST',
stepName: 'JS桥自检',
stepStatus: bridgeOk ? 'SUCCESS' : 'FAILED',
message
})
} catch (error) {
console.warn('[recharge] report bridge self-test failed:', error)
}
return bridgeOk
},
/** /**
* 对步骤引擎外的页面级失败做统一回传,保证加载失败也能带上截图。 * 对步骤引擎外的页面级失败做统一回传,保证加载失败也能带上截图。
* *
+33 -5
View File
@@ -8,6 +8,7 @@ import { executeAction, waitForElement } from './actionExecutor'
import { reportTaskStep, reportTaskResult } from '../api/task' import { reportTaskStep, reportTaskResult } from '../api/task'
import appConfig from '../config/index' import appConfig from '../config/index'
import { captureAndUploadErrorScreenshot } from './screenshot' import { captureAndUploadErrorScreenshot } from './screenshot'
import { captureDomSnapshot } from './webviewManager'
/** /**
* 步骤阶段映射。 * 步骤阶段映射。
@@ -152,7 +153,8 @@ export async function runStepEngine(options) {
stepCode: step.code, stepCode: step.code,
filePrefix: 'step_timeout' filePrefix: 'step_timeout'
}) })
const failPayload = buildStepFailurePayload(step, timeoutMsg, screenshotData, null, runtimeSnapshots) const domData = await captureDomSnapshotSafe()
const failPayload = buildStepFailurePayload(step, timeoutMsg, screenshotData, null, runtimeSnapshots, domData)
await reportStep(step.code, step.name, 'FAILED', timeoutMsg, shouldReportRemoteStep, failPayload) await reportStep(step.code, step.name, 'FAILED', timeoutMsg, shouldReportRemoteStep, failPayload)
if (onStepEnd) onStepEnd(i, step, { success: false, message: timeoutMsg }) if (onStepEnd) onStepEnd(i, step, { success: false, message: timeoutMsg })
await reportStageFailure(stageStatusMap, step, timeoutMsg, reportStep, shouldReportRemoteStep) await reportStageFailure(stageStatusMap, step, timeoutMsg, reportStep, shouldReportRemoteStep)
@@ -240,7 +242,8 @@ export async function runStepEngine(options) {
stepCode: step.code, stepCode: step.code,
filePrefix: 'step_failed' filePrefix: 'step_failed'
}) })
const failPayload = buildStepFailurePayload(step, result.message, screenshotData, result.payload, runtimeSnapshots) const domData = await captureDomSnapshotSafe()
const failPayload = buildStepFailurePayload(step, result.message, screenshotData, result.payload, runtimeSnapshots, domData)
await reportStep(step.code, step.name, 'FAILED', result.message, shouldReportRemoteStep, failPayload) await reportStep(step.code, step.name, 'FAILED', result.message, shouldReportRemoteStep, failPayload)
await reportStageFailure(stageStatusMap, step, result.message, reportStep, shouldReportRemoteStep) await reportStageFailure(stageStatusMap, step, result.message, reportStep, shouldReportRemoteStep)
const failReason = `步骤[${step.name || step.code}]失败: ${result.message}` const failReason = `步骤[${step.name || step.code}]失败: ${result.message}`
@@ -323,16 +326,31 @@ function sleep(ms) {
} }
/** /**
* 组装失败步骤的扩展上下文,保留原有 payload 并附带截图地址。 * 安全抓取页面 DOM 快照,失败不影响主流程上报。
*
* @returns {Promise<object|null>} DOM 快照结果
*/
async function captureDomSnapshotSafe() {
try {
return await captureDomSnapshot()
} catch (e) {
console.warn('[stepEngine] captureDomSnapshot error:', e)
return null
}
}
/**
* 组装失败步骤的扩展上下文,保留原有 payload 并附带截图地址与 DOM 快照。
* *
* @param {object} step 当前步骤 * @param {object} step 当前步骤
* @param {string} message 失败消息 * @param {string} message 失败消息
* @param {object|null} screenshotData 截图结果 * @param {object|null} screenshotData 截图结果
* @param {object|null} basePayload 原有步骤负载 * @param {object|null} basePayload 原有步骤负载
* @param {object} runtimeSnapshots 运行期账号快照 * @param {object} runtimeSnapshots 运行期账号快照
* @param {object|null} domData DOM 快照结果
* @returns {object} 上报负载 * @returns {object} 上报负载
*/ */
function buildStepFailurePayload(step, message, screenshotData, basePayload = null, runtimeSnapshots = {}) { function buildStepFailurePayload(step, message, screenshotData, basePayload = null, runtimeSnapshots = {}, domData = null) {
return { return {
...(basePayload || {}), ...(basePayload || {}),
stepCode: step.code, stepCode: step.code,
@@ -340,7 +358,17 @@ function buildStepFailurePayload(step, message, screenshotData, basePayload = nu
failMessage: message, failMessage: message,
douyinLoginAccount: runtimeSnapshots.DOUYIN_LOGIN || '', douyinLoginAccount: runtimeSnapshots.DOUYIN_LOGIN || '',
alipayLoginAccount: runtimeSnapshots.ALIPAY_ACCOUNT || '', alipayLoginAccount: runtimeSnapshots.ALIPAY_ACCOUNT || '',
...(screenshotData || {}) ...(screenshotData || {}),
...(domData && domData.ok
? {
domUrl: domData.url || '',
domTitle: domData.title || '',
domReadyState: domData.readyState || '',
domTruncated: !!domData.truncated,
domLength: domData.length || 0,
domHtml: domData.html || ''
}
: {})
} }
} }
+77
View File
@@ -372,6 +372,83 @@ export function evalJSOnce(script, timeout = 3000) {
}) })
} }
/**
* 抓取当前 WebView 页面 DOM 快照,用于运行异常时回传定位。
*
* 由于 native↔WebView 取值依赖 document.title,单次能携带的长度在不同机型上
* 限制不一,这里先在页内清洗+截断 HTML 存到 window,再分片取回拼装,避免超长丢失。
* 若取值桥本身不可用(如某些机型不发 titleUpdate),会返回 null,可据此与桥自检互相印证。
*
* @param {object} [options] 抓取参数
* @param {number} [options.maxLength=20000] 清洗后 HTML 的最大保留长度
* @param {number} [options.chunkSize=1500] 单次分片取回的字符数
* @returns {Promise<object|null>} { ok, url, title, readyState, truncated, length, html }
*/
export async function captureDomSnapshot(options = {}) {
if (!webviewInstance) {
return null
}
const maxLength = Number(options.maxLength || 20000)
const chunkSize = Number(options.chunkSize || 1500)
const prepScript = `
try {
var html = document.documentElement ? document.documentElement.outerHTML : '';
html = html
.replace(/<script[\\s\\S]*?<\\/script>/gi, '<script>[stripped]</script>')
.replace(/<style[\\s\\S]*?<\\/style>/gi, '<style>[stripped]</style>')
.replace(/ on[a-z]+="[^"]*"/gi, '');
var truncated = html.length > ${maxLength};
if (truncated) { html = html.slice(0, ${maxLength}); }
window.__afDom = html;
return {
ok: true,
total: window.__afDom.length,
truncated: truncated,
url: location.href,
title: document.title,
readyState: document.readyState
};
} catch (e) {
return { ok: false, message: String((e && e.message) || e) };
}
`
const meta = await evalJSOnce(prepScript, 4000)
if (!meta) {
return null
}
if (!meta.ok) {
return { ok: false, message: meta.message || 'DOM 抓取失败' }
}
let html = ''
for (let start = 0; start < meta.total; start += chunkSize) {
const sliceScript = `return (window.__afDom || '').slice(${start}, ${start + chunkSize});`
const part = await evalJSOnce(sliceScript, 3000)
if (typeof part !== 'string') {
break
}
html += part
if (part.length < chunkSize) {
break
}
}
evalJSSync('try { delete window.__afDom; } catch (e) { window.__afDom = null; }')
return {
ok: true,
url: meta.url || '',
title: meta.title || '',
readyState: meta.readyState || '',
truncated: !!meta.truncated,
length: html.length,
html
}
}
/** /**
* 在 WebView 中执行 JS(fire-and-forget),不等待返回。 * 在 WebView 中执行 JS(fire-and-forget),不等待返回。
* *