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 c660fb9..0e270c6 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 @@ -186,7 +186,7 @@ object PddScreenParser { // purchase/order/payment controls must never become collection click targets. private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款") - fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, collectionContext: Boolean = false): ParsedPddScreen { + fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, collectionContext: Boolean = false, purchaseEntryContext: Boolean = false): ParsedPddScreen { val visibleNodes = snapshot.nodes.filter { it.visible } val visible = visibleNodes.mapNotNull { node -> val descendants = descendants(node, visibleNodes) @@ -333,6 +333,14 @@ object PddScreenParser { boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 && dimensions.size == 1 && dimensions.single().values.isNotEmpty() && hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction + // Only the executor that just activated a safe entry on this same + // product Activity can enable this first-view purchase exception. + val singleDimensionPurchasePanel = purchaseEntryContext && !collectionContext && + snapshot.packageName == PDD_PACKAGE && !snapshot.activityName.isNullOrBlank() && + goodsId.isNotBlank() && problem == null && !hasSelectionSummary && + boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 && + dimensions.size == 1 && dimensions.single().values.isNotEmpty() && + hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction // Some selected-spec panels omit both the "已选" prefix and a confirm // button. Keep the same structural evidence required for checkout. val specPanelType = when { @@ -340,6 +348,7 @@ object PddScreenParser { orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION structuredSelectionPanel -> SpecPanelType.NORMAL_SCROLLABLE singleDimensionCollectionPanel -> SpecPanelType.NORMAL_SCROLLABLE + singleDimensionPurchasePanel -> SpecPanelType.NORMAL_SCROLLABLE panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION // Some PDD builds expose the complete selector as non-scrollable 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 a8862d2..a640082 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 @@ -96,10 +96,12 @@ class PurchaseRehearsalExecutor( ) { private var purchasePanelContext: PurchasePanelContext? = null private var pageIdentity: Pair = null to null + private var purchaseEntryIdentity: Pair? = null private var entryRecoveryReason = "not_checked" fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set): PurchaseExecutionOutcome { purchasePanelContext = null + purchaseEntryIdentity = null validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it } var observedPrice: Long? = null var addressProof: ShippingAddressProof? = null @@ -380,6 +382,7 @@ class PurchaseRehearsalExecutor( val beforeSignature = specActionSignature(screen) val beforeIdentity = pageIdentity val click = driver.clickFreshDetailed(requireNotNull(target)) + purchaseEntryIdentity = if (click.result == FreshActionResult.SUCCESS) beforeIdentity else null panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}") when (click.result) { // The parser already narrowed to a single semantic candidate; the @@ -1205,7 +1208,9 @@ class PurchaseRehearsalExecutor( private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen { val snapshot = driver.capture() pageIdentity = snapshot.packageName to snapshot.activityName - val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext) + val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext, + purchaseEntryContext = purchaseEntryIdentity != null && purchaseEntryIdentity == pageIdentity) + if (purchaseEntryIdentity != pageIdentity) purchaseEntryIdentity = null purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) { screen.specPanelContainer?.let { PurchasePanelContext(it, normalizedTarget("color", input.mappedColor).orEmpty()) 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 3ca9d93..10a092e 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 @@ -27,6 +27,20 @@ import org.junit.Assert.assertTrue import org.junit.Test class PddProductDetailCollectorTest { + @Test fun `purchase entry can recognize one heading only with combined structure`() { + val snapshot = singleDimensionPanel() + assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, null).specPanelOpen) + assertEquals(SpecPanelType.NORMAL_SCROLLABLE, + PddScreenParser.parse(snapshot, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelType) + for (missing in listOf("close", "plus", "minus", "info/quantity", "payment", "submit", "scroll/color-title", "scroll/color")) { + val changed = snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing }) + assertFalse(missing, PddScreenParser.parse(changed, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen) + } + assertFalse(PddScreenParser.parse(snapshot.copy(packageName = "other"), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen) + assertFalse(PddScreenParser.parse(snapshot.copy(activityName = null), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen) + assertFalse(PddScreenParser.parse(snapshot, config(), "", null, purchaseEntryContext = true).specPanelOpen) + } + private fun singleDimensionPanel(): UiSnapshot { val snapshot = prefixlessPanel() return snapshot.copy(nodes = snapshot.nodes.filterNot {