fix(agent): reject review-page spec entry misroutes (#166)

This commit is contained in:
QiuSW
2026-08-31 11:47:33 +08:00
parent 09d9d2227a
commit a3628df292
7 changed files with 212 additions and 27 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 43
versionName = "0.9.30"
versionCode = 44
versionName = "0.9.31"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -295,6 +295,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (candidates.isEmpty()) return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
if (candidates.size != 1) return FreshClickOutcome(FreshActionResult.AMBIGUOUS, FreshClickReason.TARGET_AMBIGUOUS, candidates.size)
var node = candidates.single()
if (target.clickable && !node.isClickable) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND, candidates.size)
}
var ancestorDepth = 0
while (!node.isClickable) {
node = node.parent ?: return FreshClickOutcome(
@@ -107,6 +107,7 @@ data class ParsedPddScreen(
val specPanelOpen: Boolean,
val specPanelType: SpecPanelType,
val specEntry: SnapshotNode?,
val specEntryClickTarget: SnapshotNode?,
val specEntrySource: String?,
val quickConfirmationEntry: SnapshotNode?,
val reviewPageOpen: Boolean,
@@ -158,6 +159,7 @@ data class ParsedPddScreen(
}
object PddScreenParser {
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
private val reviewPattern = Regex("(?:商品评价\\s*[((]?\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?|[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*条?评价|评价\\s*[0-9]+)")
@@ -311,13 +313,17 @@ object PddScreenParser {
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes, config) }
.maxByOrNull { it.bounds.top }
val bottomSpecEntry = if (panelOpen || explicitSpecEntry != null) null else safeBottomSpecEntry(visibleNodes, visible, config)
val specEntry = explicitSpecEntry ?: bottomSpecEntry
val candidateSpecEntry = explicitSpecEntry ?: bottomSpecEntry?.anchor
val candidateClickTarget = explicitSpecEntry ?: bottomSpecEntry?.clickTarget
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, candidateSpecEntry, config)
val specEntry = candidateSpecEntry.takeUnless { reviewPageOpen }
val specEntryClickTarget = candidateClickTarget.takeUnless { reviewPageOpen }
val specEntrySource = when {
reviewPageOpen -> null
explicitSpecEntry != null -> "explicit_selection"
bottomSpecEntry != null -> "bottom_purchase"
else -> null
}
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, specEntry, config)
val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null
val rootAvailable = snapshot.packageName != null || snapshot.activityName != null || snapshot.nodes.isNotEmpty()
val packageMatched = evidence == null || snapshot.packageName == evidence.packageName
@@ -343,6 +349,7 @@ object PddScreenParser {
specPanelOpen = panelOpen,
specPanelType = specPanelType,
specEntry = specEntry,
specEntryClickTarget = specEntryClickTarget,
specEntrySource = specEntrySource,
quickConfirmationEntry = quickConfirmationEntry,
reviewPageOpen = reviewPageOpen,
@@ -446,7 +453,7 @@ object PddScreenParser {
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::startsWith)
}
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): SnapshotNode? {
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): SafeSpecEntry? {
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return null
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return null
if (screenWidth <= 0 || screenHeight <= 0) return null
@@ -460,15 +467,20 @@ object PddScreenParser {
var clickable: SnapshotNode? = labelNode
while (clickable != null && !clickable.clickable) clickable = clickable.parentPath?.let(byPath::get)
val clickTarget = clickable?.takeIf { it.visible && it.enabled } ?: return@mapNotNull null
if (hasReviewContext(labelNode, source, config) || hasReviewContext(clickTarget, source, config)) {
return@mapNotNull null
}
if (clickTarget.bounds.centerX.toDouble() < screenWidth * 0.4 ||
clickTarget.bounds.centerY.toDouble() < screenHeight * 0.8 ||
clickTarget.bounds.bottom.toDouble() < screenHeight * 0.88 ||
clickTarget.bounds.width.toDouble() < screenWidth * 0.08 ||
clickTarget.bounds.height.toDouble() > screenHeight * 0.3
) return@mapNotNull null
(normalizedByPath[labelNode.path] ?: labelNode) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height
val normalizedAnchor = normalizedByPath[labelNode.path] ?: labelNode
val normalizedTarget = normalizedByPath[clickTarget.path] ?: return@mapNotNull null
SafeSpecEntry(normalizedAnchor, normalizedTarget) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height
}
.minWithOrNull(compareBy<Pair<SnapshotNode, Long>> { it.second }.thenByDescending { it.first.bounds.centerX })
.minWithOrNull(compareBy<Pair<SafeSpecEntry, Long>> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX })
?.first
}
@@ -492,17 +504,22 @@ object PddScreenParser {
specEntry: SnapshotNode?,
config: PddCollectorConfig,
): Boolean {
if (screenHeight <= 0 || specEntry != null) return false
if (screenHeight <= 0) return false
val hasTopTitle = visible.any { node ->
node.label.replace(" ", "") in config.textAliases.review.entryAliases &&
node.bounds.centerY <= screenHeight * 25 / 100
}
if (!hasTopTitle) return false
val hasReviewContent = visible.any { node ->
val reviewSignals = visible.map { node ->
val compact = node.label.replace(" ", "")
compact in setOf("全部", "有图/视频", "追评", "最新") || compact.contains("条评价")
}
return hasReviewContent || source.none { node ->
when {
compact in setOf("全部", "有图/视频", "追评", "最新") -> compact
compact.contains("条评价") -> "count"
else -> null
}
}.filterNotNull().distinct()
if (reviewSignals.size >= 2) return true
return specEntry == null && source.none { node ->
node.visible && node.enabled && node.bounds.centerY >= screenHeight * 80 / 100 &&
config.textAliases.purchase.buyWords.any(node.label::contains)
}
@@ -252,30 +252,62 @@ class PurchaseRehearsalExecutor(
private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
var screen = currentScreen(input)
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
if (screen.specPanelOpen) return null
val safeCandidates = listOfNotNull(screen.specEntry, screen.quickConfirmationEntry)
.distinctBy(SnapshotNode::path)
val safeCandidates = listOfNotNull(
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
screen.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
// A rule alias may only narrow the semantic candidates already accepted
// by PddScreenParser. It must never turn into a raw-page text lookup:
// review cards and unrelated controls can share arbitrary labels.
val candidates = action.textAliases?.let { aliases ->
safeCandidates.filter { it.label in aliases }
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
} ?: safeCandidates
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "规格入口匹配到多个控件")
val target = candidates.singleOrNull() ?: return failure("RULE_NOT_MATCHED", "没有找到商品规格入口")
when (driver.clickFresh(target)) {
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "规格入口匹配到多个控件")
if (candidates.size > 1) return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
val target = candidates.singleOrNull()?.second ?: return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口")
val click = driver.clickFreshDetailed(target)
when (click.result) {
FreshActionResult.AMBIGUOUS -> return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
FreshActionResult.SUCCESS -> Unit
else -> return failure("RULE_ACTION_FAILED", "商品规格入口点击失败")
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
}
repeat(30) {
screen = currentScreen(input)
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
if (screen.specPanelOpen) return null
pause(100)
}
return failure("RULE_NOT_MATCHED", "商品规格面板没有打开")
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板")
}
private fun specEntryMatchesAliases(screen: ParsedPddScreen, candidate: SnapshotNode, aliases: List<String>): Boolean {
val prefix = "${candidate.path}/"
return (sequenceOf(candidate) + screen.sourceNodes.asSequence().filter { it.path.startsWith(prefix) })
.any { it.label in aliases }
}
private fun leaveUnexpectedReviewPage(input: PurchaseExecutionInput): PurchaseExecutionOutcome {
if (!driver.backPurchase()) return failure(SPEC_ENTRY_OPENED_REVIEW, "back_failed")
repeat(30) {
val returned = currentScreen(input)
if (!returned.reviewPageOpen && returned.problem == null && returned.hasPurchaseProductEvidence()) {
return failure(SPEC_ENTRY_OPENED_REVIEW, "returned_to_product")
}
pause(100)
}
return failure(SPEC_ENTRY_OPENED_REVIEW, "return_unconfirmed")
}
private fun FreshClickReason.specEntrySubreason(): String = when (this) {
FreshClickReason.ROOT_UNAVAILABLE -> "root_unavailable"
FreshClickReason.TARGET_NOT_FOUND -> "target_stale"
FreshClickReason.TARGET_AMBIGUOUS -> "target_ambiguous"
FreshClickReason.NO_CLICKABLE_ANCESTOR -> "no_clickable_ancestor"
FreshClickReason.ACTION_CLICK_FALSE -> "action_click_false"
else -> "unknown"
}
private fun selectSpecs(input: PurchaseExecutionInput, rule: PurchaseRule): PurchaseExecutionOutcome? {
@@ -518,6 +550,11 @@ class PurchaseRehearsalExecutor(
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
private const val SPEC_SELECTION_POLL_MILLIS = 100L
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
private const val SPEC_ENTRY_NOT_FOUND = "PURCHASE_SPEC_ENTRY_NOT_FOUND"
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
private const val SPEC_ENTRY_CLICK_FAILED = "PURCHASE_SPEC_ENTRY_CLICK_FAILED"
private const val SPEC_ENTRY_OPENED_REVIEW = "PURCHASE_SPEC_ENTRY_OPENED_REVIEW"
private const val SPEC_PANEL_NOT_OPENED = "PURCHASE_SPEC_PANEL_NOT_OPENED"
private const val SPEC_TARGET_NOT_VISIBLE = "PURCHASE_SPEC_TARGET_NOT_VISIBLE"
private const val SPEC_TARGET_AMBIGUOUS = "PURCHASE_SPEC_TARGET_AMBIGUOUS"
private const val SPEC_SAFE_TARGET_MISSING = "PURCHASE_SPEC_SAFE_TARGET_MISSING"
@@ -298,9 +298,50 @@ class PddProductDetailCollectorTest {
assertFalse(parsed.specPanelOpen)
assertEquals("buy/label", parsed.specEntry?.path)
assertEquals("buy", parsed.specEntryClickTarget?.path)
assertEquals("bottom_purchase", parsed.specEntrySource)
}
@Test
fun bottomPurchaseInsideReviewCardIsNeverSpecEntry() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("reviews", "", 500, 1750, 1080, 2180, clickable = true),
node("reviews/title", "商品评价", 520, 1770, 900, 1840, parentPath = "reviews"),
node("reviews/buy", "购买", 780, 2050, 1040, 2140, parentPath = "reviews"),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals(null, parsed.specEntry)
assertEquals(null, parsed.specEntrySource)
}
@Test
fun reviewPageWinsOverAFlatBottomPurchaseCandidate() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("review-title", "商品评价", 300, 40, 780, 120),
node("review-tab", "全部", 20, 180, 200, 250),
node("review-count", "238条评价", 20, 280, 400, 350),
node("recommend-buy", "购买", 760, 1900, 1040, 2140, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertTrue(parsed.reviewPageOpen)
assertEquals(null, parsed.specEntry)
assertEquals(null, parsed.specEntrySource)
}
@Test
fun reviewCardContainingSizeAndDigitsIsNeverSpecEntry() {
val snapshot = UiSnapshot(
@@ -382,10 +382,59 @@ class PurchaseRehearsalExecutorTest {
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertTrue(driver.clicked.contains("免拼购买"))
assertTrue(driver.clickedPaths.contains("buy"))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") })
}
@Test
fun `purchase returns once and fails explicitly when spec entry opens reviews`() {
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, openReviewOnBottomClick = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
assertEquals("PURCHASE_SPEC_ENTRY_OPENED_REVIEW", outcome.errorCode)
assertEquals("returned_to_product", outcome.message)
assertEquals(1, driver.backCount)
assertEquals(1, driver.clickedPaths.count { it == "buy" })
assertFalse(driver.panel)
}
@Test
fun `purchase reports stable spec entry click reasons`() {
val cases = listOf(
FreshClickReason.ROOT_UNAVAILABLE to "root_unavailable",
FreshClickReason.TARGET_NOT_FOUND to "target_stale",
FreshClickReason.NO_CLICKABLE_ANCESTOR to "no_clickable_ancestor",
FreshClickReason.ACTION_CLICK_FALSE to "action_click_false",
)
cases.forEach { (reason, expected) ->
val driver = FakePurchaseDriver(forcedEntryClickReason = reason)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
assertEquals(expected, outcome.message)
assertFalse(driver.panel)
}
}
@Test
fun `purchase stops when it cannot return from an unexpected review page`() {
val driver = FakePurchaseDriver(
bottomPurchaseEntry = true,
openReviewOnBottomClick = true,
reviewBackSucceeds = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_OPENED_REVIEW", outcome.errorCode)
assertEquals("back_failed", outcome.message)
assertEquals(1, driver.backCount)
assertEquals(1, driver.clickedPaths.count { it == "buy" })
}
@Test
fun `spec aliases cannot bypass semantic safe candidates`() {
val driver = FakePurchaseDriver(includeReviewEntry = true)
@@ -393,7 +442,7 @@ class PurchaseRehearsalExecutorTest {
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(unsafeRule), PurchaseAgentCapabilities.supported)
assertEquals("RULE_NOT_MATCHED", outcome.errorCode)
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertFalse(driver.clicked.contains("商品评价"))
}
@@ -484,11 +533,14 @@ class PurchaseRehearsalExecutorTest {
private val duplicateOpen: Boolean = false,
private val bottomPurchaseEntry: Boolean = false,
private val includeReviewEntry: Boolean = false,
private val openReviewOnBottomClick: Boolean = false,
private val reviewBackSucceeds: Boolean = true,
private val hiddenSizeUntilUpSwipes: Int = 0,
private val openClickResults: MutableList<FreshActionResult> = mutableListOf(),
private val openPddOnFailedClick: Boolean = false,
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
private val forcedSizeClickReason: FreshClickReason? = null,
private val forcedEntryClickReason: FreshClickReason? = null,
private val sizeSelectsOnFailedClick: Boolean = false,
private val selectedSizeSummaryOverride: String? = null,
private val hideSizeSelectedState: Boolean = false,
@@ -509,10 +561,13 @@ class PurchaseRehearsalExecutorTest {
var openClickCount = 0
var sizeClickCount = 0
var pullDownCount = 0
var backCount = 0
private var soldOut = soldOut
private var allSpecsUnavailable = allSpecsUnavailable
private var productEvidenceLost = false
private var reviewPage = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
override fun capture(): UiSnapshot {
if (browser && !panel && color == null && size == null) {
@@ -522,6 +577,14 @@ class PurchaseRehearsalExecutorTest {
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
}
if (!panel) {
if (reviewPage) {
return UiSnapshot(PDD, ACTIVITY, listOf(
node("content", "", 0, 0, 1080, 2200),
node("review-title", "商品评价", 300, 40, 780, 120),
node("review-tab", "全部", 20, 180, 200, 250),
node("review-count", "238条评价", 20, 280, 400, 350),
))
}
if (productEvidenceLost) {
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
}
@@ -581,6 +644,11 @@ class PurchaseRehearsalExecutorTest {
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
clickedPaths += target.path
if (target.path == "buy") {
if (openReviewOnBottomClick) reviewPage = true else panel = true
return FreshActionResult.SUCCESS
}
when (target.label) {
"打开" -> {
openClickCount++
@@ -603,6 +671,15 @@ class PurchaseRehearsalExecutorTest {
}
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
val result = when (forcedEntryClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS
FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED
FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS
}
return FreshClickOutcome(result, forcedEntryClickReason)
}
if (target.label in sizes && forcedSizeClickReason != null) {
sizeClickCount++
val result = when (forcedSizeClickReason) {
@@ -654,6 +731,9 @@ class PurchaseRehearsalExecutorTest {
}
override fun backPurchase(): Boolean {
backCount++
if (reviewPage && !reviewBackSucceeds) return false
if (reviewPage) reviewPage = false
panel = false
return true
}
+11 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 52119b8626496af10f232fb663595779427f37f0
synchronized_at: 2026-08-31T03:09:59Z
wiki_revision: 484a645058de780abe2ff2951afd25d38a03b74e
synchronized_at: 2026-08-31T03:41:42Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -405,7 +405,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `DEVICE_BUSY` | 设备已有活动任务 | 否 |
| `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 |
## 采购任务共享契约(#33、#34、#36、#42、#44、#53、#67、#164)
## 采购任务共享契约(#33、#34、#36、#42、#44、#53、#67、#164、#166)
本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口、Android 演练执行及 #36 正式地址/订单动作已经落地。#36 尚未获得真机创建订单授权和验收。当前项目没有自动支付动作、入口或测试;后续支付能力必须另行建单并通过显式能力位、服务端开关、金额上限和人工授权门禁。
@@ -532,7 +532,9 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
`openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `RULE_NOT_MATCHED`。
`openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `PURCHASE_SPEC_ENTRY_NOT_FOUND`。
底部购买入口同时保留语义文字锚点和已经验证的最近可点击卡片。评价文字出现在锚点、卡片、祖先或后代上下文时拒绝候选;fresh 点击必须绑定该卡片,卡片由可点击变为不可点击时按节点失效处理,不得继续向更宽父容器漂移。点击后只有识别到规格面板才算成功;若进入具有顶部评价标题及至少两项评价页信号的评价列表,Agent 最多执行一次系统返回,确认回到 PDD 商品页后以 `PURCHASE_SPEC_ENTRY_OPENED_REVIEW / returned_to_product` 失败并停止,不得自动再次点击同一入口。返回失败或无法确认分别使用稳定子原因 `back_failed` / `return_unconfirmed`。入口诊断不保存文字、坐标、控件树或截图。
Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attempt 和 Outbox;最终结果和 Outbox 在同一事务写入。演练执行器仍在安全点停止。Agent 0.2.0 另外声明 `purchase.live.v1`、`purchase.address-update.v1` 和 `purchase.order-create.v1`,仅在 `live` 规则同时包含受限 `updateShippingAddress`、`createOrder`、`readOrderResult` 动作时启用。
@@ -556,6 +558,11 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
| `PURCHASE_SPEC_CLICK_FAILED` | 精确目标点击失败;`errorMessage` 为稳定子原因 |
| `PURCHASE_SPEC_SELECTION_UNCONFIRMED` | 点击成功但无法确认精确选中状态 |
| `PURCHASE_SPEC_REPROBE_REJECTED` | 规格探测资格已使用,重复探测被拒绝 |
| `PURCHASE_SPEC_ENTRY_NOT_FOUND` | 没有找到安全且唯一的商品规格入口 |
| `PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS` | 规格入口候选不唯一 |
| `PURCHASE_SPEC_ENTRY_CLICK_FAILED` | 规格入口 fresh 点击失败;`errorMessage` 为稳定子原因 |
| `PURCHASE_SPEC_ENTRY_OPENED_REVIEW` | 规格入口误入评价页;`errorMessage` 记录一次安全返回结果 |
| `PURCHASE_SPEC_PANEL_NOT_OPENED` | 点击后未识别到商品规格面板 |
| `PURCHASE_PRICE_OUT_OF_RANGE` | 当前商品单价超出允许范围 |
| `PURCHASE_ADDRESS_UPDATE_FAILED` | 收货地址修改失败,未创建订单 |
| `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 |