diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fb76f60..3164439 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 = 71 - versionName = "0.9.58" + versionCode = 72 + versionName = "0.9.59" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 9aefeee..867c878 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 @@ -99,6 +99,8 @@ object PddSoldOutRecoveryDefaults { const val SETTLE_MILLIS = 2_000L } +data class PurchasePanelContext(val container: SnapshotNode, val exactColor: String) + data class ParsedPddScreen( val summary: ProductSummary, val dimensions: List, @@ -129,6 +131,9 @@ data class ParsedPddScreen( val problem: PageProblem?, val sourceNodes: List, val isPddPackage: Boolean, + val hasCloseControl: Boolean = false, + val hasPaymentArea: Boolean = false, + val purchaseContextMatched: Boolean = false, ) { fun isTransientSoldOut( exactText: String, @@ -181,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?): ParsedPddScreen { + fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen { val visibleNodes = snapshot.nodes.filter { it.visible } val visible = visibleNodes.mapNotNull { node -> val descendants = descendants(node, visibleNodes) @@ -306,7 +311,16 @@ object PddScreenParser { // Opening evidence is intentionally independent from whether the current // viewport still exposes a clickable spec value. PDD may hide the only // selected value or restore a previously scrolled confirmation panel. - val structuredSelectionPanel = headedPanelScrollable != null && headings.size >= 2 && + val continuedPurchasePanel = snapshot.packageName == PDD_PACKAGE && problem == null && + purchaseContext != null && headedPanelScrollable != null && + headedPanelScrollable.path == purchaseContext.container.path && + headedPanelScrollable.className == purchaseContext.container.className && + kotlin.math.abs(headedPanelScrollable.bounds.left - purchaseContext.container.bounds.left) <= 32 && + kotlin.math.abs(headedPanelScrollable.bounds.right - purchaseContext.container.bounds.right) <= 32 && + minOf(headedPanelScrollable.bounds.bottom, purchaseContext.container.bounds.bottom) > + maxOf(headedPanelScrollable.bounds.top, purchaseContext.container.bounds.top) + val structuredSelectionPanel = headedPanelScrollable != null && + (headings.size >= 2 || (continuedPurchasePanel && headings.isNotEmpty())) && dimensions.any { it.values.isNotEmpty() } && hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction // Some selected-spec panels omit both the "已选" prefix and a confirm @@ -330,7 +344,8 @@ object PddScreenParser { val panelOpen = specPanelType != SpecPanelType.UNKNOWN val unprefixedSummary = if (structuredSelectionPanel) { val quantity = quantityInputs.single() - val knownColors = dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } + val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } + + listOfNotNull(purchaseContext?.exactColor?.takeIf { continuedPurchasePanel && it.isNotBlank() })).distinct() val byPath = visibleNodes.associateBy { it.path } var region = quantity.parentPath?.let(byPath::get) var summary: String? = null @@ -419,6 +434,9 @@ object PddScreenParser { panelHeadingCount = headings.size, panelOptionCount = dimensions.sumOf { it.values.size }, hasSelectionSummary = hasSelectionSummary, + hasCloseControl = hasClose, + hasPaymentArea = hasPaymentArea, + purchaseContextMatched = continuedPurchasePanel, hasQuantityControls = hasQuantityControls, hasOrderSubmitAction = hasOrderSubmitAction, explicitSpecEntryCount = explicitSpecEntries.size, 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 fd2bcb5..aa484fa 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 @@ -88,7 +88,10 @@ class PurchaseRehearsalExecutor( private val panelDiagnostic: (String) -> Unit = {}, private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") }, ) { + private var purchasePanelContext: PurchasePanelContext? = null + fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set): PurchaseExecutionOutcome { + purchasePanelContext = null validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it } var observedPrice: Long? = null var addressProof: ShippingAddressProof? = null @@ -231,6 +234,7 @@ class PurchaseRehearsalExecutor( } private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { + purchasePanelContext = null if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开") val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开") var clickAttempted = false @@ -796,7 +800,7 @@ class PurchaseRehearsalExecutor( */ private fun panelRecognitionFailure(screen: ParsedPddScreen, dimension: String): PurchaseExecutionOutcome = failure("RULE_NOT_MATCHED", - "未能识别当前商品规格面板 [dimension=$dimension;panel=${screen.specPanelType};headings=${screen.panelHeadingCount};options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};orderAction=${screen.hasOrderSubmitAction}]") + "未能识别当前商品规格面板 [dimension=$dimension;panel=${screen.specPanelType};headings=${screen.panelHeadingCount};options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};orderAction=${screen.hasOrderSubmitAction};close=${screen.hasCloseControl};payment=${screen.hasPaymentArea};contextMatched=${screen.purchaseContextMatched}]") private fun stableSelectionScreen(input: PurchaseExecutionInput): ParsedPddScreen { var screen = currentScreen(input) @@ -1121,8 +1125,16 @@ class PurchaseRehearsalExecutor( return normalized.takeIf(SpecValueNormalizer::isSafeSize) } - private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen = - PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null) + private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen { + val snapshot = driver.capture() + val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext) + purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) { + screen.specPanelContainer?.let { + PurchasePanelContext(it, normalizedTarget("color", input.mappedColor).orEmpty()) + } + } else null + return screen + } private fun pageProblem(snapshot: UiSnapshot): PurchaseExecutionOutcome? = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, snapshot.nodes.filter { it.visible }.map { it.label }) 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 c7670c6..a087ba0 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 @@ -11,6 +11,7 @@ import cn.ilapage.goauto.agent.automation.PddCollectorConfig import cn.ilapage.goauto.agent.automation.PddCollectorDriver import cn.ilapage.goauto.agent.automation.PddProductDetailCollector import cn.ilapage.goauto.agent.automation.PddScreenParser +import cn.ilapage.goauto.agent.automation.PurchasePanelContext import cn.ilapage.goauto.agent.automation.SnapshotNode import cn.ilapage.goauto.agent.automation.SpecPanelType import cn.ilapage.goauto.agent.automation.SwipeDirection @@ -65,6 +66,23 @@ class PddProductDetailCollectorTest { assertEquals(null, parsed.selectedSummary) } + @Test fun `scrolling away color heading and options preserves known purchase panel and summary`() { + val initial = prefixlessPanel() + val opened = PddScreenParser.parse(initial, config(), GOODS_ID, evidence()) + val context = PurchasePanelContext(requireNotNull(opened.specPanelContainer), "米白色(有里布)") + val scrolled = initial.copy(nodes = initial.nodes.filterNot { it.path.startsWith("scroll/color") }) + val continued = PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(), context) + assertEquals(1, continued.panelHeadingCount) + assertTrue(continued.specPanelOpen) + assertEquals("米白色(有里布) 2XL 建议131到150斤", continued.selectedSummary) + assertTrue(continued.dimensions.none { it.key == "color" }) + assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence()).specPanelOpen) + assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(), + context.copy(container = context.container.copy(path = "other"))).specPanelOpen) + assertFalse(PddScreenParser.parse(scrolled.copy(nodes = scrolled.nodes.filterNot { it.path == "submit" }), + config(), GOODS_ID, evidence(), context).specPanelOpen) + } + @Test fun `parser removes only trailing size price and keeps raw evidence`() { 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 f01f4d6..e6faabe 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 @@ -22,6 +22,15 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseRehearsalExecutorTest { + @Test + fun `color selection then single size heading completes without selecting color again`() { + val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals(outcome.message, "rehearsal_completed", outcome.resultType) + assertEquals(1, driver.clicked.count { it == "黑色" }) + assertEquals(1, driver.clicked.count { it == "XL" }) + } @Test fun `full exact summary confirms size after option leaves viewport`() { val target = "2XL 建议131到150斤" @@ -1208,6 +1217,7 @@ class PurchaseRehearsalExecutorTest { private val openReviewOnBottomClick: Boolean = false, private val reviewBackSucceeds: Boolean = true, private val hiddenSizeUntilUpSwipes: Int = 0, + private val prefixlessSingleHeadingAfterColor: Boolean = false, private val hideSizeAfterSelection: Boolean = false, private val revealGridSizeAfterUpSwipes: Int? = null, private val openClickResults: MutableList = mutableListOf(), @@ -1346,7 +1356,8 @@ class PurchaseRehearsalExecutorTest { node("panel-title", "确认款式", 20, 396, 300, 430), )) } - val hideColor = hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored + val singleHeading = prefixlessSingleHeadingAfterColor && color != null + val hideColor = singleHeading || (hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored) val hideSize = (hideSizeAfterQuantitySet && quantity == 2L) || (hideSizeAfterSelection && size != null) val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) { @@ -1360,7 +1371,14 @@ class PurchaseRehearsalExecutorTest { node("title", "确认款式", 20, 396, 300, 430), ) if (!nonScrollablePanel) nodes += node("scroll", "", 0, 400, 1080, 950, scrollable = true) - if (!hideSummary) { + if (singleHeading) { + nodes += node("info", "", 0, 300, 1080, 400) + nodes += node("info/summary", displayedSummary, 20, 320, 700, 350, parentPath = "info") + nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true) + nodes += node("minus", "减少数量", 300, 360, 380, 390, clickable = true) + nodes += node("plus", "增加数量", 620, 360, 700, 390, clickable = true) + nodes += node("payment", "微信支付", 600, 1000, 900, 1050) + } else if (!hideSummary) { nodes += node("selected", "已选 $displayedSummary", 20, 365, 700, 395) } if (!hideColor) { @@ -1416,9 +1434,10 @@ class PurchaseRehearsalExecutorTest { ) } } - nodes += node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText") - nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true) - nodes += node("order", "提交订单", 20, 1100, 500, 1180, clickable = true) + nodes += if (singleHeading) node("info/quantity", quantity.toString(), 400, 360, 600, 390, className = "android.widget.EditText", parentPath = "info") + else node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText") + if (!singleHeading) nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true) + nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, clickable = true) nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true) return UiSnapshot(PDD, ACTIVITY, nodes) }