From 36b11ece5d030cf131a9775bed012fa8726ec5e2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 21 Aug 2026 15:29:03 +0800 Subject: [PATCH] fix(#60): stabilize per-color price capture --- android/README.md | 1 + .../automation/PddProductDetailCollector.kt | 90 ++++++++++++++++--- .../agent/PddProductDetailCollectorTest.kt | 90 ++++++++++++++++++- docs/03-business-rules-and-glossary.md | 5 +- docs/08-agent-api-contract.md | 6 +- docs/10-real-device-acceptance.md | 5 +- 6 files changed, 173 insertions(+), 24 deletions(-) diff --git a/android/README.md b/android/README.md index 7094ec2..23ac0b6 100644 --- a/android/README.md +++ b/android/README.md @@ -31,6 +31,7 @@ - 商品标题回退只接受与销量节点空间关联的文本,不再把页面中任意最长文本当成标题。 - 规格入口只接受带明确选择语义的规格摘要,或屏幕底部真实“购买/拼单”文字按钮;评价、评论、晒单和问答区域及其父容器不能成为入口。 - 商品评价页与商品详情页共用 `NewPageActivity` 时,Agent 通过顶部评价标题和评价内容识别误触,安全返回后最多重新定位一次。点击无页面变化、重复误入评价页和面板结构无法确认使用不同错误码;日志只记录入口语义、控件类型、坐标和动作结果,不保存控件树或截图。 +- 逐颜色价格必须先取得目标选中、已选摘要或规格面板变化证据,再等待价格刷新并连续稳定读取;其他颜色残留的选中标记不会阻塞新价格,相同价格仍允许保存。动作无效果时不会沿用旧价格,结构化日志区分未确认选择、未找到价格和价格不稳定。 ```powershell .\gradlew.bat test 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 d4e83c4..1ff3bf5 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 @@ -398,7 +398,12 @@ data class PddCollectorResult( val payload: CollectionPayload? = null, ) -private data class StablePriceResult(val priceCent: Long?, val problem: PageProblem? = null) +private data class StablePriceResult( + val priceCent: Long?, + val problem: PageProblem? = null, + val reason: String? = null, + val selectionEvidence: String? = null, +) private data class SizeContinuation( val containerPath: String, val optionClasses: Set, @@ -643,7 +648,10 @@ class PddProductDetailCollector( currentRow.forEach { rowColors[it.text] = rowColors[it.text] == true || it.available } val value = currentRow.firstOrNull { it.available && it.text !in attempted } ?: break attempted += value.text - when (driver.clickFresh(value.node)) { + val beforeClick = screen + val clickResult = driver.clickFresh(value.node) + trace("color-click color=${traceLabel(value.text)} result=$clickResult") + when (clickResult) { FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件") FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> { missing += "selection:${value.text}" @@ -651,9 +659,12 @@ class PddProductDetailCollector( } FreshActionResult.SUCCESS -> Unit } - val sampled = stablePrice(goodsId, value.text, config, evidence) + val sampled = stablePrice(goodsId, value.text, beforeClick, config, evidence) sampled.problem?.let { return failure(it.code, it.message) } - sampled.priceCent?.let { prices[value.text] = it } ?: run { missing += "price:${value.text}" } + sampled.priceCent?.let { prices[value.text] = it } ?: run { + trace("color-price color=${traceLabel(value.text)} outcome=missing reason=${sampled.reason ?: "unknown"} evidence=${sampled.selectionEvidence ?: "none"}") + missing += "price:${value.text}" + } } val signature = optionSignature(currentRow) stable = if (signature == previous) stable + 1 else 0 @@ -794,28 +805,65 @@ class PddProductDetailCollector( .sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left })) } - private fun stablePrice(goodsId: String, color: String, config: PddCollectorConfig, evidence: PageEvidence): StablePriceResult { + private fun stablePrice( + goodsId: String, + color: String, + beforeClick: ParsedPddScreen, + config: PddCollectorConfig, + evidence: PageEvidence, + ): StablePriceResult { val selectionDeadline = now() + config.timeoutsMs.getValue("selection") var priceDeadline: Long? = null + var priceNotBefore: Long? = null var previous: Long? = null var stable = 0 + var observedPrice = false + var selectionEvidence: String? = null + var lastTrace: String? = null + 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 商品详情页")) - val selectionExposed = screen.dimensions.flatMap { it.values }.any { it.node.selected || it.node.checked } || screen.selectedSummary != null - val selected = screen.dimensions.flatMap { it.values }.any { it.text == color && (it.node.selected || it.node.checked) } || - screen.selectedSummary?.contains(color) == true - if (!selectionExposed || selected) { - if (priceDeadline == null) priceDeadline = now() + config.timeoutsMs.getValue("price") + 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 + val evidenceType = when { + targetState -> "target_state" + targetSummary -> "selected_summary" + panelChanged -> "panel_changed" + else -> null + } + if (evidenceType != null) { + if (selectionEvidence == null) selectionEvidence = evidenceType + if (priceDeadline == null) { + priceDeadline = now() + config.timeoutsMs.getValue("price") + priceNotBefore = now() + PRICE_SETTLE_MILLIS + } val current = screen.priceCent - stable = if (current != null && current == previous) stable + 1 else if (current != null) 1 else 0 - previous = current - if (stable >= config.limits.getValue("stablePriceReads")) return StablePriceResult(current) + observedPrice = observedPrice || current != null + if (now() >= requireNotNull(priceNotBefore)) { + stable = if (current != null && current == previous) stable + 1 else if (current != null) 1 else 0 + previous = current + if (stable >= config.limits.getValue("stablePriceReads")) { + trace("color-price color=${traceLabel(color)} outcome=stable evidence=$selectionEvidence priceCent=$current reads=$stable") + return StablePriceResult(current, selectionEvidence = selectionEvidence) + } + } + val traceState = "$selectionEvidence:${current ?: "none"}:$stable" + if (traceState != lastTrace) { + trace("color-price-sample color=${traceLabel(color)} evidence=$selectionEvidence priceCent=${current ?: "none"} stableReads=$stable") + lastTrace = traceState + } } pause(100) } - return StablePriceResult(null) + val reason = when { + selectionEvidence == null -> "selection_not_confirmed" + !observedPrice -> "price_not_found" + else -> "price_not_stable" + } + return StablePriceResult(null, reason = reason, selectionEvidence = selectionEvidence) } private fun waitFor( @@ -843,6 +891,20 @@ class PddProductDetailCollector( .sorted() .toList() + private fun selectionSignature(screen: ParsedPddScreen): List = buildList { + add("summary:${screen.selectedSummary.orEmpty()}") + add("price:${screen.priceCent ?: "none"}") + screen.dimensions.flatMap { it.values }.forEach { value -> + add("option:${value.text}:${value.node.selected}:${value.node.checked}:${value.available}") + } + }.sorted() + + private fun traceLabel(value: String): String = value.replace(Regex("[\\r\\n\\t]+"), " ").take(40) + + private companion object { + const val PRICE_SETTLE_MILLIS = 300L + } + private fun colorRows(screen: ParsedPddScreen): List> { val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values } .sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left })) 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 aaa5837..2b40a25 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 @@ -417,6 +417,79 @@ class PddProductDetailCollectorTest { assertTrue(payload.skus.isEmpty()) } + @Test + fun staleOtherColorSelectionDoesNotBlockPriceAfterPanelChanges() { + val driver = FakeCollectorDriver( + colors = listOf("红色", "蓝色"), + prices = mapOf("红色" to 1099L, "蓝色" to 1299L), + staleSelectionEvidence = true, + ) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertEquals("completed", requireNotNull(result.payload).status) + assertEquals(listOf(1099L, 1299L), requireNotNull(result.payload).colorPrices.map { it.priceCent }) + } + + @Test + fun delayedPriceUpdateWaitsForNewStableValue() { + val driver = FakeCollectorDriver( + colors = listOf("红色", "蓝色"), + prices = mapOf("红色" to 1099L, "蓝色" to 1299L), + priceDelayReads = mapOf("蓝色" to 4), + ) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertEquals("completed", requireNotNull(result.payload).status) + assertEquals(1299L, requireNotNull(result.payload).colorPrices.first { it.color == "蓝色" }.priceCent) + } + + @Test + fun acceptedClickWithoutSelectionEffectNeverReusesPreviousEqualPrice() { + val traces = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("红色", "蓝色"), + prices = mapOf("红色" to 1000L, "蓝色" to 1000L), + acceptedClicksWithoutEffect = setOf("蓝色"), + ) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, traces::add).collect(GOODS_ID, rule()) + val payload = requireNotNull(result.payload) + + assertEquals("completed_partial", payload.status) + assertEquals(listOf("红色"), payload.colorPrices.map { it.color }) + assertTrue(payload.missing.contains("price:蓝色")) + assertTrue(traces.any { it.contains("color=蓝色") && it.contains("reason=selection_not_confirmed") }) + } + + @Test + fun priceDiagnosticsDistinguishNotFoundFromNotStable() { + val notFoundTraces = mutableListOf() + var firstClock = 0L + PddProductDetailCollector( + FakeCollectorDriver(colors = listOf("红色"), prices = emptyMap()), + { firstClock }, + { firstClock += it }, + notFoundTraces::add, + ).collect(GOODS_ID, rule()) + + val unstableTraces = mutableListOf() + var secondClock = 0L + PddProductDetailCollector( + FakeCollectorDriver(colors = listOf("红色"), prices = mapOf("红色" to 1000L), unstableColors = setOf("红色")), + { secondClock }, + { secondClock += it }, + unstableTraces::add, + ).collect(GOODS_ID, rule()) + + assertTrue(notFoundTraces.any { it.contains("reason=price_not_found") }) + assertTrue(unstableTraces.any { it.contains("reason=price_not_stable") }) + } + @Test fun thirdDimensionAndSkuLimitSubmitBoundedPartialResult() { val driver = FakeCollectorDriver( @@ -520,6 +593,9 @@ class PddProductDetailCollectorTest { private val firstEntryOpensReview: Boolean = false, private val alwaysEntryOpensReview: Boolean = false, private val entryNeverOpens: Boolean = false, + private val staleSelectionEvidence: Boolean = false, + private val priceDelayReads: Map = emptyMap(), + private val acceptedClicksWithoutEffect: Set = emptySet(), ) : PddCollectorDriver { var captureCount = 0 var clickCount = 0 @@ -529,6 +605,7 @@ class PddProductDetailCollectorTest { var backCount = 0 var entryClickCount = 0 private var selected: String? = null + private var previousSelected: String? = null private var horizontalPage = 0 private var verticalPage = 0 private var priceRead = 0 @@ -564,7 +641,7 @@ class PddProductDetailCollectorTest { node("shop", "测试旗舰店", 20, 190, 300, 240), node("sales", "已拼1.2万件", 20, 250, 300, 300), node("reviews", "商品评价(1.2万)", 320, 250, 650, 300), - node("selected", "已选 ${selected.orEmpty()}", 20, 320, 700, 370), + node("selected", "已选 ${displayedSelected().orEmpty()}", 20, 320, 700, 370), node("panel-title", "确认款式", 20, 370, 300, 410), node("scroll", "", 0, 380, 1080, 1900, scrollable = true), ) @@ -584,7 +661,7 @@ class PddProductDetailCollectorTest { 220 + column * 230, 540 + row * 90, clickable = true, - selected = selected == color, + selected = displayedSelected() == color, parentPath = "scroll", ) } @@ -603,7 +680,9 @@ class PddProductDetailCollectorTest { } nodes += node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true) selected?.let { color -> - val cents = prices[color] + val delay = priceDelayReads[color] ?: 0 + val priceColor = if (priceRead < delay) previousSelected else color + val cents = priceColor?.let(prices::get) if (cents != null) { priceRead++ val shown = if (color in unstableColors && priceRead % 2 == 0) cents + 1 else cents @@ -631,11 +710,16 @@ class PddProductDetailCollectorTest { } return FreshActionResult.SUCCESS } + if (target.label in acceptedClicksWithoutEffect) return FreshActionResult.SUCCESS + previousSelected = selected selected = target.label priceRead = 0 return FreshActionResult.SUCCESS } + private fun displayedSelected(): String? = + if (staleSelectionEvidence && previousSelected != null) previousSelected else selected + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { swipes += direction to anchor when (direction) { diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 1d386a4..6813a9d 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: ca1aefbb4511cfd64d74eb3ba389cd8f944b5b2c -synchronized_at: 2026-08-21T07:01:37Z +wiki_revision: c3f7561d0b57fd7ec3d1b2bff273f8d7da7b724f +synchronized_at: 2026-08-21T07:26:39Z # 业务规则与术语 @@ -101,6 +101,7 @@ synchronized_at: 2026-08-21T07:01:37Z - PDD 标题、店铺名、销量、评价数量。 - 通用规格维度和值,当前重点是颜色和尺码。 - 采集时按颜色读取价格;同一颜色下不同尺码默认价格相同。 +- 逐颜色价格只在精确点击后取得目标选中、已选摘要或规格面板变化证据,并等待候选价格连续稳定后保存;其他颜色残留的选中标记不能阻塞当前颜色。点击无效果时不能沿用旧价格,相同价格允许保存;无法确认选择、找不到价格和价格不稳定只记录结构化原因并按缺失价格提交。 - 存储时展开 SKU 组合,将颜色价格复制到该颜色各尺码 SKU。 - SKU 不完整仍提交已有结果并标记 `completed_partial`。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index ad79e0d..5b594ad 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 086e1ec0d240c5837f141c26bb50aef02fd83612 -synchronized_at: 2026-08-21T07:02:01Z +wiki_revision: 4530130b77dea4dc0d430307c7c9f57f460b7449 +synchronized_at: 2026-08-21T07:27:04Z # MVP 共享 API 契约 @@ -167,7 +167,7 @@ Android 的 `pddProductDetailV1` 采集器执行以下固定流程: - 入口候选排除评价、评论、晒单和问答上下文,不使用全页面“规格词 + 任意数字”猜测;底部兜底必须命中真实购买/拼单文字及其可点击链路,且排除提交订单与支付语义。 - 商品评价页与详情页共用 `NewPageActivity` 时,首次误触允许全局返回、重新验证详情页并重新定位一次;第二次误触停止。点击无变化、误入评价页和面板证据不匹配分别返回 `SPEC_ENTRY_CLICK_NO_EFFECT`、`SPEC_ENTRY_OPENED_REVIEW`、`SPEC_PANEL_EVIDENCE_NOT_MATCHED`。 3. 规格面板以有界稳定读取恢复到顶部;横向颜色容器和纵向面板容器必须由已识别规格节点的祖先关系锁定,不能只按屏幕中最大滚动区域猜测。 -4. 颜色先归左,再按视觉行执行左到右、右到左交替的蛇形遍历;每次点击都在最新无障碍树中重新定位唯一文字控件,确认选中后连续读取相同价格。 +4. 颜色先归左,再按视觉行执行左到右、右到左交替的蛇形遍历;每次点击都在最新无障碍树中重新定位唯一文字控件。点击后必须取得目标选中、已选摘要或规格面板选中状态/价格变化证据,等待短暂刷新后连续读取相同价格;其他颜色残留选中标记不能阻塞当前颜色,同价合法,但动作无效果时不能沿用点击前旧价格。结构化轨迹只记录短颜色标签、点击结果、证据类型、价格采样和 `selection_not_confirmed`、`price_not_found`、`price_not_stable` 原因。 5. 滚动优先调用目标容器的无障碍前进/后退动作;回退坐标手势时必须等待系统完成回调。视口签名包含规格文字和 bounds,连续稳定后才确认到边。 6. 尺码仅通过有限次纵向滑动读取,不点击尺码;首次定位后保存容器和选项结构,标题滚出屏幕后仍可续页。颜色价格展开到该颜色下的可用尺码 SKU。 7. 缺失颜色价格、尺码、第三规格维度或超过 SKU 上限时提交有界的 `completed_partial`;不猜测缺失值。采集期间离开商品页或出现验证码、登录、风控时明确失败。 diff --git a/docs/10-real-device-acceptance.md b/docs/10-real-device-acceptance.md index 3cc23a3..c9c9fb8 100644 --- a/docs/10-real-device-acceptance.md +++ b/docs/10-real-device-acceptance.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: OnePlus-Real-Device-Acceptance wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.- -wiki_revision: c7cae738f3ce5758d23cae20fb26fa7bfe5afc3c -synchronized_at: 2026-08-21T07:14:36Z +wiki_revision: 183b6a636705fb4a3cd222692e0eae83c7cd279a +synchronized_at: 2026-08-21T07:27:14Z # 一加真机验收记录 @@ -55,6 +55,7 @@ T17 只验证 PDD 商品采集最小闭环:Agent 注册与心跳、任务串 - 浏览器点击返回值不可靠时,以离开浏览器后的最终 PDD 包名和 `NewPageActivity` 为页面事实,不把已成功跳转误报为动作失败。 - 规格入口使用右下方“价格 + 购买/拼单语义 + 最近可点击容器”强证据,只用于打开规格面板。 - 2026-08-21 三星 SM-G9700、PDD 7.95.0、商品 `172119352488` 的任务 42 暴露规格入口误触评价页:评价卡片包含规格词和数字,被旧兜底误判;评价页又与详情页共用 `NewPageActivity`。#59 收紧为明确选择语义或底部真实购买文字,排除评价上下文,并增加一次安全返回重试与分级错误。安装新 APK 并通过正式接口重置任务后,真机日志记录 `spec-entry source=bottom_purchase`、标签“快要抢光 免拼购买”、点击结果 `SUCCESS`,未进入商品评价页;规格面板采到 2 个颜色和 8 个尺码,任务以 `completed_partial` 提交,仅缺少颜色 `9031#长裤` 的价格。该缺失属于既有价格采集问题,不阻塞本次误触修复验收。 +- #60 针对上述颜色价格缺失增加点击生效证据、300ms 刷新保护、连续稳定读取和分级诊断。三星覆盖安装新 APK 后再次重置任务 42,结果为 `completed`、缺失清单为空:颜色 `8913#长裤` 与 `9031#长裤` 均取得 3112 分价格,8 个尺码完整;两个颜色的日志均显示 `target_state` 证据和连续 2 次稳定价格读取。 - 图片型颜色卡片优先点击 `TextView` 颜色文字,排除 `ImageView`、“打开大图”和“查看大图”;真机不再进入 `SkuPhotoBrowseActivity`。 - 任务 20 采到 4 个颜色,四个颜色价格均为 2690 分,并生成 4 个可用、完整的单维 SKU;标题和“已拼299件”已提交。 - 管理端任务详情 API 返回 1 个规格维度、4 个颜色价格、4 个 SKU 和缺失清单,且没有原始控件树或截图字段。