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

This commit is contained in:
QiuSW
2026-08-27 18:19:33 +08:00
parent 3619435a97
commit e117fd222d
11 changed files with 389 additions and 96 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 23
versionName = "0.9.10"
versionCode = 24
versionName = "0.9.11"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -100,7 +100,11 @@ data class ParsedPddScreen(
val problem: PageProblem?,
val sourceNodes: List<SnapshotNode>,
) {
fun isTransientSoldOut(exactText: String, fallbackTopText: String = "相似商品"): Boolean {
fun isTransientSoldOut(
exactText: String,
fallbackTopText: String = "相似商品",
aliases: SoldOutTextAliases = SoldOutTextAliases(),
): Boolean {
val visible = sourceNodes.filter { it.visible }
fun normalized(value: String) = value.replace(Regex("\\s+"), "")
val hasExactText = visible.any { normalized(it.label) == normalized(exactText) }
@@ -110,7 +114,10 @@ data class ParsedPddScreen(
}
val labels = visible.map { normalized(it.label) }
val hasPrimaryProductEvidence = specPanelOpen || specEntry != null || quickConfirmationEntry != null ||
labels.any { it == "进店" || it.startsWith("商品评价") || it in setOf("单独购买", "发起拼单", "立即购买") }
labels.any { label ->
aliases.mainProductExactAliases.any { label == normalized(it) } ||
aliases.mainProductPrefixAliases.any { label.startsWith(normalized(it)) }
}
return pageEvidenceMatched && (hasExactText || hasFallbackAtTop) && !hasPrimaryProductEvidence
}
}
@@ -126,7 +133,9 @@ object PddScreenParser {
"已選", "請選擇", "數量", "打開大圖", "查看大圖", "一次選多款",
)
private val excludedExactOptionLabels = setOf("客服", "联系客服", "聯繫客服", "店铺", "店鋪", "收藏", "关闭", "關閉")
private val reviewWords = listOf("商品评价", "全部评价", "评价详情", "评论", "晒单", "问答")
// This denylist is intentionally not configurable: it prevents purchase,
// order, and payment controls from ever becoming collection click targets.
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
@@ -145,14 +154,13 @@ object PddScreenParser {
}
}
val labels = visible.map(SnapshotNode::label)
val textAliases = config.textAliases
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
val compactLabels = labels.map { it.replace(" ", "") }
val hasSelectionSummary = compactLabels.any {
it.startsWith("已选") || it.startsWith("请选择") || it.startsWith("已選") || it.startsWith("請選擇")
}
val hasSelectionSummary = compactLabels.any { label -> textAliases.selection.summaryPrefixes.any(label::startsWith) }
val hasSubmitHint = compactLabels.any { label ->
(label.contains("提交订单") || label.contains("提交訂單")) &&
listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格").any(label::contains)
textAliases.specPanel.orderSubmitAliases.any(label::contains) &&
textAliases.selection.submitHintAliases.any(label::contains)
}
val allHeadings = visible.filter { !it.clickable && isHeading(it.label, config) }
val screenWidth = visibleNodes.maxOfOrNull { it.bounds.right } ?: 0
@@ -168,27 +176,27 @@ object PddScreenParser {
val headedPanelScrollable = boundedScrollables.asSequence()
.filter { region -> allHeadings.any { heading -> heading.path.startsWith("${region.path}/") } }
.maxByOrNull { it.bounds.width.toLong() * it.bounds.height }
val hasPanelTitle = compactLabels.any { it in setOf("确认款式", "確認款式", "关闭", "關閉") }
val hasPanelTitle = compactLabels.any { it in textAliases.specPanel.titleAliases || it in textAliases.specPanel.closeAliases }
val hasPanelAction = visible.any { node ->
val compact = node.label.replace(" ", "")
node.clickable && (
compact in setOf("确定", "確定", "确认", "確認") ||
((compact.contains("提交订单") || compact.contains("提交訂單")) &&
listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格").any(compact::contains))
compact in textAliases.specPanel.confirmAliases ||
(textAliases.specPanel.orderSubmitAliases.any(compact::contains) &&
textAliases.selection.submitHintAliases.any(compact::contains))
)
}
val quantityInputs = visibleNodes.filter {
it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
}
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in setOf("减少数量", "減少數量") }
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in setOf("增加数量", "增加數量") }
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityIncreaseAliases }
val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1
val hasPaymentArea = compactLabels.any { label -> listOf("微信支付", "先用后付", "先用後付", "支付方式").any(label::contains) }
val hasPaymentArea = compactLabels.any { label -> textAliases.specPanel.paymentAreaAliases.any(label::contains) }
val hasQuickBuy = visible.any { node ->
node.label.replace(" ", "").let { it.contains("现在买") || it.contains("現在買") } &&
node.label.replace(" ", "").let { compact -> textAliases.specPanel.quickBuyAliases.any(compact::contains) } &&
node.bounds.centerY.toDouble() >= screenHeight * 0.75
}
val hasClose = compactLabels.any { it in setOf("关闭", "關閉") }
val hasClose = compactLabels.any { it in textAliases.specPanel.closeAliases }
val quickConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea && hasQuickBuy && hasQuantityControls
val orderSubmitCandidates = visibleNodes.asSequence()
.filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 }
@@ -196,11 +204,11 @@ object PddScreenParser {
.filter { node ->
val compact = (listOf(node.label) + descendants(node, visibleNodes).map(SnapshotNode::label))
.joinToString("") { it.replace(" ", "") }
compact.contains("提交订单") || compact.contains("提交訂單")
textAliases.specPanel.orderSubmitAliases.any(compact::contains)
}
.toList()
.filter { candidate ->
orderSubmitCandidatesAncestorFree(candidate, visibleNodes)
orderSubmitCandidatesAncestorFree(candidate, visibleNodes, textAliases.specPanel.orderSubmitAliases)
}
val hasOrderSubmitAction = orderSubmitCandidates.size == 1
val orderConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea &&
@@ -252,16 +260,16 @@ object PddScreenParser {
.mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) }
.firstOrNull()
val explicitSpecEntry = if (panelOpen) null else visible
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes) }
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes, config) }
.maxByOrNull { it.bounds.top }
val bottomSpecEntry = if (panelOpen || explicitSpecEntry != null) null else safeBottomSpecEntry(visibleNodes, visible)
val bottomSpecEntry = if (panelOpen || explicitSpecEntry != null) null else safeBottomSpecEntry(visibleNodes, visible, config)
val specEntry = explicitSpecEntry ?: bottomSpecEntry
val specEntrySource = when {
explicitSpecEntry != null -> "explicit_selection"
bottomSpecEntry != null -> "bottom_purchase"
else -> null
}
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, specEntry)
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, specEntry, config)
val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null
val rootAvailable = snapshot.packageName != null || snapshot.activityName != null || snapshot.nodes.isNotEmpty()
val packageMatched = evidence == null || snapshot.packageName == evidence.packageName
@@ -281,7 +289,7 @@ object PddScreenParser {
dimensions = dimensions,
selectedSummary = labels.firstOrNull {
val compact = it.replace(" ", "")
compact.startsWith("已选") || compact.startsWith("已選")
textAliases.selection.selectedPrefixes.any(compact::startsWith)
},
priceCent = price,
specPanelOpen = panelOpen,
@@ -312,13 +320,13 @@ object PddScreenParser {
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
}
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>): Boolean {
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>, aliases: List<String>): Boolean {
val candidatePrefix = "${candidate.path}/"
return source.none { node ->
node.path.startsWith(candidatePrefix) && node.visible && node.enabled && node.clickable &&
(listOf(node.label) + descendants(node, source).map(SnapshotNode::label))
.joinToString("") { it.replace(" ", "") }
.let { it.contains("提交订单") || it.contains("提交訂單") }
.let { compact -> aliases.any(compact::contains) }
}
}
@@ -336,45 +344,44 @@ object PddScreenParser {
private fun isHeading(label: String, config: PddCollectorConfig): Boolean {
val compact = label.replace(" ", "").replace(Regex("[((]\\d+[))]$"), "")
if (compact.startsWith("请选择") || compact.startsWith("已选") || compact == "确认款式") return false
if (config.textAliases.selection.summaryPrefixes.any(compact::startsWith) || compact in config.textAliases.specPanel.titleAliases) return false
return (config.colorAliases + config.sizeAliases).any { compact == it.replace(" ", "") } ||
compact.endsWith("颜色") || compact.endsWith("款式") || compact.endsWith("尺码") ||
compact.endsWith("尺寸") || compact.endsWith("规格") || compact.endsWith("型号") || compact.endsWith("套餐") ||
compact.endsWith("容量") || compact.endsWith("类型") || compact.endsWith("版本") || compact.endsWith("口味")
(config.textAliases.dimension.colorSuffixes + config.textAliases.dimension.sizeSuffixes +
config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases).any(compact::endsWith)
}
private fun isExactHeadingLabel(label: String, config: PddCollectorConfig): Boolean {
val compact = label.replace(" ", "").replace(Regex("[((]\\d+[))]$"), "")
return (config.colorAliases + config.sizeAliases).any { compact == it.replace(" ", "") } ||
compact in setOf("颜色分类", "颜色", "花色", "款式", "尺码", "尺寸", "规格", "型号", "套餐", "容量", "类型", "版本", "口味")
compact in (config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases)
}
private fun dimensionKey(label: String, config: PddCollectorConfig, hasColor: Boolean): String = when {
config.colorAliases.any { label.contains(it) } || label.contains("颜色") || label.contains("款式") -> "color"
label.contains("套餐") -> if (hasColor) "size" else "color"
config.sizeAliases.any { label.contains(it) } || label.contains("尺码") || label.contains("尺寸") -> "size"
config.colorAliases.any { label.contains(it) } -> "color"
config.textAliases.dimension.adaptiveAliases.any(label::contains) -> if (hasColor) "size" else "color"
config.sizeAliases.any { label.contains(it) } -> "size"
else -> "unsupported"
}
private fun isSpecEntry(label: String, config: PddCollectorConfig): Boolean {
val compact = label.replace(" ", "")
if (reviewWords.any(compact::contains)) return false
if (config.textAliases.review.entryAliases.any(compact::contains)) return false
val aliases = config.colorAliases + config.sizeAliases
val hasSpecWord = aliases.any(compact::contains) || compact.contains("规格")
return hasSpecWord && listOf("请选择", "选择", "已选").any(compact::startsWith)
val hasSpecWord = aliases.any(compact::contains) ||
(config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases).any(compact::contains)
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::startsWith)
}
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>): SnapshotNode? {
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): SnapshotNode? {
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return null
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return null
if (screenWidth <= 0 || screenHeight <= 0) return null
val byPath = source.associateBy(SnapshotNode::path)
val normalizedByPath = visible.associateBy(SnapshotNode::path)
val buyWords = listOf("单独购买", "立即购买", "发起拼单", "去拼单", "免拼购买", "购买")
val dangerousWords = listOf("提交订单", "确认订单", "支付", "付款")
val buyWords = config.textAliases.purchase.buyWords
return source.asSequence()
.filter { it.visible && it.enabled && buyWords.any(it.label.replace(" ", "")::contains) }
.filterNot { node -> dangerousWords.any(node.label.replace(" ", "")::contains) }
.filterNot { node -> nonConfigurableClickDenylist.any(node.label.replace(" ", "")::contains) }
.mapNotNull { labelNode ->
var clickable: SnapshotNode? = labelNode
while (clickable != null && !clickable.clickable) clickable = clickable.parentPath?.let(byPath::get)
@@ -391,7 +398,7 @@ object PddScreenParser {
?.first
}
private fun hasReviewContext(node: SnapshotNode, source: List<SnapshotNode>): Boolean {
private fun hasReviewContext(node: SnapshotNode, source: List<SnapshotNode>, config: PddCollectorConfig): Boolean {
val byPath = source.associateBy(SnapshotNode::path)
val context = mutableListOf(node.label)
var parentPath = node.parentPath
@@ -401,7 +408,7 @@ object PddScreenParser {
parentPath = parent.parentPath
}
context += descendants(node, source).map(SnapshotNode::label)
return context.any { label -> reviewWords.any(label.replace(" ", "")::contains) }
return context.any { label -> config.textAliases.review.entryAliases.any(label.replace(" ", "")::contains) }
}
private fun isReviewPage(
@@ -409,10 +416,11 @@ object PddScreenParser {
visible: List<SnapshotNode>,
screenHeight: Int,
specEntry: SnapshotNode?,
config: PddCollectorConfig,
): Boolean {
if (screenHeight <= 0 || specEntry != null) return false
val hasTopTitle = visible.any { node ->
node.label.replace(" ", "") in setOf("商品评价", "全部评价", "评价详情") &&
node.label.replace(" ", "") in config.textAliases.review.entryAliases &&
node.bounds.centerY <= screenHeight * 25 / 100
}
if (!hasTopTitle) return false
@@ -420,7 +428,10 @@ object PddScreenParser {
val compact = node.label.replace(" ", "")
compact in setOf("全部", "有图/视频", "追评", "最新") || compact.contains("条评价")
}
return hasReviewContent || source.none { it.visible && it.enabled && it.bounds.centerY >= screenHeight * 80 / 100 && it.label.contains("购买") }
return hasReviewContent || source.none { node ->
node.visible && node.enabled && node.bounds.centerY >= screenHeight * 80 / 100 &&
config.textAliases.purchase.buyWords.any(node.label::contains)
}
}
private fun safeOptionRank(node: SnapshotNode, source: List<SnapshotNode>): Int = when {
@@ -438,18 +449,19 @@ object PddScreenParser {
val screenBottom = source.maxOfOrNull { it.bounds.bottom } ?: return null
if (screenBottom <= 0) return null
val compactLabels = visible.map { it.label.replace(" ", "") }
val hasClose = compactLabels.any { it in setOf("关闭", "關閉") }
val hasSummary = compactLabels.any { it.startsWith("已选") || it.startsWith("已選") }
val hasPaymentArea = compactLabels.any { label -> listOf("微信支付", "先用后付", "支付方式").any(label::contains) }
val aliases = config.textAliases
val hasClose = compactLabels.any { it in aliases.specPanel.closeAliases }
val hasSummary = compactLabels.any { label -> aliases.selection.selectedPrefixes.any(label::startsWith) }
val hasPaymentArea = compactLabels.any { label -> aliases.specPanel.paymentAreaAliases.any(label::contains) }
val hasQuickBuy = visible.any { node ->
node.label.replace(" ", "").let { it.contains("现在买") || it.contains("現在買") } &&
node.label.replace(" ", "").let { compact -> aliases.specPanel.quickBuyAliases.any(compact::contains) } &&
node.bounds.centerY.toDouble() >= screenBottom * 0.75
}
val hasQuantity = source.any {
it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
}
val hasDecrease = visible.any { it.clickable && it.label.replace(" ", "") in setOf("减少数量", "減少數量") }
val hasIncrease = visible.any { it.clickable && it.label.replace(" ", "") in setOf("增加数量", "增加數量") }
val hasDecrease = visible.any { it.clickable && it.label.replace(" ", "") in aliases.specPanel.quantityDecreaseAliases }
val hasIncrease = visible.any { it.clickable && it.label.replace(" ", "") in aliases.specPanel.quantityIncreaseAliases }
if (!hasClose || !hasSummary || !hasPaymentArea || !hasQuickBuy || !hasQuantity || !hasDecrease || !hasIncrease) return null
val headings = visible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top }
@@ -540,7 +552,7 @@ class PddProductDetailCollector(
var current = waitForPageEvidence(config.timeoutsMs.getValue("page"), goodsId, config, evidence)
?: return failure("PDD_DETAIL_ENTRY_FAILED", "未进入 PDD 商品详情页")
val recovery = rule.transientSoldOutRecovery
if (recovery?.enabled == true && current.isTransientSoldOut(recovery.exactText, recovery.fallbackTopText)) {
if (recovery?.enabled == true && current.isTransientSoldOut(recovery.exactText, recovery.fallbackTopText, config.textAliases.soldOut)) {
trace("transient-sold-out recovery=start pulls=${recovery.pullDownCount}")
repeat(recovery.pullDownCount) { index ->
if (!driver.pullDownGoodsPage()) {
@@ -550,7 +562,7 @@ class PddProductDetailCollector(
}
pause(recovery.settleMs)
current = parse(goodsId, config, evidence)
if (current.isTransientSoldOut(recovery.exactText, recovery.fallbackTopText)) {
if (current.isTransientSoldOut(recovery.exactText, recovery.fallbackTopText, config.textAliases.soldOut)) {
return failure("PDD_GOODS_SOLD_OUT", "商品页下拉恢复后仍显示已售罄")
}
if (!current.pageEvidenceMatched) {
@@ -1009,7 +1021,7 @@ class PddProductDetailCollector(
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes)
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation)
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation, config)
visible.forEach { sizes[it.text] = sizes[it.text] == true || it.available }
val signature = if (visible.isNotEmpty()) optionSignature(visible) else viewportSignature(screen)
trace("sizes pass=$pass dimensions=${screen.dimensions.joinToString { "${it.key}:${it.values.size}" }} signature=${signature.size} collected=${sizes.size}")
@@ -1127,9 +1139,10 @@ class PddProductDetailCollector(
)
}
private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?): List<VisibleSpecValue> {
private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
if (context == null) return emptyList()
val headingLabels = setOf("颜色分类", "颜色", "花色", "款式", "尺码", "尺寸", "规格", "型号", "套餐", "容量", "类型", "版本", "口味")
val headingLabels = (config.colorAliases + config.sizeAliases + config.textAliases.dimension.exactNames +
config.textAliases.dimension.adaptiveAliases).map { it.replace(" ", "") }.toSet()
val priceOnly = Regex("^(?:券后|到手价|拼单价|单买价|价格)?[¥¥]\\s*\\d+(?:\\.\\d{1,2})?$")
return screen.sourceNodes.asSequence()
.filter { it.visible && it.enabled && it.clickable }
@@ -56,6 +56,54 @@ data class PddCollectorConfig(
val sizeAliases: List<String>,
val timeoutsMs: Map<String, Int>,
val limits: Map<String, Int>,
val textAliases: PddCollectorTextAliases = PddCollectorTextAliases(),
)
data class PddCollectorTextAliases(
val specPanel: SpecPanelTextAliases = SpecPanelTextAliases(),
val dimension: DimensionTextAliases = DimensionTextAliases(),
val selection: SelectionTextAliases = SelectionTextAliases(),
val review: ReviewTextAliases = ReviewTextAliases(),
val soldOut: SoldOutTextAliases = SoldOutTextAliases(),
val purchase: PurchaseTextAliases = PurchaseTextAliases(),
)
data class SpecPanelTextAliases(
val titleAliases: List<String> = listOf("确认款式", "確認款式"),
val closeAliases: List<String> = listOf("关闭", "關閉"),
val confirmAliases: List<String> = listOf("确定", "確定", "确认", "確認"),
val quantityDecreaseAliases: List<String> = listOf("减少数量", "減少數量"),
val quantityIncreaseAliases: List<String> = listOf("增加数量", "增加數量"),
val paymentAreaAliases: List<String> = listOf("微信支付", "先用后付", "先用後付", "支付方式"),
val orderSubmitAliases: List<String> = listOf("提交订单", "提交訂單"),
val quickBuyAliases: List<String> = listOf("现在买", "現在買"),
)
data class DimensionTextAliases(
val colorSuffixes: List<String> = listOf("颜色", "款式"),
val sizeSuffixes: List<String> = listOf("尺码", "尺寸"),
val exactNames: List<String> = listOf("规格", "型号", "容量", "类型", "版本", "口味"),
val adaptiveAliases: List<String> = listOf("套餐"),
)
data class SelectionTextAliases(
val summaryPrefixes: List<String> = listOf("已选", "请选择", "已選", "請選擇"),
val selectedPrefixes: List<String> = listOf("已选", "已選"),
val specEntryPrefixes: List<String> = listOf("请选择", "选择", "已选"),
val submitHintAliases: List<String> = listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格"),
)
data class ReviewTextAliases(
val entryAliases: List<String> = listOf("商品评价", "全部评价", "评价详情", "评论", "晒单", "问答"),
)
data class SoldOutTextAliases(
val mainProductExactAliases: List<String> = listOf("进店", "单独购买", "发起拼单", "立即购买"),
val mainProductPrefixAliases: List<String> = listOf("商品评价"),
)
data class PurchaseTextAliases(
val buyWords: List<String> = listOf("单独购买", "立即购买", "发起拼单", "去拼单", "免拼购买", "购买"),
)
data class TransientSoldOutRecovery(
@@ -210,10 +258,9 @@ object RuleParser {
rejectUnknown(value, setOf("transientSoldOut"), "pageRecovery")
val recovery = value.optJSONObject("transientSoldOut") ?: return null
rejectUnknown(recovery, setOf("enabled", "exactText", "fallbackTopText", "pullDownCount", "intervalMs", "settleMs", "maxAttempts"), "pageRecovery.transientSoldOut")
val exactText = recovery.optString("exactText")
if (exactText != "商品已售罄") invalid("pageRecovery.transientSoldOut.exactText 只能是商品已售罄")
val exactText = parseReadOnlyText(recovery.optString("exactText"), "pageRecovery.transientSoldOut.exactText")
val fallbackTopText = recovery.optString("fallbackTopText", "相似商品")
if (fallbackTopText != "相似商品") invalid("pageRecovery.transientSoldOut.fallbackTopText 只能是相似商品")
parseReadOnlyText(fallbackTopText, "pageRecovery.transientSoldOut.fallbackTopText")
val pullDownCount = recovery.optInt("pullDownCount")
val intervalMs = recovery.optLong("intervalMs")
val settleMs = recovery.optLong("settleMs")
@@ -302,7 +349,7 @@ object RuleParser {
}
private fun parseCollector(value: JSONObject): PddCollectorConfig {
rejectUnknown(value, setOf("collectorId", "specEntryStrategy", "priceParser", "priceGranularity", "dimensionAliases", "timeoutsMs", "limits"), "collector")
rejectUnknown(value, setOf("collectorId", "specEntryStrategy", "priceParser", "priceGranularity", "dimensionAliases", "timeoutsMs", "limits", "textAliases"), "collector")
if (value.optString("collectorId") != "pddProductDetailV1" ||
value.optString("specEntryStrategy") != "safeBottomSpecEntryV1" ||
value.optString("priceParser") != "pddRmbPriceV1" ||
@@ -320,15 +367,67 @@ object RuleParser {
value.optJSONObject("limits") ?: invalid("limits 必填"),
mapOf("goodsPageVerticalSwipes" to 0..10, "specHorizontalSwipes" to 0..30, "specVerticalSwipes" to 0..30, "stableEdgeReads" to 1..5, "stablePriceReads" to 2..5, "maxSkuCount" to 1..2_000),
)
return PddCollectorConfig("pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color", colorAliases, sizeAliases, timeouts, limits)
val textAliases = value.optJSONObject("textAliases")?.let(::parseTextAliases) ?: PddCollectorTextAliases()
return PddCollectorConfig("pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color", colorAliases, sizeAliases, timeouts, limits, textAliases)
}
private fun parseAliases(items: JSONArray?, name: String): List<String> {
items ?: invalid("dimensionAliases.$name 必填")
if (items.length() !in 1..20) invalid("dimensionAliases.$name 必须包含 1..20 项")
return (0 until items.length()).map { index ->
items.optString(index).trim().also { if (it.isEmpty() || it.length > 30) invalid("dimensionAliases.$name 含无效别名") }
}.distinct()
val values = (0 until items.length()).map { index ->
items.optString(index).also { if (it != it.trim() || it.isEmpty() || it.codePointCount(0, it.length) > 30) invalid("dimensionAliases.$name 含无效别名") }
}
if (values.distinct().size != values.size) invalid("dimensionAliases.$name 不能重复")
return values
}
private fun parseTextAliases(value: JSONObject): PddCollectorTextAliases {
rejectUnknown(value, setOf("specPanel", "dimension", "selection", "review", "soldOut", "purchase"), "collector.textAliases")
fun section(name: String, fields: Set<String>) = value.optJSONObject(name)?.also {
rejectUnknown(it, fields, "collector.textAliases.$name")
} ?: invalid("collector.textAliases.$name 必填")
val spec = section("specPanel", setOf("titleAliases", "closeAliases", "confirmAliases", "quantityDecreaseAliases", "quantityIncreaseAliases", "paymentAreaAliases", "orderSubmitAliases", "quickBuyAliases"))
val dimension = section("dimension", setOf("colorSuffixes", "sizeSuffixes", "exactNames", "adaptiveAliases"))
val selection = section("selection", setOf("summaryPrefixes", "selectedPrefixes", "specEntryPrefixes", "submitHintAliases"))
val review = section("review", setOf("entryAliases"))
val soldOut = section("soldOut", setOf("mainProductExactAliases", "mainProductPrefixAliases"))
val purchase = section("purchase", setOf("buyWords"))
fun aliases(section: JSONObject, sectionName: String, field: String) =
parseReadOnlyAliases(section.optJSONArray(field), "collector.textAliases.$sectionName.$field")
return PddCollectorTextAliases(
SpecPanelTextAliases(
aliases(spec, "specPanel", "titleAliases"), aliases(spec, "specPanel", "closeAliases"),
aliases(spec, "specPanel", "confirmAliases"), aliases(spec, "specPanel", "quantityDecreaseAliases"),
aliases(spec, "specPanel", "quantityIncreaseAliases"), aliases(spec, "specPanel", "paymentAreaAliases"),
aliases(spec, "specPanel", "orderSubmitAliases"), aliases(spec, "specPanel", "quickBuyAliases"),
),
DimensionTextAliases(
aliases(dimension, "dimension", "colorSuffixes"), aliases(dimension, "dimension", "sizeSuffixes"),
aliases(dimension, "dimension", "exactNames"), aliases(dimension, "dimension", "adaptiveAliases"),
),
SelectionTextAliases(
aliases(selection, "selection", "summaryPrefixes"), aliases(selection, "selection", "selectedPrefixes"),
aliases(selection, "selection", "specEntryPrefixes"), aliases(selection, "selection", "submitHintAliases"),
),
ReviewTextAliases(aliases(review, "review", "entryAliases")),
SoldOutTextAliases(aliases(soldOut, "soldOut", "mainProductExactAliases"), aliases(soldOut, "soldOut", "mainProductPrefixAliases")),
PurchaseTextAliases(aliases(purchase, "purchase", "buyWords")),
)
}
private fun parseReadOnlyAliases(items: JSONArray?, label: String): List<String> {
items ?: invalid("$label 必填")
if (items.length() !in 1..20) invalid("$label 必须包含 1..20 项")
val values = (0 until items.length()).map { parseReadOnlyText(items.optString(it), label) }
if (values.distinct().size != values.size) invalid("$label 不能重复")
return values
}
private fun parseReadOnlyText(value: String, label: String): String {
if (value != value.trim() || value.isEmpty() || value.codePointCount(0, value.length) > 30 ||
listOf("修改地址", "收货地址").any(value::contains)
) invalid("$label 含空值、地址操作文字或过长文字")
return value
}
private fun parseBoundedMap(value: JSONObject, allowed: Map<String, IntRange>): Map<String, Int> {
@@ -112,6 +112,18 @@ class PddProductDetailCollectorTest {
assertEquals(listOf("红色", "蓝色", "黑色", "白色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text })
}
@Test
fun dimensionAliasesCanTrulyNarrowRecognition() {
val base = config()
val narrowed = base.copy(
colorAliases = listOf("色系"),
)
val parsed = PddScreenParser.parse(FakeCollectorDriver().capture(), narrowed, GOODS_ID, evidence())
assertFalse(parsed.dimensions.any { it.key == "color" })
}
@Test
fun unrelatedLongTextIsNotUsedAsProductTitle() {
val driver = FakeCollectorDriver()
@@ -134,6 +134,19 @@ class RuleExecutorTest {
}
}
@Test
fun `v2 collector text aliases allow read-only order evidence but reject address operations`() {
val configured = validV2Rule().replace(
"\"timeoutsMs\"",
"\"textAliases\":" + textAliasesJson() + ",\"timeoutsMs\"",
)
val rule = RuleParser.parse(configured)
assertEquals(listOf("提交订单", "提交訂單"), rule.collector?.textAliases?.specPanel?.orderSubmitAliases)
assertTrue(runCatching { RuleParser.parse(configured.replace("单独购买", "收货地址")) }.isFailure)
}
private fun validV2Rule() = """{
"schemaVersion":2,"ruleType":"pddProductDetail",
"navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]},
@@ -144,6 +157,15 @@ class RuleExecutorTest {
"collector":{"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color","dimensionAliases":{"color":["颜色"],"size":["尺码"]},"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}}
}"""
private fun textAliasesJson() = """{
"specPanel":{"titleAliases":["确认款式","確認款式"],"closeAliases":["关闭","關閉"],"confirmAliases":["确定","確定","确认","確認"],"quantityDecreaseAliases":["减少数量","減少數量"],"quantityIncreaseAliases":["增加数量","增加數量"],"paymentAreaAliases":["微信支付","先用后付","先用後付","支付方式"],"orderSubmitAliases":["提交订单","提交訂單"],"quickBuyAliases":["现在买","現在買"]},
"dimension":{"colorSuffixes":["颜色","款式"],"sizeSuffixes":["尺码","尺寸"],"exactNames":["规格","型号","容量","类型","版本","口味"],"adaptiveAliases":["套餐"]},
"selection":{"summaryPrefixes":["已选","请选择","已選","請選擇"],"selectedPrefixes":["已选","已選"],"specEntryPrefixes":["请选择","选择","已选"],"submitHintAliases":["选择","選擇","颜色","顏色","尺码","尺碼","规格","規格"]},
"review":{"entryAliases":["商品评价","全部评价","评价详情","评论","晒单","问答"]},
"soldOut":{"mainProductExactAliases":["进店","单独购买","发起拼单","立即购买"],"mainProductPrefixAliases":["商品评价"]},
"purchase":{"buyWords":["单独购买","立即购买","发起拼单","去拼单","免拼购买","购买"]}
}"""
private class FakeDriver(
private val nodes: Map<String, List<UiNodeRef>>,
private val activity: String = "com.xunmeng.pinduoduo.activity.NewPageActivity",
+3 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 26618d4312a1e7091f6b483111c31e67f70f26eb
synchronized_at: 2026-08-27T09:57:37Z
wiki_revision: b208729957c26801012279a8e89fc18b350f3a87
synchronized_at: 2026-08-27T10:14:02Z
<!-- 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 相关流程。
- 采购阶段的规格匹配由服务端决策(见 [#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`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。
- PDD 采集规则的 `collector.textAliases` 是规格面板、维度标题、选择摘要、评价入口、售罄主商品证据和购买入口等只读识别文案的服务端下发事实源;缺省字段只为旧快照兼容使用 Agent 内置默认值。颜色/尺码分类不得再用代码字面量扩大 `dimensionAliases`。订单和支付文字可以参与只读页面判定,但 Agent 内不可配置的创建订单/确认订单/支付/付款点击拒绝清单始终优先,规则不能放宽;修改地址和收货地址在只读及点击字段中都禁止。
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。
+4 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 3d6a43359e015d4d6eaeb896f63dc1920ca601f6
synchronized_at: 2026-08-27T09:58:31Z
wiki_revision: 0bbe3f1041a0795a156dcd1205dbe85474e80f8c
synchronized_at: 2026-08-27T10:14:59Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -153,9 +153,9 @@ v2 完整示例见 [PDD 商品详情规则](rules/pdd-product-detail-v2.proposed
- `pageEvidence`:精确的 PDD 包名、Activity 和非空节点证据。
- `collector.collectorId: pddProductDetailV1`:引用 Agent 中经过测试的类型化能力,不下发可执行代码。
- `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 毫秒。登录、验证码、风控、无效链接和进入详情页后的采集失败不触发。
- `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,不能由规则覆盖。
例如规格面板打开后向上滑动两次,只修改规则:
+36 -1
View File
@@ -83,9 +83,44 @@
"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,
+100 -23
View File
@@ -8,6 +8,8 @@ import (
"io"
"sort"
"strings"
"go-admin/app/goauto/purchasecontract"
)
const (
@@ -63,23 +65,69 @@ type hookAction struct {
}
type collectorConfig struct {
CollectorID string `json:"collectorId"`
SpecEntryStrategy string `json:"specEntryStrategy"`
PriceParser string `json:"priceParser"`
PriceGranularity string `json:"priceGranularity"`
DimensionAliases map[string][]string `json:"dimensionAliases"`
TimeoutsMS map[string]int `json:"timeoutsMs"`
Limits map[string]int `json:"limits"`
CollectorID string `json:"collectorId"`
SpecEntryStrategy string `json:"specEntryStrategy"`
PriceParser string `json:"priceParser"`
PriceGranularity string `json:"priceGranularity"`
DimensionAliases map[string][]string `json:"dimensionAliases"`
TimeoutsMS map[string]int `json:"timeoutsMs"`
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 {
Enabled bool `json:"enabled"`
ExactText string `json:"exactText"`
FallbackTopText string `json:"fallbackTopText,omitempty"`
PullDownCount int `json:"pullDownCount"`
IntervalMS int `json:"intervalMs"`
SettleMS int `json:"settleMs"`
MaxAttempts int `json:"maxAttempts"`
Enabled bool `json:"enabled"`
ExactText string `json:"exactText"`
FallbackTopText *string `json:"fallbackTopText,omitempty"`
PullDownCount int `json:"pullDownCount"`
IntervalMS int `json:"intervalMs"`
SettleMS int `json:"settleMs"`
MaxAttempts int `json:"maxAttempts"`
}
type pageRecovery struct {
@@ -260,11 +308,13 @@ func parseV2(raw []byte) (v2Rule, error) {
}
if rule.PageRecovery != nil && rule.PageRecovery.TransientSoldOut != nil {
recovery := rule.PageRecovery.TransientSoldOut
if recovery.ExactText != "商品已售罄" {
return rule, errors.New("pageRecovery.transientSoldOut.exactText 只能是商品已售罄")
if err := purchasecontract.ValidateReadOnlyTextAliases("pageRecovery.transientSoldOut.exactText", []string{recovery.ExactText}, 1, 30); err != nil {
return rule, err
}
if recovery.FallbackTopText != "" && recovery.FallbackTopText != "相似商品" {
return rule, errors.New("pageRecovery.transientSoldOut.fallbackTopText 只能是相似商品")
if recovery.FallbackTopText != nil {
if err := purchasecontract.ValidateReadOnlyTextAliases("pageRecovery.transientSoldOut.fallbackTopText", []string{*recovery.FallbackTopText}, 1, 30); err != nil {
return rule, err
}
}
if recovery.PullDownCount < 1 || recovery.PullDownCount > 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"} {
aliases := rule.Collector.DimensionAliases[key]
if len(aliases) == 0 || len(aliases) > 20 {
return rule, fmt.Errorf("dimensionAliases.%s 必须包含 1..20 项", key)
if err := validateAliases(aliases, "dimensionAliases."+key, 1, 20, 30); err != nil {
return rule, err
}
for _, alias := range aliases {
if strings.TrimSpace(alias) == "" || len([]rune(alias)) > 30 {
return rule, fmt.Errorf("dimensionAliases.%s 含无效别名", key)
}
if aliases := rule.Collector.TextAliases; aliases != nil {
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":["尺码","尺寸","套餐","参考分类"]`)) {
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) {
@@ -80,9 +83,16 @@ func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
if err := Validate([]byte(valid)); err != nil {
t.Fatalf("valid recovery rejected: %v", err)
}
for _, invalid := range []string{
for _, customized := range []string{
strings.Replace(valid, `"exactText":"商品已售罄"`, `"exactText":"售罄"`, 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, `"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) {
base := validV2(`[]`)
valid := strings.Replace(base, `"hooks"`, `"navigationRecovery":{"reopenBrowser":{"enabled":true,"maxAttempts":1,"settleMs":1000}},"hooks"`, 1)
@@ -18,6 +18,14 @@ var pddProductDetailTemplate = json.RawMessage(`{
"collector":{
"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color",
"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},
"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}
}