fix(android): merge truncated color caption with trailing price and badge (#379)

采购1 上 PDD 说明文字形如「名称...⏎ ¥11.8 零差评」,价格后带徽标,末尾价格
规范化去不掉,导致卡片未合并、说明值含 ¥ 而整维清空颜色。

isTruncatedDisplayCard 仅在说明文字规范化后仍含货币符号时,尝试严格后缀
「空白* ¥金额 空白+ 徽标$」(徽标 1-8 个不含货币符号、数字、空白的字符),
取价格前部分并去掉末尾省略号后再做前缀比较;要求金额等于外层全名的末尾价格,
外层无价格或金额不等则不合并。#362 的结构条件全部保留,不改公共语法。

测试:按采购1 结构合成快照(3 色、无重复、无 invalid),覆盖金额不等、徽标含 ¥、
徽标超长或含空白、外层无价格、前缀不符、卡片内第二个可点文字、无徽标截断回归。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-10-12 14:45:58 +08:00
co-authored by Claude Sonnet 5.5
parent e966d0f702
commit b86f858cb7
2 changed files with 173 additions and 1 deletions
@@ -248,6 +248,8 @@ object PddScreenParser {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
private val badgeCaptionSuffix =
Regex("[\\s\\u00a0\\u3000]*[¥¥][\\s\\u00a0\\u3000]*(\\d+(?:\\.\\d{1,2})?)[\\s\\u00a0\\u3000]+[^¥¥\\d\\s\\u00a0\\u3000]{1,8}$")
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
private val reviewPattern = Regex("(?:商品评价\\s*[((]?\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?|[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*条?评价|评价\\s*[0-9]+)")
private val excludedOptionWords = listOf(
@@ -667,7 +669,11 @@ object PddScreenParser {
val image = children.singleOrNull { it.className == "android.widget.ImageView" } ?: return false
val caption = children.singleOrNull { it.className == "android.widget.TextView" } ?: return false
if (children.size != 2 || name(image.label) != full) return false
val short = name(caption.label)
// #379: a caption such as "名称...\n ¥11.8 零差评" keeps a price plus a badge
// after its truncated name, which the terminal-price grammar cannot strip.
val plainCaption = SpecValueNormalizer.normalizeColor(caption.label)
val badgeName = if (plainCaption.any { it == '¥' || it == '¥' }) badgeCaptionName(caption.label, block.label) else null
val short = (badgeName ?: plainCaption).filterNot { it.isWhitespace() }
if (short.isEmpty() || short == full || !full.startsWith(short)) return false
val descendants = sourceByPath.values.filter { it.path.startsWith("${block.path}/") }
@@ -688,6 +694,21 @@ object PddScreenParser {
return prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 }
}
/**
* #379: truncated name of a caption shaped `名称... ¥金额 徽标`, or null. The
* badge is 1-8 characters without currency symbols, digits or whitespace and
* the amount must equal the outer card's terminal price.
*/
private fun badgeCaptionName(captionLabel: String, fullLabel: String): String? {
val match = badgeCaptionSuffix.find(captionLabel) ?: return null
val amount = match.groupValues[1].toBigDecimalOrNull() ?: return null
val fullAmount = SpecValueNormalizer.trailingPriceAmount(fullLabel) ?: return null
if (amount.compareTo(fullAmount) != 0) return null
val head = captionLabel.substring(0, match.range.first).filterNot { it.isWhitespace() }
.replace(Regex("(?:\\.{3}|…)+$"), "")
return head.takeIf { it.isNotEmpty() && head.none { ch -> ch == '¥' || ch == '¥' } }
}
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
var current: SnapshotNode? = node
@@ -2943,6 +2943,157 @@ class PddProductDetailCollectorTest {
}
}
// #379: purchase-1 card shape: clickable ViewGroup > (View, full-name ImageView,
// "打开大图" ImageView, non-clickable LinearLayout > clickable caption TextView).
private fun badgeCard(index: Int, left: Int, fullName: String, caption: String): List<SnapshotNode> {
fun described(path: String, parent: String?, text: String?, description: String?, bounds: NodeBounds, className: String, clickable: Boolean) =
SnapshotNode(
path, parent, text, description, null, className, bounds,
clickable, scrollable = false, selected = false, checked = false, enabled = true, visible = true,
)
val card = "scroll/card$index"
val right = left + 316
return listOf(
described(card, "scroll", null, fullName, NodeBounds(left, 470, right, 690), "android.view.ViewGroup", true),
described("$card/bg", card, null, null, NodeBounds(left, 470, right, 690), "android.view.View", false),
described("$card/image", card, null, fullName, NodeBounds(left, 470, right, 640), "android.widget.ImageView", true),
described("$card/big", card, null, "打开大图", NodeBounds(left, 470, left + 111, 581), "android.widget.ImageView", true),
described("$card/box", card, null, null, NodeBounds(left, 630, right, 690), "android.widget.LinearLayout", false),
described("$card/box/caption", "$card/box", caption, null, NodeBounds(left, 630, right, 690), "android.widget.TextView", true),
)
}
private fun badgeCardsSnapshot(cards: List<List<SnapshotNode>>): UiSnapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("summary", "已选:", 20, 300, 700, 360),
node("title", "确认款式", 20, 370, 300, 410),
node("scroll", "", 0, 380, 1080, 1900, scrollable = true),
node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll"),
) + cards.flatten() + listOf(
node("scroll/size-heading", "尺码", 20, 700, 300, 750, parentPath = "scroll"),
node("scroll/size-s", "S 75斤内穿", 30, 770, 220, 840, clickable = true, parentPath = "scroll"),
node("scroll/size-m", "M 80-90斤", 260, 770, 450, 840, clickable = true, parentPath = "scroll"),
node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true),
),
)
private fun purchase1Cards(captionOne: String = "黑色 微喇春秋加...\n ¥11.8 零差评") = listOf(
badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", captionOne),
badgeCard(2, 382, " 黑色 微喇春夏款长裤 ¥9.8", " 黑色 微喇春夏...\n ¥9.8 最多人选择"),
badgeCard(3, 728, "黑色 微喇夏天薄款长裤 ¥9.8", "黑色 微喇夏天薄款长裤 ¥9.8"),
)
@Test
fun `price plus badge caption merges into the outer card and yields three colors`() {
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards()), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(
listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)", "黑色 微喇春夏款长裤", "黑色 微喇夏天薄款长裤"),
colors.map { it.text },
)
assertEquals(colors.size, colors.map { it.text }.distinct().size)
assertTrue(colors.none { it.text.contains('¥') || it.text.contains('¥') })
assertEquals("黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", colors[0].rawText)
assertTrue(colors.all { it.node.path.endsWith("/caption") || it.node.path.startsWith("scroll/card") })
// Size dimension is unaffected.
assertEquals(listOf("S 75斤内穿", "M 80-90斤"), parsed.dimensions.first { it.key == "size" }.values.map { it.text })
}
@Test
fun `price plus badge cards collect three colors without invalid marker`() {
val base = FakeCollectorDriver(colors = listOf("占位"), sizePages = listOf(listOf("S 75斤内穿", "M 80-90斤")))
val driver = object : PddCollectorDriver by base {
override fun capture(): UiSnapshot {
val snapshot = base.capture()
return snapshot.copy(
nodes = snapshot.nodes.filterNot { it.path.startsWith("scroll/color-") && it.path != "scroll/color-heading" } +
purchase1Cards().flatten(),
)
}
}
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(
listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)", "黑色 微喇春夏款长裤", "黑色 微喇夏天薄款长裤"),
payload.dimensions.first { it.key == "color" }.values,
)
assertTrue(payload.missing.none { it.startsWith("spec_dimension_") })
}
@Test
fun `badge caption amount differing from the outer card price is not merged`() {
val parsed = PddScreenParser.parse(
badgeCardsSnapshot(purchase1Cards("黑色 微喇春秋加...\n ¥11.9 零差评")),
config(), GOODS_ID, evidence(),
)
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(4, colors.size)
assertTrue(colors.any { it.text.contains('¥') })
}
@Test
fun `badge caption with currency or an overlong badge is not merged`() {
for (caption in listOf("黑色 微喇春秋加...\n ¥11.8 零¥差评", "黑色 微喇春秋加...\n ¥11.8 零差评零差评零差评", "黑色 微喇春秋加...\n ¥11.8 零 差评")) {
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards(caption)), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(caption, 4, colors.size)
assertTrue(caption, colors.any { it.text.contains('¥') })
}
}
@Test
fun `badge caption is not merged when the outer card has no price`() {
val cards = listOf(badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤", "黑色 微喇春秋加...\n ¥11.8 零差评"))
val parsed = PddScreenParser.parse(badgeCardsSnapshot(cards), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(2, colors.size)
assertTrue(colors.any { it.text.contains('¥') })
}
@Test
fun `badge caption must still be a prefix of the outer name`() {
val parsed = PddScreenParser.parse(
badgeCardsSnapshot(purchase1Cards("红色 微喇春秋加...\n ¥11.8 零差评")),
config(), GOODS_ID, evidence(),
)
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(4, colors.size)
}
@Test
fun `badge caption does not merge when the card holds a second clickable text`() {
val card = badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", "黑色 微喇春秋加...\n ¥11.8 零差评")
val extra = SnapshotNode(
"scroll/card1/box/other", "scroll/card1/box", "黑色", null, null, "android.widget.TextView",
NodeBounds(36, 630, 100, 660), clickable = true, scrollable = false, selected = false,
checked = false, enabled = true, visible = true,
)
val parsed = PddScreenParser.parse(badgeCardsSnapshot(listOf(card + extra)), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertTrue(colors.size > 1)
}
@Test
fun `truncated caption without badge still merges as before`() {
val cards = listOf(badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", "黑色 微喇春秋加薄绒长裤 (适合 ¥11.8"))
val parsed = PddScreenParser.parse(badgeCardsSnapshot(cards), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)"), colors.map { it.text })
}
companion object {
private const val GOODS_ID = "719834019024"
private const val GRID_VIEWPORT_RIGHT = 835