fix(android): block PDD size advice navigation (#189)

This commit is contained in:
QiuSW
2026-09-01 16:41:43 +08:00
parent 4a1b4af29d
commit b5a08bfa1f
2 changed files with 64 additions and 3 deletions
@@ -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
}
@@ -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
}