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:
co-authored by
Claude Sonnet 5.5
parent
e966d0f702
commit
b86f858cb7
+22
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user