feat(#115): move PDD recognition aliases into rules
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+68
-55
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user