From a3628df2923fd8b9eb86ce5a3a92539a839fedd1 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 11:47:33 +0800 Subject: [PATCH] fix(agent): reject review-page spec entry misroutes (#166) --- android/app/build.gradle.kts | 4 +- .../automation/GoAutoAccessibilityService.kt | 3 + .../automation/PddProductDetailCollector.kt | 37 +++++--- .../automation/PurchaseRehearsalExecutor.kt | 55 ++++++++++-- .../agent/PddProductDetailCollectorTest.kt | 41 +++++++++ .../agent/PurchaseRehearsalExecutorTest.kt | 84 ++++++++++++++++++- docs/08-agent-api-contract.md | 15 +++- 7 files changed, 212 insertions(+), 27 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ea471c2..65ce03a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index e80de06..2ab8cbf 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -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( diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index cb29b02..2728c17 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -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, visible: List, config: PddCollectorConfig): SnapshotNode? { + private fun safeBottomSpecEntry(source: List, visible: List, 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> { it.second }.thenByDescending { it.first.bounds.centerX }) + .minWithOrNull(compareBy> { 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) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index 4a27de0..a48df8f 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -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): 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" diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index ba55acd..de1785f 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -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( diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 3afe718..588dadb 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -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 = mutableListOf(), private val openPddOnFailedClick: Boolean = false, private val sizeClickResults: MutableList = 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() + val clickedPaths = mutableListOf() 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 } diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index deacefa..09cc30b 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -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 @@ -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` | 无法确认订单是否创建,请人工检查 |