fix(#60): stabilize per-color price capture
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
- 商品标题回退只接受与销量节点空间关联的文本,不再把页面中任意最长文本当成标题。
|
||||
- 规格入口只接受带明确选择语义的规格摘要,或屏幕底部真实“购买/拼单”文字按钮;评价、评论、晒单和问答区域及其父容器不能成为入口。
|
||||
- 商品评价页与商品详情页共用 `NewPageActivity` 时,Agent 通过顶部评价标题和评价内容识别误触,安全返回后最多重新定位一次。点击无页面变化、重复误入评价页和面板结构无法确认使用不同错误码;日志只记录入口语义、控件类型、坐标和动作结果,不保存控件树或截图。
|
||||
- 逐颜色价格必须先取得目标选中、已选摘要或规格面板变化证据,再等待价格刷新并连续稳定读取;其他颜色残留的选中标记不会阻塞新价格,相同价格仍允许保存。动作无效果时不会沿用旧价格,结构化日志区分未确认选择、未找到价格和价格不稳定。
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat test
|
||||
|
||||
+76
-14
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user