diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 49d90ac..6c38e4c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index 12a6fa7..7b25178 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -100,7 +100,11 @@ data class ParsedPddScreen( val problem: PageProblem?, val sourceNodes: List, ) { - 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): Boolean { + private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List, aliases: List): 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, visible: List): SnapshotNode? { + private fun safeBottomSpecEntry(source: List, visible: List, 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): Boolean { + private fun hasReviewContext(node: SnapshotNode, source: List, 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, 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): 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 { + private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List { 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 } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt index 5aee3f3..208f58c 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt @@ -56,6 +56,54 @@ data class PddCollectorConfig( val sizeAliases: List, val timeoutsMs: Map, val limits: Map, + 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 = listOf("确认款式", "確認款式"), + val closeAliases: List = listOf("关闭", "關閉"), + val confirmAliases: List = listOf("确定", "確定", "确认", "確認"), + val quantityDecreaseAliases: List = listOf("减少数量", "減少數量"), + val quantityIncreaseAliases: List = listOf("增加数量", "增加數量"), + val paymentAreaAliases: List = listOf("微信支付", "先用后付", "先用後付", "支付方式"), + val orderSubmitAliases: List = listOf("提交订单", "提交訂單"), + val quickBuyAliases: List = listOf("现在买", "現在買"), +) + +data class DimensionTextAliases( + val colorSuffixes: List = listOf("颜色", "款式"), + val sizeSuffixes: List = listOf("尺码", "尺寸"), + val exactNames: List = listOf("规格", "型号", "容量", "类型", "版本", "口味"), + val adaptiveAliases: List = listOf("套餐"), +) + +data class SelectionTextAliases( + val summaryPrefixes: List = listOf("已选", "请选择", "已選", "請選擇"), + val selectedPrefixes: List = listOf("已选", "已選"), + val specEntryPrefixes: List = listOf("请选择", "选择", "已选"), + val submitHintAliases: List = listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格"), +) + +data class ReviewTextAliases( + val entryAliases: List = listOf("商品评价", "全部评价", "评价详情", "评论", "晒单", "问答"), +) + +data class SoldOutTextAliases( + val mainProductExactAliases: List = listOf("进店", "单独购买", "发起拼单", "立即购买"), + val mainProductPrefixAliases: List = listOf("商品评价"), +) + +data class PurchaseTextAliases( + val buyWords: List = 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 { 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) = 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 { + 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): Map { diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index 8f34036..b4bece2 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -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() diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt index fabd7d7..3a0a096 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt @@ -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>, private val activity: String = "com.xunmeng.pinduoduo.activity.NewPageActivity", diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index a201f89..32e8d10 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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 # 业务规则与术语 @@ -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 内不可配置的创建订单/确认订单/支付/付款点击拒绝清单始终优先,规则不能放宽;修改地址和收货地址在只读及点击字段中都禁止。 - 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。 - 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index ac6aa5c..3ac7f33 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -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 # 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,不能由规则覆盖。 例如规格面板打开后向上滑动两次,只修改规则: diff --git a/docs/rules/pdd-product-detail-v2.proposed.json b/docs/rules/pdd-product-detail-v2.proposed.json index ed2002c..c12e175 100644 --- a/docs/rules/pdd-product-detail-v2.proposed.json +++ b/docs/rules/pdd-product-detail-v2.proposed.json @@ -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, diff --git a/server/app/goauto/rulecontract/contract.go b/server/app/goauto/rulecontract/contract.go index dcd606c..ea649e9 100644 --- a/server/app/goauto/rulecontract/contract.go +++ b/server/app/goauto/rulecontract/contract.go @@ -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 } } } diff --git a/server/app/goauto/rulecontract/contract_test.go b/server/app/goauto/rulecontract/contract_test.go index 6d71a50..8ebee95 100644 --- a/server/app/goauto/rulecontract/contract_test.go +++ b/server/app/goauto/rulecontract/contract_test.go @@ -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) diff --git a/server/app/goauto/rulecontract/template.go b/server/app/goauto/rulecontract/template.go index 5df79cd..874c185 100644 --- a/server/app/goauto/rulecontract/template.go +++ b/server/app/goauto/rulecontract/template.go @@ -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} }