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 1959c22..7176f2f 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 @@ -166,12 +166,14 @@ object PddScreenParser { private val excludedOptionWords = listOf( "确定", "确认", "购买", "现在买", "下单", "订单", "支付", "付款", "已选", "请选择", "数量", "打开大图", "查看大图", "一次选多款", + "查看尺码建议", "查看尺寸建议", "尺码助手", "尺寸助手", "智能选码", "选码建议", "確定", "確認", "購買", "現在買", "下單", "訂單", "付款", "已選", "請選擇", "數量", "打開大圖", "查看大圖", "一次選多款", + "查看尺碼建議", "查看尺寸建議", "尺碼助手", "尺寸助手", "智能選碼", "選碼建議", ) private val excludedExactOptionLabels = setOf("客服", "联系客服", "聯繫客服", "店铺", "店鋪", "收藏", "关闭", "關閉") - // This denylist is intentionally not configurable: it prevents purchase, - // order, and payment controls from ever becoming collection click targets. + // These fixed guards are intentionally not configurable: non-spec navigation and + // 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 { @@ -268,7 +270,10 @@ object PddScreenParser { .filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower } .filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 } .filterNot { isExactHeadingLabel(it.label, config) } - .filterNot { node -> isExcludedOptionLabel(node.label) } + .filterNot { node -> + isExcludedOptionLabel(node.label) || + descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) } + } .map { val rawText = it.label val normalizedText = when (dimensionKey) { @@ -1542,11 +1547,19 @@ class PddProductDetailCollector( var observedPrice = false var selectionEvidence: String? = null var lastTrace: String? = null + var panelMissingReads = 0 val baseline = selectionSignature(beforeClick) while (now() <= (priceDeadline ?: selectionDeadline)) { val screen = parse(goodsId, config, evidence) screen.problem?.let { return StablePriceResult(null, it) } if (!screen.pageEvidenceMatched) return StablePriceResult(null, PageProblem("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")) + panelMissingReads = if (screen.specPanelOpen) 0 else panelMissingReads + 1 + if (panelMissingReads >= SPEC_PANEL_LOST_STABLE_READS) { + return StablePriceResult( + null, + PageProblem("SPEC_PANEL_EVIDENCE_NOT_MATCHED", "点击规格后离开了商品规格面板"), + ) + } val targetState = screen.dimensions.flatMap { it.values }.any { it.text == color && (it.node.selected || it.node.checked) } val targetSummary = screen.selectedSummary?.contains(color) == true val panelChanged = selectionSignature(screen) != baseline @@ -1667,6 +1680,7 @@ class PddProductDetailCollector( private companion object { const val PRICE_SETTLE_MILLIS = 300L + const val SPEC_PANEL_LOST_STABLE_READS = 2 const val COLOR_IMAGE_MAX_COUNT = 64 const val COLOR_IMAGE_MAX_TOTAL_BYTES = 8 * 1024 * 1024 } 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 ba924bf..adf1d07 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 @@ -749,6 +749,29 @@ class PddProductDetailCollectorTest { assertEquals(listOf("红色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text }) } + @Test + fun sizeAdviceNavigationCannotBecomeAColorValue() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("summary", "已选:", 20, 300, 800, 360), + node("title", "确认款式", 20, 370, 300, 410), + node("color-heading", "颜色分类", 20, 420, 300, 470), + node("red-container", "红色", 30, 500, 260, 580, clickable = true), + node("red-container/advice", "查看尺码建议", 40, 510, 250, 570, parentPath = "red-container"), + node("blue", "蓝色", 290, 500, 520, 580, clickable = true), + node("advice", "查看尺码建议", 30, 600, 360, 670, clickable = true), + node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertEquals(listOf("蓝色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text }) + } + @Test fun collectsEveryColorPriceAndAllSizesUsingFreshTrees() { val driver = FakeCollectorDriver( @@ -1253,6 +1276,23 @@ class PddProductDetailCollectorTest { assertEquals(listOf("红色"), driver.clickedLabels.distinct()) } + @Test + fun leavingSpecPanelAfterColorClickFailsBeforeSizeDiscovery() { + val driver = FakeCollectorDriver( + colors = listOf("红色"), + prices = mapOf("红色" to 1000L), + navigateAwayAfterColorClick = true, + ) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertFalse(result.successful) + assertEquals("SPEC_PANEL_EVIDENCE_NOT_MATCHED", result.code) + assertEquals(listOf("红色"), driver.clickedLabels) + assertTrue(driver.swipes.none { it.first == SwipeDirection.UP }) + } + @Test fun reviewMisrouteReturnsAndRetriesSpecEntryOnce() { val driver = FakeCollectorDriver(startWithPanelClosed = true, firstEntryOpensReview = true) @@ -1533,6 +1573,7 @@ class PddProductDetailCollectorTest { private val initialSelectedSize: String? = null, private val selectedSummaryPrefix: String = "已选", private val imageColorCards: Boolean = false, + private val navigateAwayAfterColorClick: Boolean = false, ) : PddCollectorDriver { var captureCount = 0 var clickCount = 0 @@ -1550,10 +1591,15 @@ class PddProductDetailCollectorTest { private var panelOpen = !startWithPanelClosed private var reviewOpen = false private var quickConfirmationRecovered = false + private var adviceOpen = false override fun capture(): UiSnapshot { captureCount++ fixedSnapshot?.let { return it } + if (adviceOpen) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("advice-title", "尺码建议", 20, 120, 400, 200), + )) if (pullDownCount < soldOutUntilPulls) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf( node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), node("sold-out", "商品已售罄", 200, 700, 880, 800), @@ -1710,6 +1756,7 @@ class PddProductDetailCollectorTest { previousSelected = selected selected = target.label priceRead = 0 + if (navigateAwayAfterColorClick) adviceOpen = true return FreshActionResult.SUCCESS }