From bc9c6375220c372e4ba053b06f3749310be37b75 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 17:29:24 +0800 Subject: [PATCH] fix(#113): restore hidden PDD spec headings --- android/app/build.gradle.kts | 4 +- .../automation/GoAutoAccessibilityService.kt | 35 +++- .../automation/PddProductDetailCollector.kt | 99 +++++++++- .../agent/persistence/AgentDiagnosticStore.kt | 5 + .../agent/PddProductDetailCollectorTest.kt | 183 +++++++++++++++++- docs/03-business-rules-and-glossary.md | 5 +- .../app/goauto/rulecontract/contract_test.go | 4 + server/app/goauto/rulecontract/template.go | 2 +- 8 files changed, 326 insertions(+), 11 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4fa8faf..49d90ac 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 = 22 - versionName = "0.9.9" + versionCode = 23 + versionName = "0.9.10" 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 087389c..4d9c000 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 @@ -379,6 +379,32 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return swipe(SemanticTarget.SPEC_PANEL, direction) } + override fun pullDownSpecPanel(anchor: SnapshotNode): Boolean { + if (!anchor.scrollable) return false + val root = rootInActiveWindow ?: return false + val matches = mutableListOf() + walk(root) { node -> + val bounds = Rect().also(node::getBoundsInScreen) + if ( + node.isVisibleToUser && node.isEnabled && node.isScrollable && + node.className?.toString() == anchor.className && + kotlin.math.abs(bounds.centerX() - anchor.bounds.centerX) <= 32 && + kotlin.math.abs(bounds.centerY() - anchor.bounds.centerY) <= 32 + ) matches += node + } + if (matches.size != 1) return false + val bounds = Rect().also(matches.single()::getBoundsInScreen) + Log.i("GoAutoCollector", "spec-panel-restore anchored=true bounds=$bounds class=${matches.single().className}") + return swipeNode( + matches.single(), + SwipeDirection.DOWN, + durationMs = 550, + preferScrollAction = false, + downStartPercent = 55, + downEndPercent = 88, + ) + } + override fun pullDownGoodsPage(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { Log.i("GoAutoCollector", "pull-down result=unsupported sdk=${Build.VERSION.SDK_INT}") @@ -440,6 +466,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto direction: SwipeDirection, durationMs: Long = 450, preferScrollAction: Boolean = true, + downStartPercent: Int = 25, + downEndPercent: Int = 75, ): Boolean { val bounds = Rect().also(node::getBoundsInScreen) if (bounds.width() < 2 || bounds.height() < 2) return false @@ -458,7 +486,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto val centerY = bounds.centerY() val points = when (direction) { SwipeDirection.UP -> listOf(centerX, bottom, centerX, top) - SwipeDirection.DOWN -> listOf(centerX, top, centerX, bottom) + SwipeDirection.DOWN -> listOf( + centerX, + bounds.top + bounds.height() * downStartPercent.coerceIn(1, 98) / 100, + centerX, + bounds.top + bounds.height() * downEndPercent.coerceIn(downStartPercent + 1, 99) / 100, + ) SwipeDirection.LEFT -> listOf(right, centerY, left, centerY) SwipeDirection.RIGHT -> listOf(left, centerY, right, centerY) } 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 515e01d..12a6fa7 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 @@ -65,13 +65,14 @@ interface PddCollectorDriver { reason = FreshClickReason.UNKNOWN, ) fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean + fun pullDownSpecPanel(anchor: SnapshotNode): Boolean = swipeSpec(SwipeDirection.DOWN, anchor) fun pullDownGoodsPage(): Boolean fun back(): Boolean } data class VisibleSpecValue(val text: String, val available: Boolean, val node: SnapshotNode) data class VisibleDimension(val key: String, val name: String, val values: List) -enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION } +enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION } data class ParsedPddScreen( val summary: ProductSummary, @@ -95,6 +96,7 @@ data class ParsedPddScreen( val panelOptionCount: Int, val hasSelectionSummary: Boolean, val hasQuantityControls: Boolean, + val hasOrderSubmitAction: Boolean, val problem: PageProblem?, val sourceNodes: List, ) { @@ -188,8 +190,25 @@ object PddScreenParser { } val hasClose = compactLabels.any { it in setOf("关闭", "關閉") } val quickConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea && hasQuickBuy && hasQuantityControls + val orderSubmitCandidates = visibleNodes.asSequence() + .filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 } + .filter { it.bounds.centerY.toDouble() >= screenHeight * 0.75 } + .filter { node -> + val compact = (listOf(node.label) + descendants(node, visibleNodes).map(SnapshotNode::label)) + .joinToString("") { it.replace(" ", "") } + compact.contains("提交订单") || compact.contains("提交訂單") + } + .toList() + .filter { candidate -> + orderSubmitCandidatesAncestorFree(candidate, visibleNodes) + } + val hasOrderSubmitAction = orderSubmitCandidates.size == 1 + val orderConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea && + hasQuantityControls && hasOrderSubmitAction && boundedScrollables.size == 1 val fallbackScrollable = boundedScrollables.singleOrNull().takeIf { - allHeadings.isEmpty() && hasSelectionSummary && hasPanelTitle && hasPanelAction + allHeadings.isEmpty() && ( + (hasSelectionSummary && hasPanelTitle && hasPanelAction) || orderConfirmationEvidence + ) } val panelScrollable = headedPanelScrollable ?: fallbackScrollable val panelVisible = panelScrollable?.let { region -> @@ -221,6 +240,7 @@ object PddScreenParser { // selected value or restore a previously scrolled confirmation panel. val specPanelType = when { quickConfirmationEvidence -> SpecPanelType.QUICK_CONFIRMATION + orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION else -> SpecPanelType.UNKNOWN @@ -281,6 +301,7 @@ object PddScreenParser { panelOptionCount = dimensions.sumOf { it.values.size }, hasSelectionSummary = hasSelectionSummary, hasQuantityControls = hasQuantityControls, + hasOrderSubmitAction = hasOrderSubmitAction, problem = problem, sourceNodes = visibleNodes, ) @@ -291,6 +312,16 @@ object PddScreenParser { return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) } } + private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List): Boolean { + val candidatePrefix = "${candidate.path}/" + return source.none { node -> + node.path.startsWith(candidatePrefix) && node.visible && node.enabled && node.clickable && + (listOf(node.label) + descendants(node, source).map(SnapshotNode::label)) + .joinToString("") { it.replace(" ", "") } + .let { it.contains("提交订单") || it.contains("提交訂單") } + } + } + private fun SnapshotNode.matches(selector: NodeSelector): Boolean = (selector.resourceId == null || resourceId == selector.resourceId) && (selector.text == null || text == selector.text) && @@ -588,6 +619,67 @@ class PddProductDetailCollector( reviewPage = screen break } + if (screen.pageEvidenceMatched && screen.specPanelType == SpecPanelType.ORDER_CONFIRMATION) { + val maxRestores = minOf(3, config.limits.getValue("specVerticalSwipes")) + var restored = screen + var restoreAttempts = 0 + var previousSignature = screenSignature(screen) + var stableReads = 0 + var restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_LIMIT + var restoreContainer = screen.specPanelContainer + if (restoreContainer == null) { + restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE + } else { + while (restoreAttempts < maxRestores) { + val currentContainer = restoreContainer + if (currentContainer == null) { + restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE + break + } + if (!driver.pullDownSpecPanel(currentContainer)) { + restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_GESTURE_FAILED + break + } + restoreAttempts++ + pause(350) + val refreshed = parse(goodsId, config, evidence) + panelAttempts++ + lastPanelScreen = refreshed + refreshed.problem?.let { return failure(it.code, it.message) } + if (!refreshed.pageEvidenceMatched) { + return failure("RULE_NOT_MATCHED", "规格面板回顶期间离开 PDD 商品详情页") + } + restored = refreshed + if (refreshed.dimensions.isNotEmpty()) { + restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_SUCCEEDED + break + } + restoreContainer = refreshed.specPanelContainer + if (restoreContainer == null) { + restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE + break + } + val signature = screenSignature(refreshed) + stableReads = if (signature == previousSignature) stableReads + 1 else 0 + previousSignature = signature + if (stableReads >= config.limits.getValue("stableEdgeReads")) { + restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_EDGE + break + } + } + } + recordSpecPanelEntry( + restoreReason, + restored, + restoreAttempts, + now() - panelStartedAt, + screenChanged, + restoreAttempts > 0, + restoreReason == AgentDiagnosticReason.SPEC_PANEL_RESTORE_SUCCEEDED, + ) + opened = if (restored.dimensions.isEmpty()) restored.copy(specPanelContainer = null) else restored + break + } if ( screen.pageEvidenceMatched && screen.specPanelType == SpecPanelType.QUICK_CONFIRMATION && @@ -991,7 +1083,8 @@ class PddProductDetailCollector( val shape = screen?.let { "type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" + "option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" + - "quantity=${it.hasQuantityControls.toInt()};changed=${screenChanged.toInt()};" + + "quantity=${it.hasQuantityControls.toInt()};submit=${it.hasOrderSubmitAction.toInt()};" + + "changed=${screenChanged.toInt()};" + "recovery=${when { recoverySucceeded -> "success" reason == AgentDiagnosticReason.SPEC_PANEL_RECOVERY_UNAVAILABLE -> "unavailable" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index 5676b57..91a15ae 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -23,6 +23,11 @@ enum class AgentDiagnosticReason { SPEC_PANEL_RECOVERED, SPEC_PANEL_RECOVERY_UNAVAILABLE, SPEC_PANEL_RECOVERY_FAILED, + SPEC_PANEL_RESTORE_SUCCEEDED, + SPEC_PANEL_RESTORE_EDGE, + SPEC_PANEL_RESTORE_LIMIT, + SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE, + SPEC_PANEL_RESTORE_GESTURE_FAILED, SPEC_PANEL_EVIDENCE_NOT_MATCHED, SPEC_ENTRY_CLICK_NO_EFFECT, SIZE_FOUND, 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 bcb2289..8f34036 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 @@ -335,6 +335,67 @@ class PddProductDetailCollectorTest { assertEquals(null, parsed.quickConfirmationEntry) } + @Test + fun orderConfirmationUsesReadOnlySubmitEvidenceAndUniqueRecyclerView() { + val snapshot = orderConfirmationSnapshot() + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertTrue(parsed.specPanelOpen) + assertEquals(SpecPanelType.ORDER_CONFIRMATION, parsed.specPanelType) + assertEquals("panel", parsed.specPanelContainer?.path) + assertTrue(parsed.dimensions.isEmpty()) + assertTrue(parsed.hasOrderSubmitAction) + assertEquals(null, parsed.quickConfirmationEntry) + } + + @Test + fun orderConfirmationRejectsAmbiguousScrollableContainers() { + val snapshot = orderConfirmationSnapshot().let { + it.copy( + nodes = it.nodes + node( + "secondary-panel", + "", + 0, + 700, + 1080, + 1300, + scrollable = true, + className = "androidx.recyclerview.widget.RecyclerView", + ), + ) + } + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertFalse(parsed.specPanelOpen) + assertEquals(SpecPanelType.UNKNOWN, parsed.specPanelType) + assertEquals(null, parsed.specPanelContainer) + } + + @Test + fun referenceCategoryIsParsedAsSizeAfterPanelRestore() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("summary", "已选:绿色 100", 20, 300, 700, 360), + node("title", "确认款式", 20, 370, 300, 420), + node("panel", "", 0, 430, 1080, 1800, scrollable = true), + node("panel/color-heading", "颜色分类", 20, 500, 300, 550, parentPath = "panel"), + node("panel/green", "绿色", 30, 580, 260, 650, clickable = true, parentPath = "panel"), + node("panel/size-heading", "参考分类", 20, 720, 300, 770, parentPath = "panel"), + node("panel/100", "100 建议身高90-100", 30, 800, 360, 870, clickable = true, parentPath = "panel"), + node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertEquals(listOf("100 建议身高90-100"), parsed.dimensions.single { it.key == "size" }.values.map { it.text }) + } + @Test fun boundedScrollablePanelUsesHeadingsInsideItsOwnRegion() { val snapshot = UiSnapshot( @@ -825,6 +886,92 @@ class PddProductDetailCollectorTest { assertTrue(event.targetClassName?.contains("recovery=success") == true) } + @Test + fun orderConfirmationUsesAtMostThreePanelGesturesAndFindsReferenceCategory() { + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("绿色"), + sizes = listOf("100 建议身高90-100"), + startWithPanelClosed = true, + orderConfirmationAfterEntry = true, + orderRestorePagesBeforeSpecs = 2, + sizeHeadingLabel = "参考分类", + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 82, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertEquals(2, driver.restoreGestures) + assertEquals(listOf("100 建议身高90-100"), result.payload?.dimensions?.single { it.key == "size" }?.values) + assertFalse(driver.clickedLabels.any { it.contains("已选") || it.contains("提交订单") || it.contains("支付") }) + assertEquals( + AgentDiagnosticReason.SPEC_PANEL_RESTORE_SUCCEEDED, + events.single { it.stage == AgentDiagnosticStage.SPEC_PANEL_ENTRY }.reason, + ) + } + + @Test + fun orderConfirmationStopsAfterThreeChangingViewsWithoutGuessingActions() { + val events = mutableListOf() + val driver = FakeCollectorDriver( + startWithPanelClosed = true, + orderConfirmationAfterEntry = true, + orderRestorePagesBeforeSpecs = 10, + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 83, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertEquals(3, driver.restoreGestures) + assertEquals(listOf("单独购买"), driver.clickedLabels) + assertEquals( + AgentDiagnosticReason.SPEC_PANEL_RESTORE_LIMIT, + events.single { it.stage == AgentDiagnosticStage.SPEC_PANEL_ENTRY }.reason, + ) + } + + @Test + fun orderConfirmationStopsWhenBoundedRestoreGestureFails() { + val events = mutableListOf() + val driver = FakeCollectorDriver( + startWithPanelClosed = true, + orderConfirmationAfterEntry = true, + orderRestorePagesBeforeSpecs = 2, + verticalSwipeSucceeds = false, + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 84, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertEquals(0, driver.restoreGestures) + assertEquals(listOf("单独购买"), driver.clickedLabels) + assertEquals( + AgentDiagnosticReason.SPEC_PANEL_RESTORE_GESTURE_FAILED, + events.single { it.stage == AgentDiagnosticStage.SPEC_PANEL_ENTRY }.reason, + ) + } + @Test fun repeatedReviewMisrouteStopsAfterSingleRecovery() { val driver = FakeCollectorDriver(startWithPanelClosed = true, alwaysEntryOpensReview = true) @@ -931,6 +1078,9 @@ class PddProductDetailCollectorTest { private val verticalSwipeSucceeds: Boolean = true, private val quickConfirmationAfterEntry: Boolean = false, private val hideQuickConfirmationSpec: Boolean = false, + private val orderConfirmationAfterEntry: Boolean = false, + private val orderRestorePagesBeforeSpecs: Int = 0, + private val sizeHeadingLabel: String = "尺码", ) : PddCollectorDriver { var captureCount = 0 var clickCount = 0 @@ -939,6 +1089,7 @@ class PddProductDetailCollectorTest { var pullDownCount = 0 var backCount = 0 var entryClickCount = 0 + var restoreGestures = 0 private var selected: String? = null private var previousSelected: String? = null private var horizontalPage = 0 @@ -986,6 +1137,9 @@ class PddProductDetailCollectorTest { add(node("buy-now", "现在买", 500, 1800, 1080, 2180, clickable = true)) }) } + if (orderConfirmationAfterEntry && restoreGestures < orderRestorePagesBeforeSpecs) { + return orderConfirmationSnapshot(restoreGestures) + } val nodes = mutableListOf( node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), node("title", "这是一个足够长的拼多多测试商品标题", 20, 100, 1000, 180), @@ -1017,7 +1171,7 @@ class PddProductDetailCollectorTest { ) } } - if (!continuationPage) nodes += node("scroll/size-heading", "尺码", 20, 700, 300, 750, parentPath = "scroll") + if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, 700, 300, 750, parentPath = "scroll") sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size -> nodes += node("scroll/size-$size-$captureCount", size, 30 + index * 230, 770, 220 + index * 230, 840, clickable = true, parentPath = "scroll") } @@ -1086,6 +1240,13 @@ class PddProductDetailCollectorTest { return true } + override fun pullDownSpecPanel(anchor: SnapshotNode): Boolean { + if (!anchor.scrollable || !verticalSwipeSucceeds) return false + restoreGestures++ + swipes += SwipeDirection.DOWN to anchor + return true + } + override fun pullDownGoodsPage(): Boolean { pullDownCount++; return true } override fun back(): Boolean { @@ -1119,6 +1280,24 @@ class PddProductDetailCollectorTest { clickable, scrollable, selected, false, true, true, ) + private fun orderConfirmationSnapshot(row: Int = 0) = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2328, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("close", "关闭", 975, 273, 1050, 348, clickable = true, className = "android.widget.ImageView"), + node("summary", "已选:绿色两件套 100 建议身高90-100", 396, 654, 1053, 768), + node("quantity", "1", 480, 804, 567, 879, className = "android.widget.EditText"), + node("decrease", "减少数量", 396, 804, 474, 879, clickable = true, className = "android.widget.ImageView"), + node("increase", "增加数量", 567, 804, 645, 879, clickable = true, className = "android.widget.ImageView"), + node("panel", "", 0, 1052, 1080, 2079, scrollable = true, className = "androidx.recyclerview.widget.RecyclerView"), + node("panel/row", "确认内容$row", 20, 1200, 1000, 1280, clickable = true, parentPath = "panel"), + node("payment", "使用微信支付,更换先用后付可0元下单", 112, 2100, 930, 2157), + node("submit", "", 0, 2181, 1080, 2328, clickable = true, className = "android.widget.FrameLayout"), + node("submit/text", "提交订单 ¥9.82", 306, 2225, 627, 2284, parentPath = "submit"), + ), + ) + private fun evidence() = PageEvidence(PDD_PACKAGE, ACTIVITY, NodeSelector(resourceId = "android:id/content", className = "android.widget.FrameLayout")) private fun detailSnapshot( @@ -1135,7 +1314,7 @@ class PddProductDetailCollectorTest { private fun config(maxSkuCount: Int = 500) = PddCollectorConfig( "pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color", - listOf("颜色分类", "颜色", "款式"), listOf("尺码", "尺寸", "套餐"), + listOf("颜色分类", "颜色", "款式"), listOf("尺码", "尺寸", "套餐", "参考分类"), mapOf("page" to 1000, "specPanel" to 1000, "selection" to 500, "price" to 500, "overall" to 10000), mapOf("goodsPageVerticalSwipes" to 1, "specHorizontalSwipes" to 3, "specVerticalSwipes" to 3, "stableEdgeReads" to 1, "stablePriceReads" to 2, "maxSkuCount" to maxSkuCount), ) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 7bd5af9..b053257 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 2c2d84b24108217e4242337a6afc69db64ff924c -synchronized_at: 2026-08-27T06:57:31Z +wiki_revision: f3d85bf36ae7543aaa3fa1c6926e9bc0f221fb1e +synchronized_at: 2026-08-27T09:22:25Z # 业务规则与术语 @@ -89,6 +89,7 @@ synchronized_at: 2026-08-27T06:57:31Z - v2 任务首次未进入精确商品详情页时,可以按规则显式重开同一浏览器 URL 一次;登录、验证码、风控、无效链接和详情页内采集失败不触发该恢复。 - 假售罄恢复只使用主商品规格/购买强证据判断页面是否正常;顶部“相似商品”可以作为受控兜底证据,推荐卡片的标题、价格和销量不得冒充主商品证据。 - 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。 +- PDD 已选规格的订单确认形态只可由详情页、选择摘要、唯一数量控件、支付区、唯一底部提交动作和唯一主要滚动容器的组合证据确认;底部提交动作只读且永久禁止点击。Agent 只在该已确认容器内最多三次向下拖动回顶,每次重新读取节点;“参考分类”作为尺码的精确标题别名处理。 - Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则未来可以使用独立的创建订单能力,任何规则都不能付款。 ## 采集任务 diff --git a/server/app/goauto/rulecontract/contract_test.go b/server/app/goauto/rulecontract/contract_test.go index f463321..6d71a50 100644 --- a/server/app/goauto/rulecontract/contract_test.go +++ b/server/app/goauto/rulecontract/contract_test.go @@ -1,6 +1,7 @@ package rulecontract import ( + "bytes" "strings" "testing" ) @@ -68,6 +69,9 @@ func TestBuiltInPDDProductDetailTemplateIsValid(t *testing.T) { if strings.Join(required, ",") != "action.swipe.v1,collector.pdd.product-detail.v1,rule.schema.v2" { t.Fatalf("unexpected template capabilities: %v", required) } + if !bytes.Contains(raw, []byte(`"size":["尺码","尺寸","套餐","参考分类"]`)) { + t.Fatal("built-in template must classify 参考分类 as size") + } } func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) { diff --git a/server/app/goauto/rulecontract/template.go b/server/app/goauto/rulecontract/template.go index e910efa..5df79cd 100644 --- a/server/app/goauto/rulecontract/template.go +++ b/server/app/goauto/rulecontract/template.go @@ -17,7 +17,7 @@ var pddProductDetailTemplate = json.RawMessage(`{ "hooks":{"afterSpecPanelOpen":[]}, "collector":{ "collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color", - "dimensionAliases":{"color":["颜色分类","颜色","款式","颜色款式","花色"],"size":["尺码","尺寸","套餐"]}, + "dimensionAliases":{"color":["颜色分类","颜色","款式","颜色款式","花色"],"size":["尺码","尺寸","套餐","参考分类"]}, "timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000}, "limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500} }