feat(#115): move PDD recognition aliases into rules
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "cn.ilapage.goauto.agent"
|
applicationId = "cn.ilapage.goauto.agent"
|
||||||
minSdk = 23
|
minSdk = 23
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 23
|
versionCode = 24
|
||||||
versionName = "0.9.10"
|
versionName = "0.9.11"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
+68
-55
@@ -100,7 +100,11 @@ data class ParsedPddScreen(
|
|||||||
val problem: PageProblem?,
|
val problem: PageProblem?,
|
||||||
val sourceNodes: List<SnapshotNode>,
|
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 }
|
val visible = sourceNodes.filter { it.visible }
|
||||||
fun normalized(value: String) = value.replace(Regex("\\s+"), "")
|
fun normalized(value: String) = value.replace(Regex("\\s+"), "")
|
||||||
val hasExactText = visible.any { normalized(it.label) == normalized(exactText) }
|
val hasExactText = visible.any { normalized(it.label) == normalized(exactText) }
|
||||||
@@ -110,7 +114,10 @@ data class ParsedPddScreen(
|
|||||||
}
|
}
|
||||||
val labels = visible.map { normalized(it.label) }
|
val labels = visible.map { normalized(it.label) }
|
||||||
val hasPrimaryProductEvidence = specPanelOpen || specEntry != null || quickConfirmationEntry != null ||
|
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
|
return pageEvidenceMatched && (hasExactText || hasFallbackAtTop) && !hasPrimaryProductEvidence
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +133,9 @@ object PddScreenParser {
|
|||||||
"已選", "請選擇", "數量", "打開大圖", "查看大圖", "一次選多款",
|
"已選", "請選擇", "數量", "打開大圖", "查看大圖", "一次選多款",
|
||||||
)
|
)
|
||||||
private val excludedExactOptionLabels = setOf("客服", "联系客服", "聯繫客服", "店铺", "店鋪", "收藏", "关闭", "關閉")
|
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 {
|
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen {
|
||||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||||
@@ -145,14 +154,13 @@ object PddScreenParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val labels = visible.map(SnapshotNode::label)
|
val labels = visible.map(SnapshotNode::label)
|
||||||
|
val textAliases = config.textAliases
|
||||||
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
|
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
|
||||||
val compactLabels = labels.map { it.replace(" ", "") }
|
val compactLabels = labels.map { it.replace(" ", "") }
|
||||||
val hasSelectionSummary = compactLabels.any {
|
val hasSelectionSummary = compactLabels.any { label -> textAliases.selection.summaryPrefixes.any(label::startsWith) }
|
||||||
it.startsWith("已选") || it.startsWith("请选择") || it.startsWith("已選") || it.startsWith("請選擇")
|
|
||||||
}
|
|
||||||
val hasSubmitHint = compactLabels.any { label ->
|
val hasSubmitHint = compactLabels.any { label ->
|
||||||
(label.contains("提交订单") || label.contains("提交訂單")) &&
|
textAliases.specPanel.orderSubmitAliases.any(label::contains) &&
|
||||||
listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格").any(label::contains)
|
textAliases.selection.submitHintAliases.any(label::contains)
|
||||||
}
|
}
|
||||||
val allHeadings = visible.filter { !it.clickable && isHeading(it.label, config) }
|
val allHeadings = visible.filter { !it.clickable && isHeading(it.label, config) }
|
||||||
val screenWidth = visibleNodes.maxOfOrNull { it.bounds.right } ?: 0
|
val screenWidth = visibleNodes.maxOfOrNull { it.bounds.right } ?: 0
|
||||||
@@ -168,27 +176,27 @@ object PddScreenParser {
|
|||||||
val headedPanelScrollable = boundedScrollables.asSequence()
|
val headedPanelScrollable = boundedScrollables.asSequence()
|
||||||
.filter { region -> allHeadings.any { heading -> heading.path.startsWith("${region.path}/") } }
|
.filter { region -> allHeadings.any { heading -> heading.path.startsWith("${region.path}/") } }
|
||||||
.maxByOrNull { it.bounds.width.toLong() * it.bounds.height }
|
.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 hasPanelAction = visible.any { node ->
|
||||||
val compact = node.label.replace(" ", "")
|
val compact = node.label.replace(" ", "")
|
||||||
node.clickable && (
|
node.clickable && (
|
||||||
compact in setOf("确定", "確定", "确认", "確認") ||
|
compact in textAliases.specPanel.confirmAliases ||
|
||||||
((compact.contains("提交订单") || compact.contains("提交訂單")) &&
|
(textAliases.specPanel.orderSubmitAliases.any(compact::contains) &&
|
||||||
listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格").any(compact::contains))
|
textAliases.selection.submitHintAliases.any(compact::contains))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val quantityInputs = visibleNodes.filter {
|
val quantityInputs = visibleNodes.filter {
|
||||||
it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
|
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 decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
|
||||||
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in setOf("增加数量", "增加數量") }
|
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 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 ->
|
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
|
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 quickConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea && hasQuickBuy && hasQuantityControls
|
||||||
val orderSubmitCandidates = visibleNodes.asSequence()
|
val orderSubmitCandidates = visibleNodes.asSequence()
|
||||||
.filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 }
|
.filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 }
|
||||||
@@ -196,11 +204,11 @@ object PddScreenParser {
|
|||||||
.filter { node ->
|
.filter { node ->
|
||||||
val compact = (listOf(node.label) + descendants(node, visibleNodes).map(SnapshotNode::label))
|
val compact = (listOf(node.label) + descendants(node, visibleNodes).map(SnapshotNode::label))
|
||||||
.joinToString("") { it.replace(" ", "") }
|
.joinToString("") { it.replace(" ", "") }
|
||||||
compact.contains("提交订单") || compact.contains("提交訂單")
|
textAliases.specPanel.orderSubmitAliases.any(compact::contains)
|
||||||
}
|
}
|
||||||
.toList()
|
.toList()
|
||||||
.filter { candidate ->
|
.filter { candidate ->
|
||||||
orderSubmitCandidatesAncestorFree(candidate, visibleNodes)
|
orderSubmitCandidatesAncestorFree(candidate, visibleNodes, textAliases.specPanel.orderSubmitAliases)
|
||||||
}
|
}
|
||||||
val hasOrderSubmitAction = orderSubmitCandidates.size == 1
|
val hasOrderSubmitAction = orderSubmitCandidates.size == 1
|
||||||
val orderConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea &&
|
val orderConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea &&
|
||||||
@@ -252,16 +260,16 @@ object PddScreenParser {
|
|||||||
.mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) }
|
.mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) }
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
val explicitSpecEntry = if (panelOpen) null else visible
|
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 }
|
.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 specEntry = explicitSpecEntry ?: bottomSpecEntry
|
||||||
val specEntrySource = when {
|
val specEntrySource = when {
|
||||||
explicitSpecEntry != null -> "explicit_selection"
|
explicitSpecEntry != null -> "explicit_selection"
|
||||||
bottomSpecEntry != null -> "bottom_purchase"
|
bottomSpecEntry != null -> "bottom_purchase"
|
||||||
else -> null
|
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 quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null
|
||||||
val rootAvailable = snapshot.packageName != null || snapshot.activityName != null || snapshot.nodes.isNotEmpty()
|
val rootAvailable = snapshot.packageName != null || snapshot.activityName != null || snapshot.nodes.isNotEmpty()
|
||||||
val packageMatched = evidence == null || snapshot.packageName == evidence.packageName
|
val packageMatched = evidence == null || snapshot.packageName == evidence.packageName
|
||||||
@@ -281,7 +289,7 @@ object PddScreenParser {
|
|||||||
dimensions = dimensions,
|
dimensions = dimensions,
|
||||||
selectedSummary = labels.firstOrNull {
|
selectedSummary = labels.firstOrNull {
|
||||||
val compact = it.replace(" ", "")
|
val compact = it.replace(" ", "")
|
||||||
compact.startsWith("已选") || compact.startsWith("已選")
|
textAliases.selection.selectedPrefixes.any(compact::startsWith)
|
||||||
},
|
},
|
||||||
priceCent = price,
|
priceCent = price,
|
||||||
specPanelOpen = panelOpen,
|
specPanelOpen = panelOpen,
|
||||||
@@ -312,13 +320,13 @@ object PddScreenParser {
|
|||||||
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
|
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}/"
|
val candidatePrefix = "${candidate.path}/"
|
||||||
return source.none { node ->
|
return source.none { node ->
|
||||||
node.path.startsWith(candidatePrefix) && node.visible && node.enabled && node.clickable &&
|
node.path.startsWith(candidatePrefix) && node.visible && node.enabled && node.clickable &&
|
||||||
(listOf(node.label) + descendants(node, source).map(SnapshotNode::label))
|
(listOf(node.label) + descendants(node, source).map(SnapshotNode::label))
|
||||||
.joinToString("") { it.replace(" ", "") }
|
.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 {
|
private fun isHeading(label: String, config: PddCollectorConfig): Boolean {
|
||||||
val compact = label.replace(" ", "").replace(Regex("[((]\\d+[))]$"), "")
|
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(" ", "") } ||
|
return (config.colorAliases + config.sizeAliases).any { compact == it.replace(" ", "") } ||
|
||||||
compact.endsWith("颜色") || compact.endsWith("款式") || compact.endsWith("尺码") ||
|
(config.textAliases.dimension.colorSuffixes + config.textAliases.dimension.sizeSuffixes +
|
||||||
compact.endsWith("尺寸") || compact.endsWith("规格") || compact.endsWith("型号") || compact.endsWith("套餐") ||
|
config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases).any(compact::endsWith)
|
||||||
compact.endsWith("容量") || compact.endsWith("类型") || compact.endsWith("版本") || compact.endsWith("口味")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isExactHeadingLabel(label: String, config: PddCollectorConfig): Boolean {
|
private fun isExactHeadingLabel(label: String, config: PddCollectorConfig): Boolean {
|
||||||
val compact = label.replace(" ", "").replace(Regex("[((]\\d+[))]$"), "")
|
val compact = label.replace(" ", "").replace(Regex("[((]\\d+[))]$"), "")
|
||||||
return (config.colorAliases + config.sizeAliases).any { compact == it.replace(" ", "") } ||
|
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 {
|
private fun dimensionKey(label: String, config: PddCollectorConfig, hasColor: Boolean): String = when {
|
||||||
config.colorAliases.any { label.contains(it) } || label.contains("颜色") || label.contains("款式") -> "color"
|
config.colorAliases.any { label.contains(it) } -> "color"
|
||||||
label.contains("套餐") -> if (hasColor) "size" else "color"
|
config.textAliases.dimension.adaptiveAliases.any(label::contains) -> if (hasColor) "size" else "color"
|
||||||
config.sizeAliases.any { label.contains(it) } || label.contains("尺码") || label.contains("尺寸") -> "size"
|
config.sizeAliases.any { label.contains(it) } -> "size"
|
||||||
else -> "unsupported"
|
else -> "unsupported"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isSpecEntry(label: String, config: PddCollectorConfig): Boolean {
|
private fun isSpecEntry(label: String, config: PddCollectorConfig): Boolean {
|
||||||
val compact = label.replace(" ", "")
|
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 aliases = config.colorAliases + config.sizeAliases
|
||||||
val hasSpecWord = aliases.any(compact::contains) || compact.contains("规格")
|
val hasSpecWord = aliases.any(compact::contains) ||
|
||||||
return hasSpecWord && listOf("请选择", "选择", "已选").any(compact::startsWith)
|
(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 screenWidth = source.maxOfOrNull { it.bounds.right } ?: return null
|
||||||
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return null
|
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||||
if (screenWidth <= 0 || screenHeight <= 0) return null
|
if (screenWidth <= 0 || screenHeight <= 0) return null
|
||||||
val byPath = source.associateBy(SnapshotNode::path)
|
val byPath = source.associateBy(SnapshotNode::path)
|
||||||
val normalizedByPath = visible.associateBy(SnapshotNode::path)
|
val normalizedByPath = visible.associateBy(SnapshotNode::path)
|
||||||
val buyWords = listOf("单独购买", "立即购买", "发起拼单", "去拼单", "免拼购买", "购买")
|
val buyWords = config.textAliases.purchase.buyWords
|
||||||
val dangerousWords = listOf("提交订单", "确认订单", "支付", "付款")
|
|
||||||
return source.asSequence()
|
return source.asSequence()
|
||||||
.filter { it.visible && it.enabled && buyWords.any(it.label.replace(" ", "")::contains) }
|
.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 ->
|
.mapNotNull { labelNode ->
|
||||||
var clickable: SnapshotNode? = labelNode
|
var clickable: SnapshotNode? = labelNode
|
||||||
while (clickable != null && !clickable.clickable) clickable = clickable.parentPath?.let(byPath::get)
|
while (clickable != null && !clickable.clickable) clickable = clickable.parentPath?.let(byPath::get)
|
||||||
@@ -391,7 +398,7 @@ object PddScreenParser {
|
|||||||
?.first
|
?.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 byPath = source.associateBy(SnapshotNode::path)
|
||||||
val context = mutableListOf(node.label)
|
val context = mutableListOf(node.label)
|
||||||
var parentPath = node.parentPath
|
var parentPath = node.parentPath
|
||||||
@@ -401,7 +408,7 @@ object PddScreenParser {
|
|||||||
parentPath = parent.parentPath
|
parentPath = parent.parentPath
|
||||||
}
|
}
|
||||||
context += descendants(node, source).map(SnapshotNode::label)
|
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(
|
private fun isReviewPage(
|
||||||
@@ -409,10 +416,11 @@ object PddScreenParser {
|
|||||||
visible: List<SnapshotNode>,
|
visible: List<SnapshotNode>,
|
||||||
screenHeight: Int,
|
screenHeight: Int,
|
||||||
specEntry: SnapshotNode?,
|
specEntry: SnapshotNode?,
|
||||||
|
config: PddCollectorConfig,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (screenHeight <= 0 || specEntry != null) return false
|
if (screenHeight <= 0 || specEntry != null) return false
|
||||||
val hasTopTitle = visible.any { node ->
|
val hasTopTitle = visible.any { node ->
|
||||||
node.label.replace(" ", "") in setOf("商品评价", "全部评价", "评价详情") &&
|
node.label.replace(" ", "") in config.textAliases.review.entryAliases &&
|
||||||
node.bounds.centerY <= screenHeight * 25 / 100
|
node.bounds.centerY <= screenHeight * 25 / 100
|
||||||
}
|
}
|
||||||
if (!hasTopTitle) return false
|
if (!hasTopTitle) return false
|
||||||
@@ -420,7 +428,10 @@ object PddScreenParser {
|
|||||||
val compact = node.label.replace(" ", "")
|
val compact = node.label.replace(" ", "")
|
||||||
compact in setOf("全部", "有图/视频", "追评", "最新") || compact.contains("条评价")
|
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 {
|
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
|
val screenBottom = source.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||||
if (screenBottom <= 0) return null
|
if (screenBottom <= 0) return null
|
||||||
val compactLabels = visible.map { it.label.replace(" ", "") }
|
val compactLabels = visible.map { it.label.replace(" ", "") }
|
||||||
val hasClose = compactLabels.any { it in setOf("关闭", "關閉") }
|
val aliases = config.textAliases
|
||||||
val hasSummary = compactLabels.any { it.startsWith("已选") || it.startsWith("已選") }
|
val hasClose = compactLabels.any { it in aliases.specPanel.closeAliases }
|
||||||
val hasPaymentArea = compactLabels.any { label -> listOf("微信支付", "先用后付", "支付方式").any(label::contains) }
|
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 ->
|
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
|
node.bounds.centerY.toDouble() >= screenBottom * 0.75
|
||||||
}
|
}
|
||||||
val hasQuantity = source.any {
|
val hasQuantity = source.any {
|
||||||
it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
|
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 hasDecrease = visible.any { it.clickable && it.label.replace(" ", "") in aliases.specPanel.quantityDecreaseAliases }
|
||||||
val hasIncrease = visible.any { it.clickable && it.label.replace(" ", "") in setOf("增加数量", "增加數量") }
|
val hasIncrease = visible.any { it.clickable && it.label.replace(" ", "") in aliases.specPanel.quantityIncreaseAliases }
|
||||||
if (!hasClose || !hasSummary || !hasPaymentArea || !hasQuickBuy || !hasQuantity || !hasDecrease || !hasIncrease) return null
|
if (!hasClose || !hasSummary || !hasPaymentArea || !hasQuickBuy || !hasQuantity || !hasDecrease || !hasIncrease) return null
|
||||||
|
|
||||||
val headings = visible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top }
|
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)
|
var current = waitForPageEvidence(config.timeoutsMs.getValue("page"), goodsId, config, evidence)
|
||||||
?: return failure("PDD_DETAIL_ENTRY_FAILED", "未进入 PDD 商品详情页")
|
?: return failure("PDD_DETAIL_ENTRY_FAILED", "未进入 PDD 商品详情页")
|
||||||
val recovery = rule.transientSoldOutRecovery
|
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}")
|
trace("transient-sold-out recovery=start pulls=${recovery.pullDownCount}")
|
||||||
repeat(recovery.pullDownCount) { index ->
|
repeat(recovery.pullDownCount) { index ->
|
||||||
if (!driver.pullDownGoodsPage()) {
|
if (!driver.pullDownGoodsPage()) {
|
||||||
@@ -550,7 +562,7 @@ class PddProductDetailCollector(
|
|||||||
}
|
}
|
||||||
pause(recovery.settleMs)
|
pause(recovery.settleMs)
|
||||||
current = parse(goodsId, config, evidence)
|
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", "商品页下拉恢复后仍显示已售罄")
|
return failure("PDD_GOODS_SOLD_OUT", "商品页下拉恢复后仍显示已售罄")
|
||||||
}
|
}
|
||||||
if (!current.pageEvidenceMatched) {
|
if (!current.pageEvidenceMatched) {
|
||||||
@@ -1009,7 +1021,7 @@ class PddProductDetailCollector(
|
|||||||
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
|
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
|
||||||
val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
|
val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
|
||||||
if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes)
|
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 }
|
visible.forEach { sizes[it.text] = sizes[it.text] == true || it.available }
|
||||||
val signature = if (visible.isNotEmpty()) optionSignature(visible) else viewportSignature(screen)
|
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}")
|
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()
|
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})?$")
|
val priceOnly = Regex("^(?:券后|到手价|拼单价|单买价|价格)?[¥¥]\\s*\\d+(?:\\.\\d{1,2})?$")
|
||||||
return screen.sourceNodes.asSequence()
|
return screen.sourceNodes.asSequence()
|
||||||
.filter { it.visible && it.enabled && it.clickable }
|
.filter { it.visible && it.enabled && it.clickable }
|
||||||
|
|||||||
@@ -56,6 +56,54 @@ data class PddCollectorConfig(
|
|||||||
val sizeAliases: List<String>,
|
val sizeAliases: List<String>,
|
||||||
val timeoutsMs: Map<String, Int>,
|
val timeoutsMs: Map<String, Int>,
|
||||||
val limits: 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(
|
data class TransientSoldOutRecovery(
|
||||||
@@ -210,10 +258,9 @@ object RuleParser {
|
|||||||
rejectUnknown(value, setOf("transientSoldOut"), "pageRecovery")
|
rejectUnknown(value, setOf("transientSoldOut"), "pageRecovery")
|
||||||
val recovery = value.optJSONObject("transientSoldOut") ?: return null
|
val recovery = value.optJSONObject("transientSoldOut") ?: return null
|
||||||
rejectUnknown(recovery, setOf("enabled", "exactText", "fallbackTopText", "pullDownCount", "intervalMs", "settleMs", "maxAttempts"), "pageRecovery.transientSoldOut")
|
rejectUnknown(recovery, setOf("enabled", "exactText", "fallbackTopText", "pullDownCount", "intervalMs", "settleMs", "maxAttempts"), "pageRecovery.transientSoldOut")
|
||||||
val exactText = recovery.optString("exactText")
|
val exactText = parseReadOnlyText(recovery.optString("exactText"), "pageRecovery.transientSoldOut.exactText")
|
||||||
if (exactText != "商品已售罄") invalid("pageRecovery.transientSoldOut.exactText 只能是商品已售罄")
|
|
||||||
val fallbackTopText = recovery.optString("fallbackTopText", "相似商品")
|
val fallbackTopText = recovery.optString("fallbackTopText", "相似商品")
|
||||||
if (fallbackTopText != "相似商品") invalid("pageRecovery.transientSoldOut.fallbackTopText 只能是相似商品")
|
parseReadOnlyText(fallbackTopText, "pageRecovery.transientSoldOut.fallbackTopText")
|
||||||
val pullDownCount = recovery.optInt("pullDownCount")
|
val pullDownCount = recovery.optInt("pullDownCount")
|
||||||
val intervalMs = recovery.optLong("intervalMs")
|
val intervalMs = recovery.optLong("intervalMs")
|
||||||
val settleMs = recovery.optLong("settleMs")
|
val settleMs = recovery.optLong("settleMs")
|
||||||
@@ -302,7 +349,7 @@ object RuleParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCollector(value: JSONObject): PddCollectorConfig {
|
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" ||
|
if (value.optString("collectorId") != "pddProductDetailV1" ||
|
||||||
value.optString("specEntryStrategy") != "safeBottomSpecEntryV1" ||
|
value.optString("specEntryStrategy") != "safeBottomSpecEntryV1" ||
|
||||||
value.optString("priceParser") != "pddRmbPriceV1" ||
|
value.optString("priceParser") != "pddRmbPriceV1" ||
|
||||||
@@ -320,15 +367,67 @@ object RuleParser {
|
|||||||
value.optJSONObject("limits") ?: invalid("limits 必填"),
|
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),
|
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> {
|
private fun parseAliases(items: JSONArray?, name: String): List<String> {
|
||||||
items ?: invalid("dimensionAliases.$name 必填")
|
items ?: invalid("dimensionAliases.$name 必填")
|
||||||
if (items.length() !in 1..20) invalid("dimensionAliases.$name 必须包含 1..20 项")
|
if (items.length() !in 1..20) invalid("dimensionAliases.$name 必须包含 1..20 项")
|
||||||
return (0 until items.length()).map { index ->
|
val values = (0 until items.length()).map { index ->
|
||||||
items.optString(index).trim().also { if (it.isEmpty() || it.length > 30) invalid("dimensionAliases.$name 含无效别名") }
|
items.optString(index).also { if (it != it.trim() || it.isEmpty() || it.codePointCount(0, it.length) > 30) invalid("dimensionAliases.$name 含无效别名") }
|
||||||
}.distinct()
|
}
|
||||||
|
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> {
|
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 })
|
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
|
@Test
|
||||||
fun unrelatedLongTextIsNotUsedAsProductTitle() {
|
fun unrelatedLongTextIsNotUsedAsProductTitle() {
|
||||||
val driver = FakeCollectorDriver()
|
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() = """{
|
private fun validV2Rule() = """{
|
||||||
"schemaVersion":2,"ruleType":"pddProductDetail",
|
"schemaVersion":2,"ruleType":"pddProductDetail",
|
||||||
"navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]},
|
"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}}
|
"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 class FakeDriver(
|
||||||
private val nodes: Map<String, List<UiNodeRef>>,
|
private val nodes: Map<String, List<UiNodeRef>>,
|
||||||
private val activity: String = "com.xunmeng.pinduoduo.activity.NewPageActivity",
|
private val activity: String = "com.xunmeng.pinduoduo.activity.NewPageActivity",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Business-Rules-and-Glossary
|
wiki_page: Business-Rules-and-Glossary
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||||
wiki_revision: 26618d4312a1e7091f6b483111c31e67f70f26eb
|
wiki_revision: b208729957c26801012279a8e89fc18b350f3a87
|
||||||
synchronized_at: 2026-08-27T09:57:37Z
|
synchronized_at: 2026-08-27T10:14:02Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 业务规则与术语
|
# 业务规则与术语
|
||||||
@@ -171,6 +171,7 @@ synchronized_at: 2026-08-27T09:57:37Z
|
|||||||
- 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。
|
- 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。
|
||||||
- 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)、[#62](https://git.ilapage.cn/OPC/goauto/issues/62)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI Provider 只有一个 OpenAI-compatible 配置,由管理员维护。根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只向管理员设置接口返回以便查看和替换;它仍不得写入代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Provider Base URL 不限制内网或公网,支持 HTTP/HTTPS;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。
|
- 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)、[#62](https://git.ilapage.cn/OPC/goauto/issues/62)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI Provider 只有一个 OpenAI-compatible 配置,由管理员维护。根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只向管理员设置接口返回以便查看和替换;它仍不得写入代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Provider Base URL 不限制内网或公网,支持 HTTP/HTTPS;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。
|
||||||
- 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。
|
- 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。
|
||||||
|
- PDD 采集规则的 `collector.textAliases` 是规格面板、维度标题、选择摘要、评价入口、售罄主商品证据和购买入口等只读识别文案的服务端下发事实源;缺省字段只为旧快照兼容使用 Agent 内置默认值。颜色/尺码分类不得再用代码字面量扩大 `dimensionAliases`。订单和支付文字可以参与只读页面判定,但 Agent 内不可配置的创建订单/确认订单/支付/付款点击拒绝清单始终优先,规则不能放宽;修改地址和收货地址在只读及点击字段中都禁止。
|
||||||
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
|
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
|
||||||
- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。
|
- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Android-Agent-API-Contract
|
wiki_page: Android-Agent-API-Contract
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||||
wiki_revision: 3d6a43359e015d4d6eaeb896f63dc1920ca601f6
|
wiki_revision: 0bbe3f1041a0795a156dcd1205dbe85474e80f8c
|
||||||
synchronized_at: 2026-08-27T09:58:31Z
|
synchronized_at: 2026-08-27T10:14:59Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# MVP 共享 API 契约
|
# MVP 共享 API 契约
|
||||||
@@ -153,9 +153,9 @@ v2 完整示例见 [PDD 商品详情规则](rules/pdd-product-detail-v2.proposed
|
|||||||
- `pageEvidence`:精确的 PDD 包名、Activity 和非空节点证据。
|
- `pageEvidence`:精确的 PDD 包名、Activity 和非空节点证据。
|
||||||
- `collector.collectorId: pddProductDetailV1`:引用 Agent 中经过测试的类型化能力,不下发可执行代码。
|
- `collector.collectorId: pddProductDetailV1`:引用 Agent 中经过测试的类型化能力,不下发可执行代码。
|
||||||
- `hooks.afterSpecPanelOpen`:固定阶段的安全动作;当前只允许对语义目标 `specPanel` 执行 `swipe`,方向为上下左右、单动作次数 1~5、等待 0~2000 毫秒,单阶段最多 8 个动作。
|
- `hooks.afterSpecPanelOpen`:固定阶段的安全动作;当前只允许对语义目标 `specPanel` 执行 `swipe`,方向为上下左右、单动作次数 1~5、等待 0~2000 毫秒,单阶段最多 8 个动作。
|
||||||
- `pageRecovery.transientSoldOut`:商品详情页精确显示“商品已售罄”,或顶部区域出现固定兜底标题“相似商品”且缺少主商品规格/购买强证据时,允许对商品页执行一次有界恢复;默认下拉 2 次、间隔 1000 毫秒。推荐卡片自身的标题、价格和销量不属于主商品证据。`fallbackTopText` 缺省时 Agent 使用“相似商品”兼容旧快照。Agent 必须声明无障碍手势能力,且只把系统回调完成的下拉计为成功。规格面板内的 SKU 售罄不触发。恢复后仍异常时失败码为 `PDD_GOODS_SOLD_OUT`。
|
- `pageRecovery.transientSoldOut`:商品详情页出现规则配置的 `exactText`,或顶部区域出现 `fallbackTopText` 且缺少主商品规格/购买强证据时,允许对商品页执行一次有界恢复;内置默认值仍为“商品已售罄”与“相似商品”,默认下拉 2 次、间隔 1000 毫秒。推荐卡片自身的标题、价格和销量不属于主商品证据。`fallbackTopText` 缺省时 Agent 使用“相似商品”兼容旧快照。Agent 必须声明无障碍手势能力,且只把系统回调完成的下拉计为成功。规格面板内的 SKU 售罄不触发。恢复后仍异常时失败码为 `PDD_GOODS_SOLD_OUT`。
|
||||||
- `navigationRecovery.reopenBrowser`:首次跳转未通过精确商品详情页证据时,Agent 重新显式打开任务 `urlSnapshot` 并重放安全导航步骤;最多恢复 1 次,重开后等待 500~5000 毫秒。登录、验证码、风控、无效链接和进入详情页后的采集失败不触发。
|
- `navigationRecovery.reopenBrowser`:首次跳转未通过精确商品详情页证据时,Agent 重新显式打开任务 `urlSnapshot` 并重放安全导航步骤;最多恢复 1 次,重开后等待 500~5000 毫秒。登录、验证码、风控、无效链接和进入详情页后的采集失败不触发。
|
||||||
- `collector.dimensionAliases`、`timeoutsMs` 和 `limits`:分别管理规格标题别名、超时和遍历/SKU 上限。
|
- `collector.dimensionAliases`、`collector.textAliases`、`timeoutsMs` 和 `limits`:分别管理颜色/尺码分类别名、只读页面识别文案、超时和遍历/SKU 上限。`textAliases` 可选;旧规则快照缺省时 Agent 使用当时内置默认值。新规则可在 `specPanel`、`dimension`、`selection`、`review`、`soldOut`、`purchase` 六组中配置面板、维度、选择摘要、评价入口、主商品证据和购买入口文字;每个数组为 1~20 项、单项不超过 30 个 Unicode 字符、不得有首尾空白或重复项。维度分类只依据规则的 `dimensionAliases`,代码不再以“颜色/款式/尺码/尺寸”字面量兜底,因此规则可以真实收窄。订单和支付文字允许作为只读识别证据;`修改地址`、`收货地址` 始终拒绝。用于阻止创建订单、确认订单、支付和付款控件成为采集点击目标的拒绝清单固定保留在 Agent,不能由规则覆盖。
|
||||||
|
|
||||||
例如规格面板打开后向上滑动两次,只修改规则:
|
例如规格面板打开后向上滑动两次,只修改规则:
|
||||||
|
|
||||||
|
|||||||
@@ -83,9 +83,44 @@
|
|||||||
"size": [
|
"size": [
|
||||||
"尺码",
|
"尺码",
|
||||||
"尺寸",
|
"尺寸",
|
||||||
"套餐"
|
"套餐",
|
||||||
|
"参考分类"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"textAliases": {
|
||||||
|
"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": ["单独购买", "立即购买", "发起拼单", "去拼单", "免拼购买", "购买"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"timeoutsMs": {
|
"timeoutsMs": {
|
||||||
"page": 30000,
|
"page": 30000,
|
||||||
"specPanel": 10000,
|
"specPanel": 10000,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/purchasecontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -63,23 +65,69 @@ type hookAction struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type collectorConfig struct {
|
type collectorConfig struct {
|
||||||
CollectorID string `json:"collectorId"`
|
CollectorID string `json:"collectorId"`
|
||||||
SpecEntryStrategy string `json:"specEntryStrategy"`
|
SpecEntryStrategy string `json:"specEntryStrategy"`
|
||||||
PriceParser string `json:"priceParser"`
|
PriceParser string `json:"priceParser"`
|
||||||
PriceGranularity string `json:"priceGranularity"`
|
PriceGranularity string `json:"priceGranularity"`
|
||||||
DimensionAliases map[string][]string `json:"dimensionAliases"`
|
DimensionAliases map[string][]string `json:"dimensionAliases"`
|
||||||
TimeoutsMS map[string]int `json:"timeoutsMs"`
|
TimeoutsMS map[string]int `json:"timeoutsMs"`
|
||||||
Limits map[string]int `json:"limits"`
|
Limits map[string]int `json:"limits"`
|
||||||
|
TextAliases *collectorTextAliases `json:"textAliases,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectorTextAliases struct {
|
||||||
|
SpecPanel specPanelTextAliases `json:"specPanel"`
|
||||||
|
Dimension dimensionTextAliases `json:"dimension"`
|
||||||
|
Selection selectionTextAliases `json:"selection"`
|
||||||
|
Review reviewTextAliases `json:"review"`
|
||||||
|
SoldOut soldOutTextAliases `json:"soldOut"`
|
||||||
|
Purchase purchaseTextAliases `json:"purchase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type specPanelTextAliases struct {
|
||||||
|
TitleAliases []string `json:"titleAliases"`
|
||||||
|
CloseAliases []string `json:"closeAliases"`
|
||||||
|
ConfirmAliases []string `json:"confirmAliases"`
|
||||||
|
QuantityDecreaseAliases []string `json:"quantityDecreaseAliases"`
|
||||||
|
QuantityIncreaseAliases []string `json:"quantityIncreaseAliases"`
|
||||||
|
PaymentAreaAliases []string `json:"paymentAreaAliases"`
|
||||||
|
OrderSubmitAliases []string `json:"orderSubmitAliases"`
|
||||||
|
QuickBuyAliases []string `json:"quickBuyAliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dimensionTextAliases struct {
|
||||||
|
ColorSuffixes []string `json:"colorSuffixes"`
|
||||||
|
SizeSuffixes []string `json:"sizeSuffixes"`
|
||||||
|
ExactNames []string `json:"exactNames"`
|
||||||
|
AdaptiveAliases []string `json:"adaptiveAliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type selectionTextAliases struct {
|
||||||
|
SummaryPrefixes []string `json:"summaryPrefixes"`
|
||||||
|
SelectedPrefixes []string `json:"selectedPrefixes"`
|
||||||
|
SpecEntryPrefixes []string `json:"specEntryPrefixes"`
|
||||||
|
SubmitHintAliases []string `json:"submitHintAliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reviewTextAliases struct {
|
||||||
|
EntryAliases []string `json:"entryAliases"`
|
||||||
|
}
|
||||||
|
type soldOutTextAliases struct {
|
||||||
|
MainProductExactAliases []string `json:"mainProductExactAliases"`
|
||||||
|
MainProductPrefixAliases []string `json:"mainProductPrefixAliases"`
|
||||||
|
}
|
||||||
|
type purchaseTextAliases struct {
|
||||||
|
BuyWords []string `json:"buyWords"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type transientSoldOutRecovery struct {
|
type transientSoldOutRecovery struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
ExactText string `json:"exactText"`
|
ExactText string `json:"exactText"`
|
||||||
FallbackTopText string `json:"fallbackTopText,omitempty"`
|
FallbackTopText *string `json:"fallbackTopText,omitempty"`
|
||||||
PullDownCount int `json:"pullDownCount"`
|
PullDownCount int `json:"pullDownCount"`
|
||||||
IntervalMS int `json:"intervalMs"`
|
IntervalMS int `json:"intervalMs"`
|
||||||
SettleMS int `json:"settleMs"`
|
SettleMS int `json:"settleMs"`
|
||||||
MaxAttempts int `json:"maxAttempts"`
|
MaxAttempts int `json:"maxAttempts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type pageRecovery struct {
|
type pageRecovery struct {
|
||||||
@@ -260,11 +308,13 @@ func parseV2(raw []byte) (v2Rule, error) {
|
|||||||
}
|
}
|
||||||
if rule.PageRecovery != nil && rule.PageRecovery.TransientSoldOut != nil {
|
if rule.PageRecovery != nil && rule.PageRecovery.TransientSoldOut != nil {
|
||||||
recovery := rule.PageRecovery.TransientSoldOut
|
recovery := rule.PageRecovery.TransientSoldOut
|
||||||
if recovery.ExactText != "商品已售罄" {
|
if err := purchasecontract.ValidateReadOnlyTextAliases("pageRecovery.transientSoldOut.exactText", []string{recovery.ExactText}, 1, 30); err != nil {
|
||||||
return rule, errors.New("pageRecovery.transientSoldOut.exactText 只能是商品已售罄")
|
return rule, err
|
||||||
}
|
}
|
||||||
if recovery.FallbackTopText != "" && recovery.FallbackTopText != "相似商品" {
|
if recovery.FallbackTopText != nil {
|
||||||
return rule, errors.New("pageRecovery.transientSoldOut.fallbackTopText 只能是相似商品")
|
if err := purchasecontract.ValidateReadOnlyTextAliases("pageRecovery.transientSoldOut.fallbackTopText", []string{*recovery.FallbackTopText}, 1, 30); err != nil {
|
||||||
|
return rule, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if recovery.PullDownCount < 1 || recovery.PullDownCount > 3 {
|
if recovery.PullDownCount < 1 || recovery.PullDownCount > 3 {
|
||||||
return rule, errors.New("pageRecovery.transientSoldOut.pullDownCount 必须为 1..3")
|
return rule, errors.New("pageRecovery.transientSoldOut.pullDownCount 必须为 1..3")
|
||||||
@@ -306,12 +356,39 @@ func parseV2(raw []byte) (v2Rule, error) {
|
|||||||
}
|
}
|
||||||
for _, key := range []string{"color", "size"} {
|
for _, key := range []string{"color", "size"} {
|
||||||
aliases := rule.Collector.DimensionAliases[key]
|
aliases := rule.Collector.DimensionAliases[key]
|
||||||
if len(aliases) == 0 || len(aliases) > 20 {
|
if err := validateAliases(aliases, "dimensionAliases."+key, 1, 20, 30); err != nil {
|
||||||
return rule, fmt.Errorf("dimensionAliases.%s 必须包含 1..20 项", key)
|
return rule, err
|
||||||
}
|
}
|
||||||
for _, alias := range aliases {
|
}
|
||||||
if strings.TrimSpace(alias) == "" || len([]rune(alias)) > 30 {
|
if aliases := rule.Collector.TextAliases; aliases != nil {
|
||||||
return rule, fmt.Errorf("dimensionAliases.%s 含无效别名", key)
|
groups := []struct {
|
||||||
|
name string
|
||||||
|
values []string
|
||||||
|
}{
|
||||||
|
{"specPanel.titleAliases", aliases.SpecPanel.TitleAliases},
|
||||||
|
{"specPanel.closeAliases", aliases.SpecPanel.CloseAliases},
|
||||||
|
{"specPanel.confirmAliases", aliases.SpecPanel.ConfirmAliases},
|
||||||
|
{"specPanel.quantityDecreaseAliases", aliases.SpecPanel.QuantityDecreaseAliases},
|
||||||
|
{"specPanel.quantityIncreaseAliases", aliases.SpecPanel.QuantityIncreaseAliases},
|
||||||
|
{"specPanel.paymentAreaAliases", aliases.SpecPanel.PaymentAreaAliases},
|
||||||
|
{"specPanel.orderSubmitAliases", aliases.SpecPanel.OrderSubmitAliases},
|
||||||
|
{"specPanel.quickBuyAliases", aliases.SpecPanel.QuickBuyAliases},
|
||||||
|
{"dimension.colorSuffixes", aliases.Dimension.ColorSuffixes},
|
||||||
|
{"dimension.sizeSuffixes", aliases.Dimension.SizeSuffixes},
|
||||||
|
{"dimension.exactNames", aliases.Dimension.ExactNames},
|
||||||
|
{"dimension.adaptiveAliases", aliases.Dimension.AdaptiveAliases},
|
||||||
|
{"selection.summaryPrefixes", aliases.Selection.SummaryPrefixes},
|
||||||
|
{"selection.selectedPrefixes", aliases.Selection.SelectedPrefixes},
|
||||||
|
{"selection.specEntryPrefixes", aliases.Selection.SpecEntryPrefixes},
|
||||||
|
{"selection.submitHintAliases", aliases.Selection.SubmitHintAliases},
|
||||||
|
{"review.entryAliases", aliases.Review.EntryAliases},
|
||||||
|
{"soldOut.mainProductExactAliases", aliases.SoldOut.MainProductExactAliases},
|
||||||
|
{"soldOut.mainProductPrefixAliases", aliases.SoldOut.MainProductPrefixAliases},
|
||||||
|
{"purchase.buyWords", aliases.Purchase.BuyWords},
|
||||||
|
}
|
||||||
|
for _, group := range groups {
|
||||||
|
if err := purchasecontract.ValidateReadOnlyTextAliases("collector.textAliases."+group.name, group.values, 20, 30); err != nil {
|
||||||
|
return rule, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ func TestBuiltInPDDProductDetailTemplateIsValid(t *testing.T) {
|
|||||||
if !bytes.Contains(raw, []byte(`"size":["尺码","尺寸","套餐","参考分类"]`)) {
|
if !bytes.Contains(raw, []byte(`"size":["尺码","尺寸","套餐","参考分类"]`)) {
|
||||||
t.Fatal("built-in template must classify 参考分类 as size")
|
t.Fatal("built-in template must classify 参考分类 as size")
|
||||||
}
|
}
|
||||||
|
if !bytes.Contains(raw, []byte(`"orderSubmitAliases":["提交订单","提交訂單"]`)) {
|
||||||
|
t.Fatal("built-in template must carry read-only collector text aliases")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
|
func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
|
||||||
@@ -80,9 +83,16 @@ func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
|
|||||||
if err := Validate([]byte(valid)); err != nil {
|
if err := Validate([]byte(valid)); err != nil {
|
||||||
t.Fatalf("valid recovery rejected: %v", err)
|
t.Fatalf("valid recovery rejected: %v", err)
|
||||||
}
|
}
|
||||||
for _, invalid := range []string{
|
for _, customized := range []string{
|
||||||
strings.Replace(valid, `"exactText":"商品已售罄"`, `"exactText":"售罄"`, 1),
|
strings.Replace(valid, `"exactText":"商品已售罄"`, `"exactText":"售罄"`, 1),
|
||||||
strings.Replace(valid, `"fallbackTopText":"相似商品"`, `"fallbackTopText":"猜你喜欢"`, 1),
|
strings.Replace(valid, `"fallbackTopText":"相似商品"`, `"fallbackTopText":"猜你喜欢"`, 1),
|
||||||
|
} {
|
||||||
|
if err := Validate([]byte(customized)); err != nil {
|
||||||
|
t.Fatalf("custom recovery evidence rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{
|
||||||
|
strings.Replace(valid, `"fallbackTopText":"相似商品"`, `"fallbackTopText":"收货地址"`, 1),
|
||||||
strings.Replace(valid, `"pullDownCount":2`, `"pullDownCount":4`, 1),
|
strings.Replace(valid, `"pullDownCount":2`, `"pullDownCount":4`, 1),
|
||||||
strings.Replace(valid, `"maxAttempts":1`, `"maxAttempts":2`, 1),
|
strings.Replace(valid, `"maxAttempts":1`, `"maxAttempts":2`, 1),
|
||||||
} {
|
} {
|
||||||
@@ -96,6 +106,22 @@ func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestV2CollectorTextAliasesAreReadOnlyAndStrict(t *testing.T) {
|
||||||
|
raw := string(PDDProductDetailTemplate())
|
||||||
|
if err := Validate([]byte(raw)); err != nil {
|
||||||
|
t.Fatalf("payment/order evidence must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{
|
||||||
|
strings.Replace(raw, `"buyWords":["单独购买"`, `"buyWords":["收货地址"`, 1),
|
||||||
|
strings.Replace(raw, `"titleAliases":["确认款式","確認款式"]`, `"titleAliases":["确认款式","确认款式"]`, 1),
|
||||||
|
strings.Replace(raw, `"entryAliases":["商品评价"`, `"entryAliases":[] ,"ignored":["商品评价"`, 1),
|
||||||
|
} {
|
||||||
|
if err := Validate([]byte(invalid)); err == nil {
|
||||||
|
t.Fatalf("invalid collector text aliases accepted: %s", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestV2ValidatesReopenBrowserRecovery(t *testing.T) {
|
func TestV2ValidatesReopenBrowserRecovery(t *testing.T) {
|
||||||
base := validV2(`[]`)
|
base := validV2(`[]`)
|
||||||
valid := strings.Replace(base, `"hooks"`, `"navigationRecovery":{"reopenBrowser":{"enabled":true,"maxAttempts":1,"settleMs":1000}},"hooks"`, 1)
|
valid := strings.Replace(base, `"hooks"`, `"navigationRecovery":{"reopenBrowser":{"enabled":true,"maxAttempts":1,"settleMs":1000}},"hooks"`, 1)
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ var pddProductDetailTemplate = json.RawMessage(`{
|
|||||||
"collector":{
|
"collector":{
|
||||||
"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color",
|
"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color",
|
||||||
"dimensionAliases":{"color":["颜色分类","颜色","款式","颜色款式","花色"],"size":["尺码","尺寸","套餐","参考分类"]},
|
"dimensionAliases":{"color":["颜色分类","颜色","款式","颜色款式","花色"],"size":["尺码","尺寸","套餐","参考分类"]},
|
||||||
|
"textAliases":{
|
||||||
|
"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":["单独购买","立即购买","发起拼单","去拼单","免拼购买","购买"]}
|
||||||
|
},
|
||||||
"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},
|
"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},
|
||||||
"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}
|
"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user