fix(#60): stabilize per-color price capture

This commit is contained in:
QiuSW
2026-08-21 15:29:03 +08:00
parent 37db89ce92
commit 36b11ece5d
6 changed files with 173 additions and 24 deletions
+1
View File
@@ -31,6 +31,7 @@
- 商品标题回退只接受与销量节点空间关联的文本,不再把页面中任意最长文本当成标题。
- 规格入口只接受带明确选择语义的规格摘要,或屏幕底部真实“购买/拼单”文字按钮;评价、评论、晒单和问答区域及其父容器不能成为入口。
- 商品评价页与商品详情页共用 `NewPageActivity` 时,Agent 通过顶部评价标题和评价内容识别误触,安全返回后最多重新定位一次。点击无页面变化、重复误入评价页和面板结构无法确认使用不同错误码;日志只记录入口语义、控件类型、坐标和动作结果,不保存控件树或截图。
- 逐颜色价格必须先取得目标选中、已选摘要或规格面板变化证据,再等待价格刷新并连续稳定读取;其他颜色残留的选中标记不会阻塞新价格,相同价格仍允许保存。动作无效果时不会沿用旧价格,结构化日志区分未确认选择、未找到价格和价格不稳定。
```powershell
.\gradlew.bat test
@@ -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<String?>,
@@ -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<String> = 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<List<VisibleSpecValue>> {
val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
@@ -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<String>()
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<String>()
var firstClock = 0L
PddProductDetailCollector(
FakeCollectorDriver(colors = listOf("红色"), prices = emptyMap()),
{ firstClock },
{ firstClock += it },
notFoundTraces::add,
).collect(GOODS_ID, rule())
val unstableTraces = mutableListOf<String>()
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<String, Int> = emptyMap(),
private val acceptedClicksWithoutEffect: Set<String> = 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) {