feat(#115): move PDD recognition aliases into rules

This commit is contained in:
QiuSW
2026-08-27 18:19:33 +08:00
parent 3619435a97
commit e117fd222d
11 changed files with 389 additions and 96 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 23
versionName = "0.9.10"
versionCode = 24
versionName = "0.9.11"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -100,7 +100,11 @@ data class ParsedPddScreen(
val problem: PageProblem?,
val sourceNodes: List<SnapshotNode>,
) {
fun isTransientSoldOut(exactText: String, fallbackTopText: String = "相似商品"): Boolean {
fun isTransientSoldOut(
exactText: String,
fallbackTopText: String = "相似商品",
aliases: SoldOutTextAliases = SoldOutTextAliases(),
): Boolean {
val visible = sourceNodes.filter { it.visible }
fun normalized(value: String) = value.replace(Regex("\\s+"), "")
val hasExactText = visible.any { normalized(it.label) == normalized(exactText) }
@@ -110,7 +114,10 @@ data class ParsedPddScreen(
}
val labels = visible.map { normalized(it.label) }
val hasPrimaryProductEvidence = specPanelOpen || specEntry != null || quickConfirmationEntry != null ||
labels.any { it == "进店" || it.startsWith("商品评价") || it in setOf("单独购买", "发起拼单", "立即购买") }
labels.any { label ->
aliases.mainProductExactAliases.any { label == normalized(it) } ||
aliases.mainProductPrefixAliases.any { label.startsWith(normalized(it)) }
}
return pageEvidenceMatched && (hasExactText || hasFallbackAtTop) && !hasPrimaryProductEvidence
}
}
@@ -126,7 +133,9 @@ object PddScreenParser {
"已選", "請選擇", "數量", "打開大圖", "查看大圖", "一次選多款",
)
private val excludedExactOptionLabels = setOf("客服", "联系客服", "聯繫客服", "店铺", "店鋪", "收藏", "关闭", "關閉")
private val reviewWords = listOf("商品评价", "全部评价", "评价详情", "评论", "晒单", "问答")
// This denylist is intentionally not configurable: it prevents purchase,
// order, and payment controls from ever becoming collection click targets.
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
@@ -145,14 +154,13 @@ object PddScreenParser {
}
}
val labels = visible.map(SnapshotNode::label)
val textAliases = config.textAliases
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
val compactLabels = labels.map { it.replace(" ", "") }
val hasSelectionSummary = compactLabels.any {
it.startsWith("已选") || it.startsWith("请选择") || it.startsWith("已選") || it.startsWith("請選擇")
}
val hasSelectionSummary = compactLabels.any { label -> textAliases.selection.summaryPrefixes.any(label::startsWith) }
val hasSubmitHint = compactLabels.any { label ->
(label.contains("提交订单") || label.contains("提交訂單")) &&
listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格").any(label::contains)
textAliases.specPanel.orderSubmitAliases.any(label::contains) &&
textAliases.selection.submitHintAliases.any(label::contains)
}
val allHeadings = visible.filter { !it.clickable && isHeading(it.label, config) }
val screenWidth = visibleNodes.maxOfOrNull { it.bounds.right } ?: 0
@@ -168,27 +176,27 @@ object PddScreenParser {
val headedPanelScrollable = boundedScrollables.asSequence()
.filter { region -> allHeadings.any { heading -> heading.path.startsWith("${region.path}/") } }
.maxByOrNull { it.bounds.width.toLong() * it.bounds.height }
val hasPanelTitle = compactLabels.any { it in setOf("确认款式", "確認款式", "关闭", "關閉") }
val hasPanelTitle = compactLabels.any { it in textAliases.specPanel.titleAliases || it in textAliases.specPanel.closeAliases }
val hasPanelAction = visible.any { node ->
val compact = node.label.replace(" ", "")
node.clickable && (
compact in setOf("确定", "確定", "确认", "確認") ||
((compact.contains("提交订单") || compact.contains("提交訂單")) &&
listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格").any(compact::contains))
compact in textAliases.specPanel.confirmAliases ||
(textAliases.specPanel.orderSubmitAliases.any(compact::contains) &&
textAliases.selection.submitHintAliases.any(compact::contains))
)
}
val quantityInputs = visibleNodes.filter {
it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
}
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in setOf("减少数量", "減少數量") }
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in setOf("增加数量", "增加數量") }
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityIncreaseAliases }
val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1
val hasPaymentArea = compactLabels.any { label -> listOf("微信支付", "先用后付", "先用後付", "支付方式").any(label::contains) }
val hasPaymentArea = compactLabels.any { label -> textAliases.specPanel.paymentAreaAliases.any(label::contains) }
val hasQuickBuy = visible.any { node ->
node.label.replace(" ", "").let { it.contains("现在买") || it.contains("現在買") } &&
node.label.replace(" ", "").let { compact -> textAliases.specPanel.quickBuyAliases.any(compact::contains) } &&
node.bounds.centerY.toDouble() >= screenHeight * 0.75
}
val hasClose = compactLabels.any { it in setOf("关闭", "關閉") }
val hasClose = compactLabels.any { it in textAliases.specPanel.closeAliases }
val quickConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea && hasQuickBuy && hasQuantityControls
val orderSubmitCandidates = visibleNodes.asSequence()
.filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 }
@@ -196,11 +204,11 @@ object PddScreenParser {
.filter { node ->
val compact = (listOf(node.label) + descendants(node, visibleNodes).map(SnapshotNode::label))
.joinToString("") { it.replace(" ", "") }
compact.contains("提交订单") || compact.contains("提交訂單")
textAliases.specPanel.orderSubmitAliases.any(compact::contains)
}
.toList()
.filter { candidate ->
orderSubmitCandidatesAncestorFree(candidate, visibleNodes)
orderSubmitCandidatesAncestorFree(candidate, visibleNodes, textAliases.specPanel.orderSubmitAliases)
}
val hasOrderSubmitAction = orderSubmitCandidates.size == 1
val orderConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea &&
@@ -252,16 +260,16 @@ object PddScreenParser {
.mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) }
.firstOrNull()
val explicitSpecEntry = if (panelOpen) null else visible
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes) }
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes, config) }
.maxByOrNull { it.bounds.top }
val bottomSpecEntry = if (panelOpen || explicitSpecEntry != null) null else safeBottomSpecEntry(visibleNodes, visible)
val bottomSpecEntry = if (panelOpen || explicitSpecEntry != null) null else safeBottomSpecEntry(visibleNodes, visible, config)
val specEntry = explicitSpecEntry ?: bottomSpecEntry
val specEntrySource = when {
explicitSpecEntry != null -> "explicit_selection"
bottomSpecEntry != null -> "bottom_purchase"
else -> null
}
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, specEntry)
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, specEntry, config)
val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null
val rootAvailable = snapshot.packageName != null || snapshot.activityName != null || snapshot.nodes.isNotEmpty()
val packageMatched = evidence == null || snapshot.packageName == evidence.packageName
@@ -281,7 +289,7 @@ object PddScreenParser {
dimensions = dimensions,
selectedSummary = labels.firstOrNull {
val compact = it.replace(" ", "")
compact.startsWith("已选") || compact.startsWith("已選")
textAliases.selection.selectedPrefixes.any(compact::startsWith)
},
priceCent = price,
specPanelOpen = panelOpen,
@@ -312,13 +320,13 @@ object PddScreenParser {
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
}
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>): Boolean {
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>, aliases: List<String>): Boolean {
val candidatePrefix = "${candidate.path}/"
return source.none { node ->
node.path.startsWith(candidatePrefix) && node.visible && node.enabled && node.clickable &&
(listOf(node.label) + descendants(node, source).map(SnapshotNode::label))
.joinToString("") { it.replace(" ", "") }
.let { it.contains("提交订单") || it.contains("提交訂單") }
.let { compact -> aliases.any(compact::contains) }
}
}
@@ -336,45 +344,44 @@ object PddScreenParser {
private fun isHeading(label: String, config: PddCollectorConfig): Boolean {
val compact = label.replace(" ", "").replace(Regex("[((]\\d+[))]$"), "")
if (compact.startsWith("请选择") || compact.startsWith("已选") || compact == "确认款式") return false
if (config.textAliases.selection.summaryPrefixes.any(compact::startsWith) || compact in config.textAliases.specPanel.titleAliases) return false
return (config.colorAliases + config.sizeAliases).any { compact == it.replace(" ", "") } ||
compact.endsWith("颜色") || compact.endsWith("款式") || compact.endsWith("尺码") ||
compact.endsWith("尺寸") || compact.endsWith("规格") || compact.endsWith("型号") || compact.endsWith("套餐") ||
compact.endsWith("容量") || compact.endsWith("类型") || compact.endsWith("版本") || compact.endsWith("口味")
(config.textAliases.dimension.colorSuffixes + config.textAliases.dimension.sizeSuffixes +
config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases).any(compact::endsWith)
}
private fun isExactHeadingLabel(label: String, config: PddCollectorConfig): Boolean {
val compact = label.replace(" ", "").replace(Regex("[((]\\d+[))]$"), "")
return (config.colorAliases + config.sizeAliases).any { compact == it.replace(" ", "") } ||
compact in setOf("颜色分类", "颜色", "花色", "款式", "尺码", "尺寸", "规格", "型号", "套餐", "容量", "类型", "版本", "口味")
compact in (config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases)
}
private fun dimensionKey(label: String, config: PddCollectorConfig, hasColor: Boolean): String = when {
config.colorAliases.any { label.contains(it) } || label.contains("颜色") || label.contains("款式") -> "color"
label.contains("套餐") -> if (hasColor) "size" else "color"
config.sizeAliases.any { label.contains(it) } || label.contains("尺码") || label.contains("尺寸") -> "size"
config.colorAliases.any { label.contains(it) } -> "color"
config.textAliases.dimension.adaptiveAliases.any(label::contains) -> if (hasColor) "size" else "color"
config.sizeAliases.any { label.contains(it) } -> "size"
else -> "unsupported"
}
private fun isSpecEntry(label: String, config: PddCollectorConfig): Boolean {
val compact = label.replace(" ", "")
if (reviewWords.any(compact::contains)) return false
if (config.textAliases.review.entryAliases.any(compact::contains)) return false
val aliases = config.colorAliases + config.sizeAliases
val hasSpecWord = aliases.any(compact::contains) || compact.contains("规格")
return hasSpecWord && listOf("请选择", "选择", "已选").any(compact::startsWith)
val hasSpecWord = aliases.any(compact::contains) ||
(config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases).any(compact::contains)
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::startsWith)
}
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>): SnapshotNode? {
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): SnapshotNode? {
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return null
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return null
if (screenWidth <= 0 || screenHeight <= 0) return null
val byPath = source.associateBy(SnapshotNode::path)
val normalizedByPath = visible.associateBy(SnapshotNode::path)
val buyWords = listOf("单独购买", "立即购买", "发起拼单", "去拼单", "免拼购买", "购买")
val dangerousWords = listOf("提交订单", "确认订单", "支付", "付款")
val buyWords = config.textAliases.purchase.buyWords
return source.asSequence()
.filter { it.visible && it.enabled && buyWords.any(it.label.replace(" ", "")::contains) }
.filterNot { node -> dangerousWords.any(node.label.replace(" ", "")::contains) }
.filterNot { node -> nonConfigurableClickDenylist.any(node.label.replace(" ", "")::contains) }
.mapNotNull { labelNode ->
var clickable: SnapshotNode? = labelNode
while (clickable != null && !clickable.clickable) clickable = clickable.parentPath?.let(byPath::get)
@@ -391,7 +398,7 @@ object PddScreenParser {
?.first
}
private fun hasReviewContext(node: SnapshotNode, source: List<SnapshotNode>): Boolean {
private fun hasReviewContext(node: SnapshotNode, source: List<SnapshotNode>, config: PddCollectorConfig): Boolean {
val byPath = source.associateBy(SnapshotNode::path)
val context = mutableListOf(node.label)
var parentPath = node.parentPath
@@ -401,7 +408,7 @@ object PddScreenParser {
parentPath = parent.parentPath
}
context += descendants(node, source).map(SnapshotNode::label)
return context.any { label -> reviewWords.any(label.replace(" ", "")::contains) }
return context.any { label -> config.textAliases.review.entryAliases.any(label.replace(" ", "")::contains) }
}
private fun isReviewPage(
@@ -409,10 +416,11 @@ object PddScreenParser {
visible: List<SnapshotNode>,
screenHeight: Int,
specEntry: SnapshotNode?,
config: PddCollectorConfig,
): Boolean {
if (screenHeight <= 0 || specEntry != null) return false
val hasTopTitle = visible.any { node ->
node.label.replace(" ", "") in setOf("商品评价", "全部评价", "评价详情") &&
node.label.replace(" ", "") in config.textAliases.review.entryAliases &&
node.bounds.centerY <= screenHeight * 25 / 100
}
if (!hasTopTitle) return false
@@ -420,7 +428,10 @@ object PddScreenParser {
val compact = node.label.replace(" ", "")
compact in setOf("全部", "有图/视频", "追评", "最新") || compact.contains("条评价")
}
return hasReviewContent || source.none { it.visible && it.enabled && it.bounds.centerY >= screenHeight * 80 / 100 && it.label.contains("购买") }
return hasReviewContent || source.none { node ->
node.visible && node.enabled && node.bounds.centerY >= screenHeight * 80 / 100 &&
config.textAliases.purchase.buyWords.any(node.label::contains)
}
}
private fun safeOptionRank(node: SnapshotNode, source: List<SnapshotNode>): Int = when {
@@ -438,18 +449,19 @@ object PddScreenParser {
val screenBottom = source.maxOfOrNull { it.bounds.bottom } ?: return null
if (screenBottom <= 0) return null
val compactLabels = visible.map { it.label.replace(" ", "") }
val hasClose = compactLabels.any { it in setOf("关闭", "關閉") }
val hasSummary = compactLabels.any { it.startsWith("已选") || it.startsWith("已選") }
val hasPaymentArea = compactLabels.any { label -> listOf("微信支付", "先用后付", "支付方式").any(label::contains) }
val aliases = config.textAliases
val hasClose = compactLabels.any { it in aliases.specPanel.closeAliases }
val hasSummary = compactLabels.any { label -> aliases.selection.selectedPrefixes.any(label::startsWith) }
val hasPaymentArea = compactLabels.any { label -> aliases.specPanel.paymentAreaAliases.any(label::contains) }
val hasQuickBuy = visible.any { node ->
node.label.replace(" ", "").let { it.contains("现在买") || it.contains("現在買") } &&
node.label.replace(" ", "").let { compact -> aliases.specPanel.quickBuyAliases.any(compact::contains) } &&
node.bounds.centerY.toDouble() >= screenBottom * 0.75
}
val hasQuantity = source.any {
it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
}
val hasDecrease = visible.any { it.clickable && it.label.replace(" ", "") in setOf("减少数量", "減少數量") }
val hasIncrease = visible.any { it.clickable && it.label.replace(" ", "") in setOf("增加数量", "增加數量") }
val hasDecrease = visible.any { it.clickable && it.label.replace(" ", "") in aliases.specPanel.quantityDecreaseAliases }
val hasIncrease = visible.any { it.clickable && it.label.replace(" ", "") in aliases.specPanel.quantityIncreaseAliases }
if (!hasClose || !hasSummary || !hasPaymentArea || !hasQuickBuy || !hasQuantity || !hasDecrease || !hasIncrease) return null
val headings = visible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top }
@@ -540,7 +552,7 @@ class PddProductDetailCollector(
var current = waitForPageEvidence(config.timeoutsMs.getValue("page"), goodsId, config, evidence)
?: return failure("PDD_DETAIL_ENTRY_FAILED", "未进入 PDD 商品详情页")
val recovery = rule.transientSoldOutRecovery
if (recovery?.enabled == true && current.isTransientSoldOut(recovery.exactText, recovery.fallbackTopText)) {
if (recovery?.enabled == true && current.isTransientSoldOut(recovery.exactText, recovery.fallbackTopText, config.textAliases.soldOut)) {
trace("transient-sold-out recovery=start pulls=${recovery.pullDownCount}")
repeat(recovery.pullDownCount) { index ->
if (!driver.pullDownGoodsPage()) {
@@ -550,7 +562,7 @@ class PddProductDetailCollector(
}
pause(recovery.settleMs)
current = parse(goodsId, config, evidence)
if (current.isTransientSoldOut(recovery.exactText, recovery.fallbackTopText)) {
if (current.isTransientSoldOut(recovery.exactText, recovery.fallbackTopText, config.textAliases.soldOut)) {
return failure("PDD_GOODS_SOLD_OUT", "商品页下拉恢复后仍显示已售罄")
}
if (!current.pageEvidenceMatched) {
@@ -1009,7 +1021,7 @@ class PddProductDetailCollector(
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes)
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation)
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation, config)
visible.forEach { sizes[it.text] = sizes[it.text] == true || it.available }
val signature = if (visible.isNotEmpty()) optionSignature(visible) else viewportSignature(screen)
trace("sizes pass=$pass dimensions=${screen.dimensions.joinToString { "${it.key}:${it.values.size}" }} signature=${signature.size} collected=${sizes.size}")
@@ -1127,9 +1139,10 @@ class PddProductDetailCollector(
)
}
private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?): List<VisibleSpecValue> {
private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
if (context == null) return emptyList()
val headingLabels = setOf("颜色分类", "颜色", "花色", "款式", "尺码", "尺寸", "规格", "型号", "套餐", "容量", "类型", "版本", "口味")
val headingLabels = (config.colorAliases + config.sizeAliases + config.textAliases.dimension.exactNames +
config.textAliases.dimension.adaptiveAliases).map { it.replace(" ", "") }.toSet()
val priceOnly = Regex("^(?:券后|到手价|拼单价|单买价|价格)?[¥¥]\\s*\\d+(?:\\.\\d{1,2})?$")
return screen.sourceNodes.asSequence()
.filter { it.visible && it.enabled && it.clickable }
@@ -56,6 +56,54 @@ data class PddCollectorConfig(
val sizeAliases: List<String>,
val timeoutsMs: Map<String, Int>,
val limits: Map<String, Int>,
val textAliases: PddCollectorTextAliases = PddCollectorTextAliases(),
)
data class PddCollectorTextAliases(
val specPanel: SpecPanelTextAliases = SpecPanelTextAliases(),
val dimension: DimensionTextAliases = DimensionTextAliases(),
val selection: SelectionTextAliases = SelectionTextAliases(),
val review: ReviewTextAliases = ReviewTextAliases(),
val soldOut: SoldOutTextAliases = SoldOutTextAliases(),
val purchase: PurchaseTextAliases = PurchaseTextAliases(),
)
data class SpecPanelTextAliases(
val titleAliases: List<String> = listOf("确认款式", "確認款式"),
val closeAliases: List<String> = listOf("关闭", "關閉"),
val confirmAliases: List<String> = listOf("确定", "確定", "确认", "確認"),
val quantityDecreaseAliases: List<String> = listOf("减少数量", "減少數量"),
val quantityIncreaseAliases: List<String> = listOf("增加数量", "增加數量"),
val paymentAreaAliases: List<String> = listOf("微信支付", "先用后付", "先用後付", "支付方式"),
val orderSubmitAliases: List<String> = listOf("提交订单", "提交訂單"),
val quickBuyAliases: List<String> = listOf("现在买", "現在買"),
)
data class DimensionTextAliases(
val colorSuffixes: List<String> = listOf("颜色", "款式"),
val sizeSuffixes: List<String> = listOf("尺码", "尺寸"),
val exactNames: List<String> = listOf("规格", "型号", "容量", "类型", "版本", "口味"),
val adaptiveAliases: List<String> = listOf("套餐"),
)
data class SelectionTextAliases(
val summaryPrefixes: List<String> = listOf("已选", "请选择", "已選", "請選擇"),
val selectedPrefixes: List<String> = listOf("已选", "已選"),
val specEntryPrefixes: List<String> = listOf("请选择", "选择", "已选"),
val submitHintAliases: List<String> = listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格"),
)
data class ReviewTextAliases(
val entryAliases: List<String> = listOf("商品评价", "全部评价", "评价详情", "评论", "晒单", "问答"),
)
data class SoldOutTextAliases(
val mainProductExactAliases: List<String> = listOf("进店", "单独购买", "发起拼单", "立即购买"),
val mainProductPrefixAliases: List<String> = listOf("商品评价"),
)
data class PurchaseTextAliases(
val buyWords: List<String> = listOf("单独购买", "立即购买", "发起拼单", "去拼单", "免拼购买", "购买"),
)
data class TransientSoldOutRecovery(
@@ -210,10 +258,9 @@ object RuleParser {
rejectUnknown(value, setOf("transientSoldOut"), "pageRecovery")
val recovery = value.optJSONObject("transientSoldOut") ?: return null
rejectUnknown(recovery, setOf("enabled", "exactText", "fallbackTopText", "pullDownCount", "intervalMs", "settleMs", "maxAttempts"), "pageRecovery.transientSoldOut")
val exactText = recovery.optString("exactText")
if (exactText != "商品已售罄") invalid("pageRecovery.transientSoldOut.exactText 只能是商品已售罄")
val exactText = parseReadOnlyText(recovery.optString("exactText"), "pageRecovery.transientSoldOut.exactText")
val fallbackTopText = recovery.optString("fallbackTopText", "相似商品")
if (fallbackTopText != "相似商品") invalid("pageRecovery.transientSoldOut.fallbackTopText 只能是相似商品")
parseReadOnlyText(fallbackTopText, "pageRecovery.transientSoldOut.fallbackTopText")
val pullDownCount = recovery.optInt("pullDownCount")
val intervalMs = recovery.optLong("intervalMs")
val settleMs = recovery.optLong("settleMs")
@@ -302,7 +349,7 @@ object RuleParser {
}
private fun parseCollector(value: JSONObject): PddCollectorConfig {
rejectUnknown(value, setOf("collectorId", "specEntryStrategy", "priceParser", "priceGranularity", "dimensionAliases", "timeoutsMs", "limits"), "collector")
rejectUnknown(value, setOf("collectorId", "specEntryStrategy", "priceParser", "priceGranularity", "dimensionAliases", "timeoutsMs", "limits", "textAliases"), "collector")
if (value.optString("collectorId") != "pddProductDetailV1" ||
value.optString("specEntryStrategy") != "safeBottomSpecEntryV1" ||
value.optString("priceParser") != "pddRmbPriceV1" ||
@@ -320,15 +367,67 @@ object RuleParser {
value.optJSONObject("limits") ?: invalid("limits 必填"),
mapOf("goodsPageVerticalSwipes" to 0..10, "specHorizontalSwipes" to 0..30, "specVerticalSwipes" to 0..30, "stableEdgeReads" to 1..5, "stablePriceReads" to 2..5, "maxSkuCount" to 1..2_000),
)
return PddCollectorConfig("pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color", colorAliases, sizeAliases, timeouts, limits)
val textAliases = value.optJSONObject("textAliases")?.let(::parseTextAliases) ?: PddCollectorTextAliases()
return PddCollectorConfig("pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color", colorAliases, sizeAliases, timeouts, limits, textAliases)
}
private fun parseAliases(items: JSONArray?, name: String): List<String> {
items ?: invalid("dimensionAliases.$name 必填")
if (items.length() !in 1..20) invalid("dimensionAliases.$name 必须包含 1..20 项")
return (0 until items.length()).map { index ->
items.optString(index).trim().also { if (it.isEmpty() || it.length > 30) invalid("dimensionAliases.$name 含无效别名") }
}.distinct()
val values = (0 until items.length()).map { index ->
items.optString(index).also { if (it != it.trim() || it.isEmpty() || it.codePointCount(0, it.length) > 30) invalid("dimensionAliases.$name 含无效别名") }
}
if (values.distinct().size != values.size) invalid("dimensionAliases.$name 不能重复")
return values
}
private fun parseTextAliases(value: JSONObject): PddCollectorTextAliases {
rejectUnknown(value, setOf("specPanel", "dimension", "selection", "review", "soldOut", "purchase"), "collector.textAliases")
fun section(name: String, fields: Set<String>) = value.optJSONObject(name)?.also {
rejectUnknown(it, fields, "collector.textAliases.$name")
} ?: invalid("collector.textAliases.$name 必填")
val spec = section("specPanel", setOf("titleAliases", "closeAliases", "confirmAliases", "quantityDecreaseAliases", "quantityIncreaseAliases", "paymentAreaAliases", "orderSubmitAliases", "quickBuyAliases"))
val dimension = section("dimension", setOf("colorSuffixes", "sizeSuffixes", "exactNames", "adaptiveAliases"))
val selection = section("selection", setOf("summaryPrefixes", "selectedPrefixes", "specEntryPrefixes", "submitHintAliases"))
val review = section("review", setOf("entryAliases"))
val soldOut = section("soldOut", setOf("mainProductExactAliases", "mainProductPrefixAliases"))
val purchase = section("purchase", setOf("buyWords"))
fun aliases(section: JSONObject, sectionName: String, field: String) =
parseReadOnlyAliases(section.optJSONArray(field), "collector.textAliases.$sectionName.$field")
return PddCollectorTextAliases(
SpecPanelTextAliases(
aliases(spec, "specPanel", "titleAliases"), aliases(spec, "specPanel", "closeAliases"),
aliases(spec, "specPanel", "confirmAliases"), aliases(spec, "specPanel", "quantityDecreaseAliases"),
aliases(spec, "specPanel", "quantityIncreaseAliases"), aliases(spec, "specPanel", "paymentAreaAliases"),
aliases(spec, "specPanel", "orderSubmitAliases"), aliases(spec, "specPanel", "quickBuyAliases"),
),
DimensionTextAliases(
aliases(dimension, "dimension", "colorSuffixes"), aliases(dimension, "dimension", "sizeSuffixes"),
aliases(dimension, "dimension", "exactNames"), aliases(dimension, "dimension", "adaptiveAliases"),
),
SelectionTextAliases(
aliases(selection, "selection", "summaryPrefixes"), aliases(selection, "selection", "selectedPrefixes"),
aliases(selection, "selection", "specEntryPrefixes"), aliases(selection, "selection", "submitHintAliases"),
),
ReviewTextAliases(aliases(review, "review", "entryAliases")),
SoldOutTextAliases(aliases(soldOut, "soldOut", "mainProductExactAliases"), aliases(soldOut, "soldOut", "mainProductPrefixAliases")),
PurchaseTextAliases(aliases(purchase, "purchase", "buyWords")),
)
}
private fun parseReadOnlyAliases(items: JSONArray?, label: String): List<String> {
items ?: invalid("$label 必填")
if (items.length() !in 1..20) invalid("$label 必须包含 1..20 项")
val values = (0 until items.length()).map { parseReadOnlyText(items.optString(it), label) }
if (values.distinct().size != values.size) invalid("$label 不能重复")
return values
}
private fun parseReadOnlyText(value: String, label: String): String {
if (value != value.trim() || value.isEmpty() || value.codePointCount(0, value.length) > 30 ||
listOf("修改地址", "收货地址").any(value::contains)
) invalid("$label 含空值、地址操作文字或过长文字")
return value
}
private fun parseBoundedMap(value: JSONObject, allowed: Map<String, IntRange>): Map<String, Int> {
@@ -112,6 +112,18 @@ class PddProductDetailCollectorTest {
assertEquals(listOf("红色", "蓝色", "黑色", "白色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text })
}
@Test
fun dimensionAliasesCanTrulyNarrowRecognition() {
val base = config()
val narrowed = base.copy(
colorAliases = listOf("色系"),
)
val parsed = PddScreenParser.parse(FakeCollectorDriver().capture(), narrowed, GOODS_ID, evidence())
assertFalse(parsed.dimensions.any { it.key == "color" })
}
@Test
fun unrelatedLongTextIsNotUsedAsProductTitle() {
val driver = FakeCollectorDriver()
@@ -134,6 +134,19 @@ class RuleExecutorTest {
}
}
@Test
fun `v2 collector text aliases allow read-only order evidence but reject address operations`() {
val configured = validV2Rule().replace(
"\"timeoutsMs\"",
"\"textAliases\":" + textAliasesJson() + ",\"timeoutsMs\"",
)
val rule = RuleParser.parse(configured)
assertEquals(listOf("提交订单", "提交訂單"), rule.collector?.textAliases?.specPanel?.orderSubmitAliases)
assertTrue(runCatching { RuleParser.parse(configured.replace("单独购买", "收货地址")) }.isFailure)
}
private fun validV2Rule() = """{
"schemaVersion":2,"ruleType":"pddProductDetail",
"navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]},
@@ -144,6 +157,15 @@ class RuleExecutorTest {
"collector":{"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color","dimensionAliases":{"color":["颜色"],"size":["尺码"]},"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}}
}"""
private fun textAliasesJson() = """{
"specPanel":{"titleAliases":["确认款式","確認款式"],"closeAliases":["关闭","關閉"],"confirmAliases":["确定","確定","确认","確認"],"quantityDecreaseAliases":["减少数量","減少數量"],"quantityIncreaseAliases":["增加数量","增加數量"],"paymentAreaAliases":["微信支付","先用后付","先用後付","支付方式"],"orderSubmitAliases":["提交订单","提交訂單"],"quickBuyAliases":["现在买","現在買"]},
"dimension":{"colorSuffixes":["颜色","款式"],"sizeSuffixes":["尺码","尺寸"],"exactNames":["规格","型号","容量","类型","版本","口味"],"adaptiveAliases":["套餐"]},
"selection":{"summaryPrefixes":["已选","请选择","已選","請選擇"],"selectedPrefixes":["已选","已選"],"specEntryPrefixes":["请选择","选择","已选"],"submitHintAliases":["选择","選擇","颜色","顏色","尺码","尺碼","规格","規格"]},
"review":{"entryAliases":["商品评价","全部评价","评价详情","评论","晒单","问答"]},
"soldOut":{"mainProductExactAliases":["进店","单独购买","发起拼单","立即购买"],"mainProductPrefixAliases":["商品评价"]},
"purchase":{"buyWords":["单独购买","立即购买","发起拼单","去拼单","免拼购买","购买"]}
}"""
private class FakeDriver(
private val nodes: Map<String, List<UiNodeRef>>,
private val activity: String = "com.xunmeng.pinduoduo.activity.NewPageActivity",