diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index d2b9f74..8918d66 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -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, screenHeight: Int): SnapshotNode? { var current: SnapshotNode? = node diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index 844df08..5f0a0ae 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -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 { + 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>): 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