From fd013c634b827fb87885367aca04c19e646efce2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 3 Sep 2026 16:26:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E5=85=BC=E5=AE=B9=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=E8=A7=84=E6=A0=BC=E5=85=A5=E5=8F=A3=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../automation/PddProductDetailCollector.kt | 70 ++++++++++++++++--- .../automation/PurchaseRehearsalExecutor.kt | 16 ++++- .../agent/PddProductDetailCollectorTest.kt | 41 +++++++++++ .../agent/PurchaseRehearsalExecutorTest.kt | 23 +++++- 4 files changed, 136 insertions(+), 14 deletions(-) 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 7442d27..e00c891 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 @@ -123,6 +123,9 @@ data class ParsedPddScreen( val hasSelectionSummary: Boolean, val hasQuantityControls: Boolean, val hasOrderSubmitAction: Boolean, + val explicitSpecEntryCount: Int, + val nestedSpecEntryCount: Int, + val bottomPurchaseEntryCount: Int, val problem: PageProblem?, val sourceNodes: List, ) { @@ -322,18 +325,28 @@ object PddScreenParser { .filter { it.bounds.top < firstHeadingTop } .mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) } .firstOrNull() - val explicitSpecEntry = if (panelOpen) null else visible + val explicitSpecEntries = if (panelOpen) emptyList() else visible .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 candidateSpecEntry = explicitSpecEntry ?: bottomSpecEntry?.anchor - val candidateClickTarget = explicitSpecEntry ?: bottomSpecEntry?.clickTarget + val explicitSpecEntry = explicitSpecEntries.maxByOrNull { it.bounds.top } + val nestedSpecEntries = if (panelOpen || explicitSpecEntry != null) emptyList() else + safeNestedSpecEntries(visibleNodes, visible, config) + // A nested selection row is accepted only when it is the single safe + // candidate. This covers PDD layouts that split “请选择” and the + // dimension name across child nodes of one clickable parent, without + // turning arbitrary page text into a click target. + val nestedSpecEntry = nestedSpecEntries.singleOrNull() + val bottomSpecEntries = if (panelOpen || explicitSpecEntry != null || nestedSpecEntry != null) emptyList() else + safeBottomSpecEntries(visibleNodes, visible, config) + val bottomSpecEntry = bottomSpecEntries.singleOrNull() + val candidateSpecEntry = explicitSpecEntry ?: nestedSpecEntry?.anchor ?: bottomSpecEntry?.anchor + val candidateClickTarget = explicitSpecEntry ?: nestedSpecEntry?.clickTarget ?: 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" + nestedSpecEntry != null -> "nested_selection" bottomSpecEntry != null -> "bottom_purchase" else -> null } @@ -378,6 +391,9 @@ object PddScreenParser { hasSelectionSummary = hasSelectionSummary, hasQuantityControls = hasQuantityControls, hasOrderSubmitAction = hasOrderSubmitAction, + explicitSpecEntryCount = explicitSpecEntries.size, + nestedSpecEntryCount = nestedSpecEntries.size, + bottomPurchaseEntryCount = bottomSpecEntries.size, problem = problem, sourceNodes = visibleNodes, ) @@ -466,10 +482,40 @@ object PddScreenParser { return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::startsWith) } - 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 + private fun safeNestedSpecEntries(source: List, visible: List, config: PddCollectorConfig): List { + val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return emptyList() + if (screenHeight <= 0) return emptyList() + return source.asSequence() + .filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 } + // The fixed purchase bar begins at the lower fifth of the screen. + // A specs row has no reason to be inside that action-only zone. + .filter { it.bounds.centerY.toDouble() < screenHeight * 0.8 } + .mapNotNull { candidate -> + val context = (listOf(candidate.label) + descendants(candidate, source).map(SnapshotNode::label)) + .joinToString("") { it.replace(Regex("\\s+"), "") } + if (!isSpecEntryContext(context, config) || + nonConfigurableClickDenylist.any(context::contains) || + hasReviewContext(candidate, source, config) + ) return@mapNotNull null + val anchor = visible.firstOrNull { it.path == candidate.path } ?: candidate + SafeSpecEntry(anchor, candidate) + } + .distinctBy { it.clickTarget.path } + .toList() + } + + private fun isSpecEntryContext(compact: String, config: PddCollectorConfig): Boolean { + if (config.textAliases.review.entryAliases.any(compact::contains)) return false + val hasSpecWord = (config.colorAliases + config.sizeAliases + + config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases) + .any(compact::contains) + return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::contains) + } + + private fun safeBottomSpecEntries(source: List, visible: List, config: PddCollectorConfig): List { + val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return emptyList() + val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return emptyList() + if (screenWidth <= 0 || screenHeight <= 0) return emptyList() val byPath = source.associateBy(SnapshotNode::path) val normalizedByPath = visible.associateBy(SnapshotNode::path) val buyWords = config.textAliases.purchase.buyWords @@ -493,8 +539,10 @@ object PddScreenParser { 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.clickTarget.bounds.centerX }) - ?.first + .sortedWith(compareBy> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX }) + .map { it.first } + .distinctBy { it.clickTarget.path } + .toList() } private fun hasReviewContext(node: SnapshotNode, source: List, config: PddCollectorConfig): Boolean { 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 368aa37..5f2c5f0 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 @@ -279,8 +279,14 @@ class PurchaseRehearsalExecutor( val candidates = action.textAliases?.let { aliases -> safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) } } ?: safeCandidates - if (candidates.size > 1) return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一") - val target = candidates.singleOrNull()?.second ?: return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口") + if (candidates.size > 1) { + panelDiagnostic(specEntryEvidence(screen, candidates.size)) + return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一") + } + val target = candidates.singleOrNull()?.second ?: run { + panelDiagnostic(specEntryEvidence(screen, 0)) + return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口") + } val click = driver.clickFreshDetailed(target) when (click.result) { FreshActionResult.AMBIGUOUS -> return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一") @@ -303,6 +309,12 @@ class PurchaseRehearsalExecutor( "options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" + "orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}" + private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int): String = + "specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" + + "nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" + + "panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" + + "pageEvidence=${screen.pageEvidenceMatched}" + 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) }) 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 c235a92..f1d5fda 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 @@ -598,6 +598,47 @@ class PddProductDetailCollectorTest { assertEquals(2, parsed.panelOptionCount) } + @Test + fun nestedSelectionRowCombinesPrefixAndDimensionBeforeChoosingClickableParent() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("selection-row", "", 20, 720, 1060, 860, clickable = true), + node("selection-row/prefix", "请选择", 48, 750, 220, 810, parentPath = "selection-row"), + node("selection-row/dimension", "颜色分类", 240, 750, 480, 810, parentPath = "selection-row"), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertEquals("selection-row", parsed.specEntry?.path) + assertEquals("selection-row", parsed.specEntryClickTarget?.path) + assertEquals("nested_selection", parsed.specEntrySource) + assertEquals(1, parsed.nestedSpecEntryCount) + } + + @Test + fun nestedSelectionSemanticsNeverAcceptsOrderOrPaymentContainer() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("unsafe", "", 20, 720, 1060, 860, clickable = true), + node("unsafe/prefix", "请选择", 48, 750, 220, 810, parentPath = "unsafe"), + node("unsafe/dimension", "颜色", 240, 750, 480, 810, parentPath = "unsafe"), + node("unsafe/order", "提交订单", 700, 750, 1020, 810, parentPath = "unsafe"), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertEquals(null, parsed.specEntry) + assertEquals(0, parsed.nestedSpecEntryCount) + } + @Test fun genericQuantityAndBuyControlsDoNotProveQuickConfirmation() { 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 28393d9..e0a0eb8 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 @@ -442,6 +442,26 @@ class PurchaseRehearsalExecutorTest { assertFalse(outcome.message.orEmpty().contains("确认款式")) } + @Test + fun `missing spec entry emits scalar source counts without node text`() { + val diagnostics = mutableListOf() + val driver = FakePurchaseDriver(missingSpecEntry = true) + val outcome = PurchaseRehearsalExecutor( + driver, + { driver.browser = true; true }, + { null }, + pause = {}, + panelDiagnostic = diagnostics::add, + ).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode) + assertEquals( + "specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true", + diagnostics.single(), + ) + assertFalse(diagnostics.single().contains("选择规格")) + } + @Test fun `transient sold out page recovers before opening specs`() { val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true) @@ -733,6 +753,7 @@ class PurchaseRehearsalExecutorTest { private val priceCent: Long = 2_000, private val duplicateOpen: Boolean = false, private val bottomPurchaseEntry: Boolean = false, + private val missingSpecEntry: Boolean = false, private val includeReviewEntry: Boolean = false, private val openReviewOnBottomClick: Boolean = false, private val reviewBackSucceeds: Boolean = true, @@ -811,7 +832,7 @@ class PurchaseRehearsalExecutorTest { nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true) nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy") nodes += node("buy/label", "免拼购买", 780, 1840, 1040, 1910, parentPath = "buy") - } else { + } else if (!missingSpecEntry) { nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true) } if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)