From 271b6dde11f6bda366ec0a733f002db155c8fdc4 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 16:09:02 +0800 Subject: [PATCH] fix: harden PDD v2 real-device collection (#25) --- android/README.md | 7 + .../automation/ActivityEvidenceTracker.kt | 17 ++ .../automation/GoAutoAccessibilityService.kt | 22 +- .../automation/PddProductDetailCollector.kt | 197 ++++++++++++++++-- .../goauto/agent/automation/RuleExecutor.kt | 5 +- .../agent/ActivityEvidenceTrackerTest.kt | 46 ++++ .../agent/PddProductDetailCollectorTest.kt | 144 ++++++++++++- .../ilapage/goauto/agent/RuleExecutorTest.kt | 23 ++ docs/00-project-profile.md | 2 +- docs/10-real-device-acceptance.md | 19 ++ docs/11-pdd-detail-rule-migration-analysis.md | 2 +- 11 files changed, 455 insertions(+), 29 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/ActivityEvidenceTracker.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/ActivityEvidenceTrackerTest.kt diff --git a/android/README.md b/android/README.md index 2d29a11..b47e732 100644 --- a/android/README.md +++ b/android/README.md @@ -20,6 +20,13 @@ - ColorOS 首次 ADB 安装需要在“安装增强防护”页执行“更多 → 开始深度扫描 → 继续安装”。 - 当前 MVP 只完成并维护一加/ColorOS 真机兼容,不包含华为 ROM。 +## T23 v2 真机验证进展 + +- 一加 PKG110 / Android 16 已验证 v2 能力上报、正式任务领取、浏览器到 PDD `NewPageActivity`、规格面板打开、颜色文字点击和逐颜色稳定价格。 +- 图片型颜色卡片只点击文字节点,不点击整卡或“打开大图/查看大图”。 +- 商品 `719834019024` 已采到 4 个颜色、4 个 2690 分颜色价格和 4 个结构化 SKU,缺少尺码、店铺和评价时正确提交 `completed_partial`。 +- 当前仍需一个有效的颜色 + 尺码商品完成全部尺码和多维 SKU 真机复核;不以已失效或只含单维规格的商品宣称通过。 + ```powershell .\gradlew.bat test .\gradlew.bat assembleDebug diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/ActivityEvidenceTracker.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/ActivityEvidenceTracker.kt new file mode 100644 index 0000000..ea7d537 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/ActivityEvidenceTracker.kt @@ -0,0 +1,17 @@ +package cn.ilapage.goauto.agent.automation + +/** Keeps the last window class that Android confirms is an Activity. */ +class ActivityEvidenceTracker( + private val isDeclaredActivity: (packageName: String, className: String) -> Boolean, +) { + @Volatile + private var activityName: String? = null + + fun observe(packageName: String?, className: String?) { + if (packageName.isNullOrBlank() || className.isNullOrBlank()) return + val normalized = if (className.startsWith('.')) packageName + className else className + if (isDeclaredActivity(packageName, normalized)) activityName = normalized + } + + fun current(): String? = activityName +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index fb68f4f..ffae6da 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -3,6 +3,8 @@ package cn.ilapage.goauto.agent.automation import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityServiceInfo import android.accessibilityservice.GestureDescription +import android.content.ComponentName +import android.content.pm.PackageManager import android.graphics.Path import android.graphics.Rect import android.os.Build @@ -11,8 +13,9 @@ import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver { - @Volatile - private var activeWindowClassName: String? = null + private val activityTracker by lazy { + ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) } + } override fun onServiceConnected() { serviceInfo = serviceInfo.apply { @@ -25,7 +28,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { - activeWindowClassName = event.className?.toString() + activityTracker.observe(event.packageName?.toString(), event.className?.toString()) } } override fun onInterrupt() = Unit @@ -36,7 +39,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } override fun currentPackage(): String? = rootInActiveWindow?.packageName?.toString() - override fun currentActivity(): String? = activeWindowClassName + override fun currentActivity(): String? = activityTracker.current() override fun visibleTexts(): List { val root = rootInActiveWindow ?: return emptyList() @@ -191,6 +194,17 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return "" } + @Suppress("DEPRECATION") + private fun isDeclaredActivity(packageName: String, className: String): Boolean = runCatching { + val component = ComponentName(packageName, className) + if (Build.VERSION.SDK_INT >= 33) { + packageManager.getActivityInfo(component, PackageManager.ComponentInfoFlags.of(0)) + } else { + packageManager.getActivityInfo(component, 0) + } + true + }.getOrDefault(false) + companion object { @Volatile var instance: GoAutoAccessibilityService? = null 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 713961a..6a60305 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 @@ -53,6 +53,7 @@ data class ParsedPddScreen( val priceCent: Long?, val specPanelOpen: Boolean, val specEntry: SnapshotNode?, + val quickConfirmationEntry: SnapshotNode?, val pageEvidenceMatched: Boolean, val problem: PageProblem?, ) @@ -61,7 +62,10 @@ object PddScreenParser { private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)") private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?") private val reviewPattern = Regex("(?:商品评价\\s*[((]?\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?|[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*条?评价|评价\\s*[0-9]+)") - private val excludedOptionWords = listOf("确定", "确认", "购买", "下单", "订单", "支付", "付款", "客服", "店铺", "收藏", "已选", "请选择", "数量") + private val excludedOptionWords = listOf( + "确定", "确认", "购买", "下单", "订单", "支付", "付款", "客服", "店铺", "收藏", + "已选", "请选择", "数量", "打开大图", "查看大图", + ) fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen { val visibleNodes = snapshot.nodes.filter { it.visible } @@ -81,25 +85,62 @@ object PddScreenParser { } val labels = visible.map(SnapshotNode::label) val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels) - val headings = visible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top } + val compactLabels = labels.map { it.replace(" ", "") } + val hasSelectionSummary = compactLabels.any { it.startsWith("已选") || it.startsWith("请选择") } + val hasSubmitHint = compactLabels.any { label -> + label.contains("提交订单") && listOf("选择", "颜色", "尺码", "规格").any(label::contains) + } + val allHeadings = visible.filter { !it.clickable && isHeading(it.label, config) } + val screenWidth = visibleNodes.maxOfOrNull { it.bounds.right } ?: 0 + val screenHeight = visibleNodes.maxOfOrNull { it.bounds.bottom } ?: 0 + val screenArea = screenWidth.toLong() * screenHeight.toLong() + val panelScrollable = visibleNodes.asSequence() + .filter { it.visible && it.scrollable && it.bounds.width > 0 && it.bounds.height > 0 } + .filter { region -> + screenArea == 0L || + (region.bounds.width.toLong() * region.bounds.height).toDouble() < screenArea * 0.9 + } + .filter { region -> allHeadings.any { heading -> heading.path.startsWith("${region.path}/") } } + .maxByOrNull { it.bounds.width.toLong() * it.bounds.height } + val panelVisible = panelScrollable?.let { region -> + visible.filter { it.path == region.path || it.path.startsWith("${region.path}/") } + } ?: visible + val headings = panelVisible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top } val dimensions = buildList { headings.forEachIndexed { index, heading -> val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE - val values = visible.asSequence() - .filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.top < lower } + val values = panelVisible.asSequence() + .filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower } .filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 } + .filterNot { isExactHeadingLabel(it.label, config) } .filterNot { node -> excludedOptionWords.any { node.label.contains(it) } } - .distinctBy { it.label } .map { val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ") VisibleSpecValue(it.label, it.enabled && !stateText.containsUnavailableWord(), it) } .toList() + .groupBy(VisibleSpecValue::text) + .map { (_, sameLabel) -> + sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) } + } if (values.isNotEmpty()) add(VisibleDimension(dimensionKey(heading.label, config, any { it.key == "color" }), heading.label, values)) } } - val strongPanelCue = labels.any { it.startsWith("已选") || it in setOf("确认", "确定", "确认款式") } - val panelOpen = dimensions.isNotEmpty() && (strongPanelCue || snapshot.nodes.any { it.visible && it.scrollable }) + val hasPanelTitle = compactLabels.any { it in setOf("确认款式", "关闭") } + val hasPanelAction = visible.any { node -> + val compact = node.label.replace(" ", "") + node.clickable && ( + compact in setOf("确定", "确认") || + (compact.contains("提交订单") && listOf("选择", "颜色", "尺码", "规格").any(compact::contains)) + ) + } + // Scrollable panels must expose a bounded nested region containing a real + // dimension heading. Custom-drawn panels use the independent title, + // selection summary and action evidence instead. + val panelOpen = dimensions.isNotEmpty() && ( + (panelScrollable != null && (hasSelectionSummary || hasSubmitHint)) || + (hasSelectionSummary && hasPanelTitle && hasPanelAction) + ) val firstHeadingTop = headings.firstOrNull()?.bounds?.top ?: Int.MAX_VALUE val price = visible.asSequence() .filter { it.bounds.top < firstHeadingTop } @@ -108,12 +149,14 @@ object PddScreenParser { val specEntry = if (panelOpen) null else visible .filter { it.clickable && isSpecEntry(it.label, config) } .maxByOrNull { it.bounds.top } + ?: safeBottomSpecEntry(visibleNodes, visible) + val quickConfirmationEntry = if (panelOpen) null else quickConfirmationSpecEntry(visibleNodes, visible, config) return ParsedPddScreen( summary = ProductSummary( pddGoodsId = goodsId, - title = title(labels), - shopName = labels.firstOrNull { it.endsWith("旗舰店") || it.endsWith("专卖店") || it.endsWith("专营店") }, - salesText = labels.firstOrNull { salesPattern.containsMatchIn(it) }, + title = title(visible, labels), + shopName = shopName(visible, labels), + salesText = labels.firstNotNullOfOrNull { salesPattern.find(it)?.value }, reviewCount = if (labels.any { it == "暂无评价" || it == "暂无评论" }) 0L else labels.firstNotNullOfOrNull { label -> reviewPattern.find(label)?.value?.let(CollectionAssembler::parseCount) }, ), @@ -122,6 +165,7 @@ object PddScreenParser { priceCent = price, specPanelOpen = panelOpen, specEntry = specEntry, + quickConfirmationEntry = quickConfirmationEntry, pageEvidenceMatched = evidence == null || ( snapshot.packageName == evidence.packageName && snapshot.activityName == evidence.activityName && @@ -152,6 +196,12 @@ object PddScreenParser { compact.endsWith("容量") || compact.endsWith("类型") || compact.endsWith("版本") || 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("颜色分类", "颜色", "花色", "款式", "尺码", "尺寸", "规格", "型号", "套餐", "容量", "类型", "版本", "口味") + } + 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" @@ -165,10 +215,96 @@ object PddScreenParser { (aliases.any(label::contains) && label.any(Char::isDigit)) } - private fun title(labels: List): String? = labels - .filter { it.length >= 12 } - .filterNot { label -> listOf("通知", "支付", "已拼", "评价", "请选择", "确认").any(label::contains) } - .maxByOrNull(String::length) + private fun safeBottomSpecEntry(source: List, visible: List): 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("购买", "拼单", "下单", "立即", "单独", "免拼") + return source.asSequence() + .filter { it.visible && it.enabled && pricePattern.containsMatchIn(it.label) } + .filter { node -> + node.bounds.centerX.toDouble() >= screenWidth * 0.4 && + node.bounds.centerY.toDouble() >= screenHeight * 0.8 + } + .mapNotNull { priceNode -> + var candidate: SnapshotNode? = priceNode + while (candidate != null && !candidate.clickable) candidate = candidate.parentPath?.let(byPath::get) + val target = candidate?.takeIf { it.visible && it.enabled } ?: return@mapNotNull null + val label = (listOf(target.label) + descendants(target, source).map(SnapshotNode::label)).joinToString(" ") + if (buyWords.none(label::contains)) return@mapNotNull null + if (target.bounds.centerY.toDouble() < screenHeight * 0.8 || target.bounds.bottom.toDouble() < screenHeight * 0.88) return@mapNotNull null + if (target.bounds.width.toDouble() < screenWidth * 0.15 || target.bounds.height.toDouble() > screenHeight * 0.3) return@mapNotNull null + val score = (if (priceNode.label.any(Char::isDigit)) 3 else 0) + + priceNode.bounds.centerY.toDouble() / screenHeight + + priceNode.bounds.centerX.toDouble() / screenWidth + (normalizedByPath[target.path] ?: target) to score + } + .maxByOrNull { it.second } + ?.first + } + + private fun safeOptionRank(node: SnapshotNode, source: List): Int = when { + node.className?.endsWith("TextView") == true -> 0 + node.className?.endsWith("ImageView") == true -> 3 + descendants(node, source).any { it.className?.endsWith("ImageView") == true } -> 2 + else -> 1 + } + + private fun quickConfirmationSpecEntry( + source: List, + visible: List, + config: PddCollectorConfig, + ): SnapshotNode? { + 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 hasQuickBuy = visible.any { node -> + node.label.replace(" ", "").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(" ", "") == "减少数量" } + val hasIncrease = visible.any { it.clickable && it.label.replace(" ", "") == "增加数量" } + if (!hasClose || !hasSummary || !hasPaymentArea || !hasQuickBuy || !hasQuantity || !hasDecrease || !hasIncrease) return null + + val headings = visible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top } + val primaryIndex = headings.indexOfFirst { dimensionKey(it.label, config, false) == "color" } + if (primaryIndex < 0) return null + val heading = headings[primaryIndex] + val lower = headings.getOrNull(primaryIndex + 1)?.bounds?.top ?: (screenBottom * 0.75).toInt() + return visible.asSequence() + .filter { it.clickable && it.visible && it.enabled } + .filter { it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower } + .filterNot { it.className?.endsWith("ImageView") == true } + .filterNot { node -> excludedOptionWords.any { node.label.contains(it) } } + .filter { it.label.isNotBlank() } + .minWithOrNull(compareBy { safeOptionRank(it, source) }.thenBy { it.bounds.top }.thenBy { it.bounds.left }) + } + + private fun title(nodes: List, labels: List): String? = + nodes.firstOrNull { it.className?.endsWith("ViewPager") == true && it.label.length >= 6 }?.label + ?: labels + .filter { it.length >= 12 } + .filterNot { label -> listOf("通知", "支付", "已拼", "评价", "请选择", "确认").any(label::contains) } + .maxByOrNull(String::length) + + private fun shopName(nodes: List, labels: List): String? { + labels.firstOrNull { it.endsWith("旗舰店") || it.endsWith("专卖店") || it.endsWith("专营店") }?.let { return it } + val anchor = nodes.filter { it.label == "进店" }.minByOrNull { it.bounds.top } ?: return null + return nodes.asSequence() + .filter { it.className?.endsWith("TextView") == true } + .filter { it.label.length in 2..30 && it.bounds.left < anchor.bounds.left } + .filter { kotlin.math.abs(it.bounds.top - anchor.bounds.top) <= 48 } + .filterNot { it.label.contains("已拼") || it.label.contains("评价") || it.label in setOf("进店", "客服", "收藏") } + .maxByOrNull { it.bounds.left } + ?.label + } private fun String.containsUnavailableWord() = contains("售罄") || contains("缺货") || contains("不可选") @@ -199,26 +335,45 @@ class PddProductDetailCollector( ?: return failure("RULE_NOT_MATCHED", "未通过 PDD 商品详情页证据校验") current.problem?.let { return failure(it.code, it.message) } var summary = current.summary + var specEntry = current.specEntry for (swipeCount in 0..config.limits.getValue("goodsPageVerticalSwipes")) { - if (current.specPanelOpen || current.specEntry != null) break - if (swipeCount == config.limits.getValue("goodsPageVerticalSwipes") || !driver.swipeSpec(SwipeDirection.UP)) { - return failure("RULE_NOT_MATCHED", "未找到 PDD 商品规格入口") - } + if (current.specPanelOpen || (summary.shopName != null && summary.reviewCount != null && specEntry != null)) break + if (swipeCount == config.limits.getValue("goodsPageVerticalSwipes") || !driver.swipeSpec(SwipeDirection.UP)) break pause(350) current = parse(goodsId, config, evidence) current.problem?.let { return failure(it.code, it.message) } summary = mergeSummary(summary, current.summary) + specEntry = current.specEntry ?: specEntry } val first = current if (!first.specPanelOpen) { - when (driver.clickFresh(requireNotNull(first.specEntry))) { + val entry = first.specEntry ?: specEntry ?: return failure("RULE_NOT_MATCHED", "未找到 PDD 商品规格入口") + when (driver.clickFresh(entry)) { FreshActionResult.SUCCESS -> Unit FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "规格入口匹配到多个控件") else -> return failure("RULE_ACTION_FAILED", "规格入口点击失败") } } - val opened = waitFor({ it.pageEvidenceMatched && it.specPanelOpen }, config.timeoutsMs.getValue("specPanel"), goodsId, config, evidence) - ?: return failure("RULE_NOT_MATCHED", "规格面板没有出现强证据") + val panelDeadline = now() + config.timeoutsMs.getValue("specPanel") + var opened: ParsedPddScreen? = null + var quickConfirmationRecovered = false + do { + val screen = parse(goodsId, config, evidence) + screen.problem?.let { return failure(it.code, it.message) } + if (screen.pageEvidenceMatched && screen.specPanelOpen) { + opened = screen + break + } + if (screen.pageEvidenceMatched && !quickConfirmationRecovered && screen.quickConfirmationEntry != null) { + when (driver.clickFresh(screen.quickConfirmationEntry)) { + FreshActionResult.SUCCESS -> quickConfirmationRecovered = true + FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "快速确认页主规格匹配到多个控件") + else -> return failure("RULE_ACTION_FAILED", "快速确认页主规格点击失败") + } + } + pause(100) + } while (now() <= panelDeadline) + if (opened == null) return failure("RULE_NOT_MATCHED", "规格面板没有出现强证据") opened.problem?.let { return failure(it.code, it.message) } summary = mergeSummary(summary, opened.summary) val hook = HookExecutor(object : UiDriver by NoopUiDriver { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleExecutor.kt index 5d1a664..0d08a95 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleExecutor.kt @@ -47,6 +47,7 @@ class RuleExecutor( for (step in rule.steps) { val deadline = now() + step.timeoutMs var lastFailure = "控件未出现" + var clickAttempted = false while (now() <= deadline) { val currentPackage = driver.currentPackage() val currentActivity = driver.currentActivity() @@ -58,15 +59,17 @@ class RuleExecutor( return RuleExecutionResult(false, problem.code, problem.message, extracted) } if (currentPackage != step.packageName) { + if (step.optional && clickAttempted) break lastFailure = "当前应用与规则不匹配" } else if (step.activityName != null && currentActivity != step.activityName) { lastFailure = "当前 Activity 与规则不匹配" } else { + if (step.action == RuleAction.CLICK) clickAttempted = true val result = executeOnce(step, extracted) if (result == null) break lastFailure = result if (result == "控件匹配到多个节点") return failure(step, "RULE_AMBIGUOUS", result, extracted) - if (result == "操作执行失败") return failure(step, "RULE_ACTION_FAILED", result, extracted) + if (result == "操作执行失败" && !step.optional) return failure(step, "RULE_ACTION_FAILED", result, extracted) } if (now() >= deadline) { if (step.optional) break diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/ActivityEvidenceTrackerTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/ActivityEvidenceTrackerTest.kt new file mode 100644 index 0000000..f5057b7 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ActivityEvidenceTrackerTest.kt @@ -0,0 +1,46 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.automation.ActivityEvidenceTracker +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ActivityEvidenceTrackerTest { + @Test + fun `popup window class cannot replace last declared activity`() { + val tracker = ActivityEvidenceTracker { _, className -> className.endsWith("Activity") } + + tracker.observe(PDD_PACKAGE, ACTIVITY) + tracker.observe(PDD_PACKAGE, "android.widget.PopupWindow${'$'}PopupDecorView") + + assertEquals(ACTIVITY, tracker.current()) + } + + @Test + fun `relative activity name is normalized before validation`() { + var observed: String? = null + val tracker = ActivityEvidenceTracker { _, className -> + observed = className + className == ACTIVITY + } + + tracker.observe(PDD_PACKAGE, ".activity.NewPageActivity") + + assertEquals(ACTIVITY, observed) + assertEquals(ACTIVITY, tracker.current()) + } + + @Test + fun `unknown window before an activity leaves evidence empty`() { + val tracker = ActivityEvidenceTracker { _, _ -> false } + + tracker.observe(PDD_PACKAGE, "android.widget.FrameLayout") + + assertNull(tracker.current()) + } + + private companion object { + const val PDD_PACKAGE = "com.xunmeng.pinduoduo" + const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity" + } +} 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 0dc0cf3..9081462 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 @@ -31,6 +31,145 @@ class PddProductDetailCollectorTest { assertEquals(listOf("红色", "蓝色", "黑色", "白色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text }) } + @Test + fun normalScrollableGoodsPageIsNotTreatedAsSpecPanel() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("scroll", "", 0, 0, 1080, 2000, scrollable = true), + node("entry", "请选择:颜色分类 尺码", 20, 900, 900, 980, clickable = true), + node("promotion", "这些人已拼,参与可立即拼成", 20, 1050, 900, 1120, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertFalse(parsed.specPanelOpen) + assertEquals("请选择:颜色分类 尺码", parsed.specEntry?.label) + } + + @Test + fun safeBottomPurchaseContainerCanOnlyOpenSpecPanel() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("buy", "", 500, 1800, 1080, 2180, clickable = true), + node("buy/price", "¥26.90", 560, 1840, 760, 1910, parentPath = "buy"), + node("buy/label", "单独购买", 780, 1840, 1040, 1910, parentPath = "buy"), + node("body-price", "¥19.90", 20, 300, 300, 380), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertFalse(parsed.specPanelOpen) + assertEquals("buy", parsed.specEntry?.path) + } + + @Test + fun imageColorCardUsesClickableCaptionInsteadOfCardOrImage() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("summary", "已选:", 20, 300, 700, 360), + node("title", "确认款式", 20, 370, 300, 410), + node("color-heading", "颜色分类", 20, 420, 300, 470), + node("scroll", "", 0, 400, 1080, 1800, scrollable = true), + node("card", "A色", 36, 500, 340, 650, clickable = true, parentPath = "scroll", className = "android.view.ViewGroup"), + node("card/image", "A色", 36, 500, 340, 620, clickable = true, parentPath = "card", className = "android.widget.ImageView"), + node("card/big", "打开大图", 280, 500, 340, 560, clickable = true, parentPath = "card", className = "android.widget.ImageView"), + node("card/caption", "A色", 36, 620, 340, 650, clickable = true, parentPath = "card"), + node("size-heading", "尺码", 20, 700, 300, 750), + node("size", "M", 36, 780, 220, 850, clickable = true), + node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + val color = parsed.dimensions.first { it.key == "color" }.values.single() + assertEquals("card/caption", color.node.path) + } + + @Test + fun quickConfirmationExposesOnlyCurrentSpecAsRecoveryEntry() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("close", "关闭", 980, 20, 1060, 100, clickable = true), + node("summary", "已选:A色 M", 20, 300, 700, 360), + node("color-heading", "颜色分类", 20, 420, 300, 470), + node("current-color", "A色", 36, 500, 340, 570, clickable = true), + node("size-heading", "尺码", 20, 620, 300, 670), + node("quantity", "1", 480, 1400, 600, 1480, className = "android.widget.EditText"), + node("decrease", "减少数量", 360, 1400, 470, 1480, clickable = true), + node("increase", "增加数量", 610, 1400, 720, 1480, clickable = true), + node("payment", "微信支付", 20, 1550, 400, 1620), + node("buy-now", "现在买", 500, 1800, 1080, 2180, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertFalse(parsed.specPanelOpen) + assertEquals("current-color", parsed.quickConfirmationEntry?.path) + } + + @Test + fun boundedScrollablePanelUsesHeadingsInsideItsOwnRegion() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("summary", "请选择:颜色分类 尺码", 20, 300, 800, 360), + node("panel", "", 0, 500, 1080, 1700, scrollable = true), + node("panel/color-heading", "颜色分类", 20, 520, 300, 570, parentPath = "panel"), + node("panel/red", "红色", 30, 600, 260, 680, clickable = true, parentPath = "panel"), + node("panel/size-heading", "尺码", 20, 800, 300, 850, parentPath = "panel"), + node("panel/m", "M", 30, 880, 260, 960, clickable = true, parentPath = "panel"), + node("outside", "这些人已拼,参与可立即拼成", 20, 1800, 900, 1880, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertTrue(parsed.specPanelOpen) + assertEquals(listOf("红色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text }) + assertEquals(listOf("M"), parsed.dimensions.first { it.key == "size" }.values.map { it.text }) + } + + @Test + fun clickableNextHeadingContainerCannotBecomePreviousDimensionValue() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("summary", "已选:", 20, 300, 800, 360), + node("title", "确认款式", 20, 370, 300, 410), + node("color-heading", "颜色分类", 20, 420, 300, 470), + node("red", "红色", 30, 500, 260, 580, clickable = true), + node("size-container", "尺码", 0, 560, 1080, 760, clickable = true), + node("size-heading", "尺码", 20, 620, 300, 670), + node("m", "M", 30, 700, 260, 760, clickable = true), + node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertEquals(listOf("红色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text }) + } + @Test fun collectsEveryColorPriceAndAllSizesUsingFreshTrees() { val driver = FakeCollectorDriver( @@ -146,6 +285,7 @@ class PddProductDetailCollectorTest { node("sales", "已拼1.2万件", 20, 250, 300, 300), node("reviews", "商品评价(1.2万)", 320, 250, 650, 300), node("selected", "已选 ${selected.orEmpty()}", 20, 320, 700, 370), + node("panel-title", "确认款式", 20, 370, 300, 410), node("color-heading", "颜色分类", 20, 400, 300, 450), node("scroll", "", 0, 380, 1080, 1900, scrollable = true), ) @@ -175,6 +315,7 @@ class PddProductDetailCollectorTest { nodes += node("order", "提交订单", 30, 1080, 500, 1160, clickable = true) nodes += node("pay", "立即支付", 520, 1080, 1020, 1160, clickable = true) } + nodes += node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true) selected?.let { color -> val cents = prices[color] if (cents != null) { @@ -226,8 +367,9 @@ class PddProductDetailCollectorTest { clickable: Boolean = false, scrollable: Boolean = false, selected: Boolean = false, + parentPath: String? = null, ) = SnapshotNode( - path, null, text, null, resourceId, className, NodeBounds(left, top, right, bottom), + path, parentPath, text, null, resourceId, className, NodeBounds(left, top, right, bottom), clickable, scrollable, selected, false, true, true, ) 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 5cde03b..ebb8f52 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 @@ -67,6 +67,29 @@ class RuleExecutorTest { assertEquals(listOf("测试商品"), result.extracted["title"]) } + @Test + fun `optional browser click accepts verified package transition even when action reports false`() { + val rule = RuleParser.parse(validV2Rule()) + var packageName = "com.heytap.browser" + val driver = object : UiDriver { + override fun currentPackage() = packageName + override fun currentActivity() = "com.xunmeng.pinduoduo.activity.NewPageActivity" + override fun visibleTexts() = emptyList() + override fun find(selector: NodeSelector) = listOf(UiNodeRef("open", "打开拼多多APP")) + override fun click(node: UiNodeRef): Boolean { + packageName = "com.xunmeng.pinduoduo" + return false + } + override fun input(node: UiNodeRef, value: String) = false + override fun back() = false + override fun swipe(target: SemanticTarget, direction: SwipeDirection) = false + } + + val result = RuleExecutor(driver).execute(rule) + + assertTrue(result.successful) + } + @Test fun `requires exact activity evidence when rule declares it`() { val rule = RuleParser.parse( diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 7e23cbd..40cf6dd 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -32,4 +32,4 @@ ## 当前阶段 -当前 MVP 的 T01~T07、T09~T21 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T22 正在提供管理端内置 v2 规则模板和安全编辑表单,随后由 T23 完成一加真机验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。 +当前 MVP 的 T01~T07、T09~T22 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T23 正在进行 v2 一加真机验收,已验证浏览器跳转、颜色文字安全点击、逐颜色稳定价格和结构化 SKU 提交;仍需使用一个当前有效且明确包含颜色与尺码的商品完成全部尺码复核。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。 diff --git a/docs/10-real-device-acceptance.md b/docs/10-real-device-acceptance.md index 9865c28..33810f8 100644 --- a/docs/10-real-device-acceptance.md +++ b/docs/10-real-device-acceptance.md @@ -38,6 +38,25 @@ T17 只验证 PDD 商品采集最小闭环:Agent 注册与心跳、任务串 - 两条任务均保存 URL、goods_id、规则和设备关联,只保存结构化缺失字段清单;规格、颜色价格和 SKU 子表没有伪造记录。 - 本轮未执行购买或支付,也未持久化原始控件树或截图。 +## 2026-08-15 T23 v2 真机复核 + +已验证: + +- 一加 PKG110 / Android 16 的设备 2 上报 `rule.schema.v2`、`action.swipe.v1` 和 `collector.pdd.product-detail.v1`,心跳在线空闲。 +- 正式规则 2 和任务 20 固化 schema v2 规则、商品 `719834019024`、URL 和设备快照;任务经历 `pending → running → completed_partial`。 +- 浏览器点击返回值不可靠时,以离开浏览器后的最终 PDD 包名和 `NewPageActivity` 为页面事实,不把已成功跳转误报为动作失败。 +- 规格入口使用右下方“价格 + 购买/拼单语义 + 最近可点击容器”强证据,只用于打开规格面板。 +- 图片型颜色卡片优先点击 `TextView` 颜色文字,排除 `ImageView`、“打开大图”和“查看大图”;真机不再进入 `SkuPhotoBrowseActivity`。 +- 任务 20 采到 4 个颜色,四个颜色价格均为 2690 分,并生成 4 个可用、完整的单维 SKU;标题和“已拼299件”已提交。 +- 管理端任务详情 API 返回 1 个规格维度、4 个颜色价格、4 个 SKU 和缺失清单,且没有原始控件树或截图字段。 +- 当前商品只暴露出颜色维度,结果按规则以 `completed_partial` 提交,缺失为 `size`、`shopName`、`reviewCount`,没有猜测数据。 +- 已验证规格标题、图片入口、窗口类名和浏览器返回值异常都会明确停止或采用受约束恢复,不点击相似候选。 + +仍待验证: + +- 需要一个当前有效、明确同时包含颜色和尺码的 PDD 商品 URL,复核全部尺码读取和颜色 × 尺码 SKU 展开。既有样例商品 `737116531267` 已失效并返回 PDD 首页,不能作为真实通过证据。 +- T23 在上述二维商品复核完成前保持进行中。 + ## 支持范围 当前 MVP 只维护一加/ColorOS 真机兼容,不包含华为 ROM。实机过程中发现的精确 Activity 页面证据和最近可点击父容器规则属于通用安全能力,继续保留。 diff --git a/docs/11-pdd-detail-rule-migration-analysis.md b/docs/11-pdd-detail-rule-migration-analysis.md index d742cd5..067c0a4 100644 --- a/docs/11-pdd-detail-rule-migration-analysis.md +++ b/docs/11-pdd-detail-rule-migration-analysis.md @@ -118,4 +118,4 @@ Agent 不限制为“只能采集”,而是注册带版本的类型化能力 ## 当前状态 -T19~T21 已验收:v2 契约、能力协商和 Android 商品详情采集器已经接入。T22 已提供服务端内置模板、参数约束接口、管理端安全编辑表单、只读 JSON 预览,并在任务详情展示颜色价格和规则快照,当前等待用户验收。T23 负责一加真机验证;在 T23 完成前,文档中的规则文件仍标记为 proposed。 +T19~T22 已验收:v2 契约、能力协商、Android 商品详情采集器、服务端内置模板和管理端安全编辑表单已经接入。T23 一加真机已跑通浏览器跳转、规格面板强识别、颜色文字安全点击、4 个颜色稳定价格和结构化 SKU 提交;当前样例只暴露一个颜色维度,仍需一个当前有效的颜色 + 尺码商品完成全部尺码复核。在该复核完成前,文档中的规则文件仍标记为 proposed,T23 保持进行中。