From 72d614524b39aed3b168ff4573b7fd5cbf2be5f8 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 17:03:52 +0800 Subject: [PATCH] fix: complete PDD spec panel traversal (#26) --- android/README.md | 6 +- .../automation/GoAutoAccessibilityService.kt | 120 ++++++--- .../automation/PddProductDetailCollector.kt | 241 ++++++++++++++---- .../agent/service/AgentForegroundService.kt | 6 +- .../agent/PddProductDetailCollectorTest.kt | 115 +++++++-- docs/00-project-profile.md | 2 +- docs/03-business-rules-and-glossary.md | 1 + docs/08-agent-api-contract.md | 8 +- docs/09-delivery-issues.md | 1 + docs/10-real-device-acceptance.md | 10 + docs/11-pdd-detail-rule-migration-analysis.md | 2 +- 11 files changed, 407 insertions(+), 105 deletions(-) diff --git a/android/README.md b/android/README.md index 09080e7..5737d30 100644 --- a/android/README.md +++ b/android/README.md @@ -20,13 +20,15 @@ - ColorOS 首次 ADB 安装需要在“安装增强防护”页执行“更多 → 开始深度扫描 → 继续安装”。 - 当前 MVP 只完成并维护一加/ColorOS 真机兼容,不包含华为 ROM。 -## T23 v2 真机验证进展 +## T23~T24 v2 真机验证 - 一加 PKG110 / Android 16 已验证 v2 能力上报、正式任务领取、浏览器到 PDD `NewPageActivity`、规格面板打开、颜色文字点击和逐颜色稳定价格。 - 图片型颜色卡片只点击文字节点,不点击整卡或“打开大图/查看大图”。 - 规格值会排除“#一次选多款#”等面板功能入口,避免把操作按钮伪装成尺码和 SKU。 - 商品 `719834019024` 已采到 4 个颜色、4 个 2690 分颜色价格和 4 个结构化 SKU,缺少尺码、店铺和评价时正确提交 `completed_partial`。 -- 当前仍需一个有效的颜色 + 尺码商品完成全部尺码和多维 SKU 真机复核;不以已失效或只含单维规格的商品宣称通过。 +- 商品 `236231603269` 已验证规格面板归顶、锚定横纵容器、逐行蛇形颜色遍历和尺码续页:14 个颜色、14 个颜色价格、8 个尺码和 112 个完整可用 SKU,任务最终为 `completed`。 +- 滚动优先使用目标容器的无障碍 `ACTION_SCROLL_FORWARD/BACKWARD`;回退坐标手势时等待系统完成回调后才读取新树,避免把未结束手势误判成列表边缘。 +- 商品标题回退只接受与销量节点空间关联的文本,不再把页面中任意最长文本当成标题。 ```powershell .\gradlew.bat test 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 ffae6da..fb46de7 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 @@ -9,8 +9,12 @@ import android.graphics.Path import android.graphics.Rect import android.os.Build import android.os.Bundle +import android.util.Log import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver { private val activityTracker by lazy { @@ -92,37 +96,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto val node = (directionalCandidates.ifEmpty { candidates }).maxByOrNull { candidate -> Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height().toLong() } } ?: return false - val bounds = Rect().also(node::getBoundsInScreen) - if (bounds.width() < 2 || bounds.height() < 2) return false - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - val action = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) { - AccessibilityNodeInfo.ACTION_SCROLL_FORWARD - } else { - AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD - } - return node.performAction(action) - } - val left = bounds.left + bounds.width() * 25 / 100 - val right = bounds.left + bounds.width() * 75 / 100 - val top = bounds.top + bounds.height() * 25 / 100 - val bottom = bounds.top + bounds.height() * 75 / 100 - val centerX = bounds.centerX() - val centerY = bounds.centerY() - val (startX, startY, endX, endY) = when (direction) { - SwipeDirection.UP -> listOf(centerX, bottom, centerX, top) - SwipeDirection.DOWN -> listOf(centerX, top, centerX, bottom) - SwipeDirection.LEFT -> listOf(right, centerY, left, centerY) - SwipeDirection.RIGHT -> listOf(left, centerY, right, centerY) - } - val path = Path().apply { - moveTo(startX.toFloat(), startY.toFloat()) - lineTo(endX.toFloat(), endY.toFloat()) - } - return dispatchGesture( - GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 450)).build(), - null, - null, - ) + Log.i("GoAutoCollector", "swipe direction=$direction anchored=false bounds=${Rect().also(node::getBoundsInScreen)} class=${node.className}") + return swipeNode(node, direction) } override fun capture(): UiSnapshot { @@ -172,7 +147,88 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) FreshActionResult.SUCCESS else FreshActionResult.FAILED } - override fun swipeSpec(direction: SwipeDirection): Boolean = swipe(SemanticTarget.SPEC_PANEL, direction) + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { + if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction) + val root = rootInActiveWindow ?: return false + val matches = mutableListOf() + walk(root) { node -> + val bounds = Rect().also(node::getBoundsInScreen) + if (node.preferredOrDescendantLabel() == anchor.label && + node.className?.toString() == anchor.className && + kotlin.math.abs(bounds.centerX() - anchor.bounds.centerX) <= 32 && + kotlin.math.abs(bounds.centerY() - anchor.bounds.centerY) <= 32 + ) matches += node + } + if (matches.size != 1) return swipe(SemanticTarget.SPEC_PANEL, direction) + val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT + val scrollableAncestors = mutableListOf() + var current: AccessibilityNodeInfo? = matches.single() + while (current != null) { + if (current.isScrollable) scrollableAncestors += current + current = current.parent + } + val anchoredTarget = if (horizontal) { + scrollableAncestors.firstOrNull { candidate -> + Rect().also(candidate::getBoundsInScreen).let { it.width() > it.height() } + } + } else { + scrollableAncestors.maxByOrNull { candidate -> + Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height().toLong() } + } + } + if (anchoredTarget != null) { + val bounds = Rect().also(anchoredTarget::getBoundsInScreen) + Log.i("GoAutoCollector", "swipe direction=$direction anchored=true bounds=$bounds class=${anchoredTarget.className}") + return swipeNode(anchoredTarget, direction) + } + return swipe(SemanticTarget.SPEC_PANEL, direction) + } + + private fun swipeNode(node: AccessibilityNodeInfo, direction: SwipeDirection): Boolean { + val bounds = Rect().also(node::getBoundsInScreen) + if (bounds.width() < 2 || bounds.height() < 2) return false + val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) { + AccessibilityNodeInfo.ACTION_SCROLL_FORWARD + } else { + AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD + } + if (node.performAction(scrollAction)) return true + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false + val left = bounds.left + bounds.width() * 25 / 100 + val right = bounds.left + bounds.width() * 75 / 100 + val top = bounds.top + bounds.height() * 25 / 100 + val bottom = bounds.top + bounds.height() * 75 / 100 + val centerX = bounds.centerX() + val centerY = bounds.centerY() + val points = when (direction) { + SwipeDirection.UP -> listOf(centerX, bottom, centerX, top) + SwipeDirection.DOWN -> listOf(centerX, top, centerX, bottom) + SwipeDirection.LEFT -> listOf(right, centerY, left, centerY) + SwipeDirection.RIGHT -> listOf(left, centerY, right, centerY) + } + val path = Path().apply { + moveTo(points[0].toFloat(), points[1].toFloat()) + lineTo(points[2].toFloat(), points[3].toFloat()) + } + val completed = AtomicBoolean(false) + val latch = CountDownLatch(1) + val queued = dispatchGesture( + GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 450)).build(), + object : GestureResultCallback() { + override fun onCompleted(gestureDescription: GestureDescription?) { + completed.set(true) + latch.countDown() + } + + override fun onCancelled(gestureDescription: GestureDescription?) { + latch.countDown() + } + }, + null, + ) + if (!queued) return false + return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get() + } private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) { visit(node) 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 10d21c4..7c3c2bb 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 @@ -40,7 +40,7 @@ enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED } interface PddCollectorDriver { fun capture(): UiSnapshot fun clickFresh(target: SnapshotNode): FreshActionResult - fun swipeSpec(direction: SwipeDirection): Boolean + fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean } data class VisibleSpecValue(val text: String, val available: Boolean, val node: SnapshotNode) @@ -56,6 +56,7 @@ data class ParsedPddScreen( val quickConfirmationEntry: SnapshotNode?, val pageEvidenceMatched: Boolean, val problem: PageProblem?, + val sourceNodes: List, ) object PddScreenParser { @@ -63,9 +64,10 @@ object PddScreenParser { 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 excludedExactOptionLabels = setOf("客服", "联系客服", "店铺", "收藏", "关闭") fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen { val visibleNodes = snapshot.nodes.filter { it.visible } @@ -113,7 +115,7 @@ object PddScreenParser { .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) } } + .filterNot { node -> isExcludedOptionLabel(node.label) } .map { val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ") VisibleSpecValue(it.label, it.enabled && !stateText.containsUnavailableWord(), it) @@ -172,9 +174,15 @@ object PddScreenParser { snapshot.nodes.any { it.visible && it.matches(evidence.selector) } ), problem = problem, + sourceNodes = visibleNodes, ) } + internal fun isExcludedOptionLabel(label: String): Boolean { + val compact = label.replace(" ", "").trim() + return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) } + } + private fun SnapshotNode.matches(selector: NodeSelector): Boolean = (selector.resourceId == null || resourceId == selector.resourceId) && (selector.text == null || text == selector.text) && @@ -287,12 +295,23 @@ object PddScreenParser { .minWithOrNull(compareBy { safeOptionRank(it, source) }.thenBy { it.bounds.top }.thenBy { it.bounds.left }) } - private fun title(nodes: List, labels: List): String? = + private fun title(nodes: List, @Suppress("UNUSED_PARAMETER") 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) + ?: nodes.filter { node -> + node.className?.endsWith("TextView") == true && !node.clickable && node.label.length in 12..500 + }.filterNot { node -> + listOf("通知", "支付", "已拼", "评价", "请选择", "确认", "收货地址", "快递", "拒收").any(node.label::contains) + }.mapNotNull { candidate -> + val distance = nodes.asSequence() + .filter { salesPattern.containsMatchIn(it.label) } + .map { sales -> kotlin.math.abs(candidate.bounds.centerY - sales.bounds.centerY) } + .filter { it <= 600 } + .minOrNull() + ?: return@mapNotNull null + candidate to distance + }.minWithOrNull(compareBy> { it.second }.thenByDescending { it.first.label.length }) + ?.first + ?.label private fun shopName(nodes: List, labels: List): String? { labels.firstOrNull { it.endsWith("旗舰店") || it.endsWith("专卖店") || it.endsWith("专营店") }?.let { return it } @@ -321,11 +340,20 @@ data class PddCollectorResult( ) private data class StablePriceResult(val priceCent: Long?, val problem: PageProblem? = null) +private data class SizeContinuation( + val containerPath: String, + val optionClasses: Set, + val minHeight: Int, + val maxHeight: Int, + val maxWidth: Int, + val colorLabels: Set, +) class PddProductDetailCollector( private val driver: PddCollectorDriver, private val now: () -> Long = System::currentTimeMillis, private val pause: (Long) -> Unit = Thread::sleep, + private val trace: (String) -> Unit = {}, ) { fun collect(goodsId: String, rule: CollectionRule): PddCollectorResult { val config = rule.collector ?: return failure("RULE_INVALID", "v2 规则缺少采集器配置") @@ -336,6 +364,14 @@ class PddProductDetailCollector( current.problem?.let { return failure(it.code, it.message) } var summary = current.summary var specEntry = current.specEntry + val entryReadyDeadline = minOf(deadline, now() + 2_000) + while (!current.specPanelOpen && specEntry == null && now() <= entryReadyDeadline) { + pause(100) + current = parse(goodsId, config, evidence) + current.problem?.let { return failure(it.code, it.message) } + summary = mergeSummary(summary, current.summary) + specEntry = current.specEntry + } for (swipeCount in 0..config.limits.getValue("goodsPageVerticalSwipes")) { if (current.specPanelOpen || (summary.shopName != null && summary.reviewCount != null && specEntry != null)) break if (swipeCount == config.limits.getValue("goodsPageVerticalSwipes") || !driver.swipeSpec(SwipeDirection.UP)) break @@ -439,40 +475,83 @@ class PddProductDetailCollector( missing: MutableSet, unsupported: MutableSet, ): PddCollectorResult? { + moveSpecPanelToTop(goodsId, config, evidence, deadline)?.let { return it } moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it } + val initial = parse(goodsId, config, evidence) + initial.problem?.let { return failure(it.code, it.message) } + val rowCount = colorRows(initial).size + if (rowCount == 0) return null + val attempted = mutableSetOf() + for (rowIndex in 0 until rowCount) { + val moveRight = rowIndex % 2 == 0 + val rowColors = linkedMapOf() + var previous = emptyList() + var stable = 0 + for (pass in 0..config.limits.getValue("specHorizontalSwipes")) { + if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时") + var currentRow = emptyList() + while (true) { + val screen = parse(goodsId, config, evidence) + screen.problem?.let { return failure(it.code, it.message) } + if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") + screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name } + val rows = colorRows(screen) + if (rowIndex >= rows.size) { + missing += "colorRow:${rowIndex + 1}" + break + } + currentRow = rows[rowIndex].sortedBy { it.node.bounds.left } + .let { if (moveRight) it else it.reversed() } + currentRow.forEach { rowColors[it.text] = rowColors[it.text] == true || it.available } + val value = currentRow.firstOrNull { it.available && it.text !in attempted } ?: break + attempted += value.text + when (driver.clickFresh(value.node)) { + FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件") + FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> { + missing += "selection:${value.text}" + continue + } + FreshActionResult.SUCCESS -> Unit + } + val sampled = stablePrice(goodsId, value.text, config, evidence) + sampled.problem?.let { return failure(it.code, it.message) } + sampled.priceCent?.let { prices[value.text] = it } ?: run { missing += "price:${value.text}" } + } + val signature = optionSignature(currentRow) + stable = if (signature == previous) stable + 1 else 0 + previous = signature + if (stable >= config.limits.getValue("stableEdgeReads") || pass == config.limits.getValue("specHorizontalSwipes")) break + val anchor = currentRow.firstOrNull()?.node ?: break + val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT + if (!driver.swipeSpec(direction, anchor)) break + pause(350) + } + val naturalOrder = rowColors.entries.toList().let { if (moveRight) it else it.reversed() } + naturalOrder.forEach { (text, available) -> colors[text] = colors[text] == true || available } + } + return null + } + + private fun moveSpecPanelToTop( + goodsId: String, + config: PddCollectorConfig, + evidence: PageEvidence, + deadline: Long, + ): PddCollectorResult? { var previous = emptyList() var stable = 0 - val attempted = mutableSetOf() - repeat(config.limits.getValue("specHorizontalSwipes") + 1) { pass -> + repeat(config.limits.getValue("specVerticalSwipes")) { if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时") - var visible: List - while (true) { - val screen = parse(goodsId, config, evidence) - screen.problem?.let { return failure(it.code, it.message) } - if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") - screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name } - visible = screen.dimensions.filter { it.key == "color" }.flatMap { snakeOrder(it.values) } - visible.forEach { colors[it.text] = colors[it.text] == true || it.available } - val value = visible.firstOrNull { it.available && it.text !in attempted } ?: break - attempted += value.text - when (driver.clickFresh(value.node)) { - FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件") - FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> { - missing += "selection:${value.text}" - continue - } - FreshActionResult.SUCCESS -> Unit - } - val sampled = stablePrice(goodsId, value.text, config, evidence) - sampled.problem?.let { return failure(it.code, it.message) } - sampled.priceCent?.let { prices[value.text] = it } ?: run { missing += "price:${value.text}" } - } - val signature = visible.map { it.text } + val screen = parse(goodsId, config, evidence) + screen.problem?.let { return failure(it.code, it.message) } + if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") + val signature = viewportSignature(screen) stable = if (signature == previous) stable + 1 else 0 previous = signature - if (stable >= config.limits.getValue("stableEdgeReads") || pass == config.limits.getValue("specHorizontalSwipes")) return null - if (!driver.swipeSpec(SwipeDirection.LEFT)) return null - pause(200) + if (stable >= config.limits.getValue("stableEdgeReads")) return null + val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: return null + if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null + pause(350) } return null } @@ -490,12 +569,14 @@ class PddProductDetailCollector( val screen = parse(goodsId, config, evidence) screen.problem?.let { return failure(it.code, it.message) } if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") - val signature = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } + val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values } + val signature = optionSignature(values) stable = if (signature == previous) stable + 1 else 0 previous = signature if (stable >= config.limits.getValue("stableEdgeReads")) return null - if (!driver.swipeSpec(SwipeDirection.RIGHT)) return null - pause(200) + val anchor = values.firstOrNull()?.node ?: return null + if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) return null + pause(350) } return null } @@ -510,24 +591,71 @@ class PddProductDetailCollector( ): PddCollectorResult? { var previous = emptyList() var stable = 0 + var continuation: SizeContinuation? = null repeat(config.limits.getValue("specVerticalSwipes") + 1) { pass -> if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时") val screen = parse(goodsId, config, evidence) screen.problem?.let { return failure(it.code, it.message) } if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name } - val visible = screen.dimensions.filter { it.key == "size" }.flatMap { it.values } + val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values } + if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes) + val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation) visible.forEach { sizes[it.text] = sizes[it.text] == true || it.available } - val signature = screen.dimensions.flatMap { dimension -> dimension.values.map { "${dimension.key}:${it.text}" } } + 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}") stable = if (signature == previous) stable + 1 else 0 previous = signature if (stable >= config.limits.getValue("stableEdgeReads") || pass == config.limits.getValue("specVerticalSwipes")) return null - if (!driver.swipeSpec(SwipeDirection.UP)) return null + val anchor = visible.firstOrNull()?.node + ?: screen.dimensions.filter { it.key == "color" }.flatMap { it.values }.firstOrNull()?.node + ?: return null + trace("sizes swipe=UP anchorBounds=${anchor.bounds}") + if (!driver.swipeSpec(SwipeDirection.UP, anchor)) return null pause(350) } return null } + private fun buildSizeContinuation(screen: ParsedPddScreen, values: List): SizeContinuation? { + val byPath = screen.sourceNodes.associateBy(SnapshotNode::path) + val containers = values.mapNotNull { value -> + var current = value.node.parentPath?.let(byPath::get) + while (current != null && !current.scrollable) current = current.parentPath?.let(byPath::get) + current + } + val container = containers.groupBy(SnapshotNode::path).maxByOrNull { it.value.size }?.value?.firstOrNull() ?: return null + return SizeContinuation( + containerPath = container.path, + optionClasses = values.map { it.node.className }.toSet(), + minHeight = values.minOf { it.node.bounds.height }, + maxHeight = values.maxOf { it.node.bounds.height }, + maxWidth = values.maxOf { it.node.bounds.width }, + colorLabels = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text }.toSet(), + ) + } + + private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?): List { + if (context == null) return emptyList() + val headingLabels = setOf("颜色分类", "颜色", "花色", "款式", "尺码", "尺寸", "规格", "型号", "套餐", "容量", "类型", "版本", "口味") + val priceOnly = Regex("^(?:券后|到手价|拼单价|单买价|价格)?[¥¥]\\s*\\d+(?:\\.\\d{1,2})?$") + return screen.sourceNodes.asSequence() + .filter { it.visible && it.enabled && it.clickable } + .filter { it.path.startsWith("${context.containerPath}/") } + .filter { it.className in context.optionClasses } + .filter { it.bounds.height in (context.minHeight / 2).coerceAtLeast(1)..context.maxHeight * 2 } + .filter { it.bounds.width <= maxOf(context.maxWidth * 2, 600) } + .filter { it.label.isNotBlank() && it.label.length <= 80 } + .filterNot { it.label in context.colorLabels || it.label.replace(" ", "") in headingLabels } + .filterNot { PddScreenParser.isExcludedOptionLabel(it.label) || priceOnly.matches(it.label.replace(" ", "")) } + .map { node -> + VisibleSpecValue(node.label, node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains), node) + } + .groupBy(VisibleSpecValue::text) + .map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } } + .sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left })) + } + private fun stablePrice(goodsId: String, color: String, config: PddCollectorConfig, evidence: PageEvidence): StablePriceResult { val selectionDeadline = now() + config.timeoutsMs.getValue("selection") var priceDeadline: Long? = null @@ -571,11 +699,32 @@ class PddProductDetailCollector( private fun parse(goodsId: String, config: PddCollectorConfig, evidence: PageEvidence) = PddScreenParser.parse(driver.capture(), config, goodsId, evidence) - private fun snakeOrder(values: List): List { - val rows = values.groupBy { it.node.bounds.centerY / 24 }.toSortedMap() - return rows.entries.flatMapIndexed { index, entry -> - entry.value.sortedBy { it.node.bounds.left }.let { if (index % 2 == 0) it else it.reversed() } + private fun colorRows(screen: ParsedPddScreen): List> { + val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values } + .sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left })) + val rows = mutableListOf>() + val centers = mutableListOf() + values.forEach { value -> + val tolerance = (value.node.bounds.height / 2).coerceIn(24, 80) + val index = centers.indexOfFirst { kotlin.math.abs(value.node.bounds.centerY - it) <= tolerance } + if (index < 0) { + rows += mutableListOf(value) + centers += value.node.bounds.centerY + } else { + rows[index] += value + centers[index] = rows[index].map { it.node.bounds.centerY }.average().toInt() + } } + return rows.onEach { it.sortBy { value -> value.node.bounds.left } } + } + + private fun optionSignature(values: List): List = values.map { value -> + val bounds = value.node.bounds + "${value.text}:${bounds.left},${bounds.top},${bounds.right},${bounds.bottom}" + } + + private fun viewportSignature(screen: ParsedPddScreen): List = screen.dimensions.flatMap { dimension -> + optionSignature(dimension.values).map { "${dimension.key}:$it" } } private fun mergeSummary(first: ProductSummary, second: ProductSummary) = ProductSummary( diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index 6fe1129..865e5f3 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -15,6 +15,7 @@ import android.net.NetworkRequest import android.os.Build import android.os.IBinder import android.os.PowerManager +import android.util.Log import cn.ilapage.goauto.agent.BuildConfig import cn.ilapage.goauto.agent.MainActivity import cn.ilapage.goauto.agent.R @@ -206,7 +207,10 @@ class AgentForegroundService : Service() { val execution = RuleExecutor(accessibility).execute(rule) if (!execution.successful) throw TaskFailure(execution.code, execution.message) val result = if (rule.schemaVersion == 2) { - val collection = PddProductDetailCollector(accessibility).collect(task.goodsIdSnapshot, rule) + val collection = PddProductDetailCollector( + accessibility, + trace = { message -> Log.i("GoAutoCollector", message) }, + ).collect(task.goodsIdSnapshot, rule) if (!collection.successful) throw TaskFailure(collection.code, collection.message) requireNotNull(collection.payload) } else { 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 1173f65..8b70efa 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,23 @@ class PddProductDetailCollectorTest { assertEquals(listOf("红色", "蓝色", "黑色", "白色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text }) } + @Test + fun unrelatedLongTextIsNotUsedAsProductTitle() { + val driver = FakeCollectorDriver() + val snapshot = driver.capture().copy(nodes = driver.capture().nodes + node( + "unrelated", + "这是一段远离商品销量区域的非商品页面长文本,不应该被识别成商品标题", + 20, + 1400, + 1000, + 1500, + )) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertEquals("这是一个足够长的拼多多测试商品标题", parsed.summary.title) + } + @Test fun normalScrollableGoodsPageIsNotTreatedAsSpecPanel() { val snapshot = UiSnapshot( @@ -170,6 +187,28 @@ class PddProductDetailCollectorTest { assertEquals(listOf("M码80-120斤"), parsed.dimensions.first { it.key == "size" }.values.map { it.text }) } + @Test + fun legitimateSizeContainingCustomerServiceTextIsPreserved() { + 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-heading", "尺码", 20, 650, 300, 700), + node("custom", "定制图案文字 联系客服", 30, 730, 520, 810, 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 == "size" }.values.map { it.text }) + } + @Test fun clickableNextHeadingContainerCannotBecomePreviousDimensionValue() { val snapshot = UiSnapshot( @@ -212,6 +251,37 @@ class PddProductDetailCollectorTest { assertTrue(driver.captureCount > driver.clickCount) } + @Test + fun colorsAreClickedByVisualRowsInSnakeOrder() { + val colors = listOf("A色", "B色", "C色", "D色", "E色", "F色") + val driver = FakeCollectorDriver(colors = colors, prices = colors.associateWith { 1000L }, rowSize = 3) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertEquals(listOf("A色", "B色", "C色", "F色", "E色", "D色"), driver.clickedLabels) + assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values) + assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN && it.second != null }) + assertTrue(driver.swipes.any { it.first == SwipeDirection.RIGHT && it.second != null }) + assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null }) + } + + @Test + fun sizesContinueAfterHeadingScrollsOutOfView() { + val driver = FakeCollectorDriver( + colors = listOf("红色"), + sizePages = listOf(listOf("S", "M"), listOf("L", "XL")), + hideDimensionHeadingsAfterFirstVerticalPage = true, + ) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertEquals(listOf("S", "M", "L", "XL"), requireNotNull(result.payload).dimensions.first { it.key == "size" }.values) + } + @Test fun selectionFailureAndUnstablePriceBecomePartialWithoutGuessing() { val driver = FakeCollectorDriver( @@ -290,10 +360,12 @@ class PddProductDetailCollectorTest { private val specialText: String? = null, private val includeDangerousActions: Boolean = false, private val rowSize: Int = 4, + private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false, ) : PddCollectorDriver { var captureCount = 0 var clickCount = 0 val clickedLabels = mutableListOf() + val swipes = mutableListOf>() private var selected: String? = null private var horizontalPage = 0 private var verticalPage = 0 @@ -309,30 +381,34 @@ class PddProductDetailCollectorTest { 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), ) - colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)].forEachIndexed { index, color -> - val row = index / rowSize - val column = index % rowSize - nodes += node( - "color-$color-$captureCount", - color, - 30 + column * 230, - 470 + row * 90, - 220 + column * 230, - 540 + row * 90, - clickable = true, - selected = selected == color, - ) + val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0 + if (!continuationPage) { + nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll") + colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)].forEachIndexed { index, color -> + val row = index / rowSize + val column = index % rowSize + nodes += node( + "scroll/color-$color-$captureCount", + color, + 30 + column * 230, + 470 + row * 90, + 220 + column * 230, + 540 + row * 90, + clickable = true, + selected = selected == color, + parentPath = "scroll", + ) + } } - nodes += node("size-heading", "尺码", 20, 700, 300, 750) + if (!continuationPage) nodes += node("scroll/size-heading", "尺码", 20, 700, 300, 750, parentPath = "scroll") sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size -> - nodes += node("size-$size-$captureCount", size, 30 + index * 230, 770, 220 + index * 230, 840, clickable = true) + nodes += node("scroll/size-$size-$captureCount", size, 30 + index * 230, 770, 220 + index * 230, 840, clickable = true, parentPath = "scroll") } if (extraDimension) { - nodes += node("capacity-heading", "容量", 20, 900, 300, 950) - nodes += node("capacity", "大容量", 30, 970, 220, 1040, clickable = true) + nodes += node("scroll/capacity-heading", "容量", 20, 900, 300, 950, parentPath = "scroll") + nodes += node("scroll/capacity", "大容量", 30, 970, 220, 1040, clickable = true, parentPath = "scroll") } if (includeDangerousActions) { nodes += node("order", "提交订单", 30, 1080, 500, 1160, clickable = true) @@ -363,7 +439,8 @@ class PddProductDetailCollectorTest { return FreshActionResult.SUCCESS } - override fun swipeSpec(direction: SwipeDirection): Boolean { + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { + swipes += direction to anchor when (direction) { SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex) SwipeDirection.UP -> verticalPage = (verticalPage + 1).coerceAtMost(sizePages.lastIndex) diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 7efa8f3..a0dd0c6 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -32,4 +32,4 @@ ## 当前阶段 -当前 MVP 的 T01~T07、T09~T22 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T23 已完成 v2 一加真机实施验证,覆盖浏览器跳转、颜色文字安全点击、逐颜色稳定价格、全部可见尺码读取和颜色 × 尺码 SKU 展开,正在等待用户验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。 +当前 MVP 的 T01~T07、T09~T22 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T23 已完成 v2 一加真机实施验证,正在等待用户验收。T24 已完成规格面板锚定滚动、逐行蛇形颜色遍历和尺码续页实现,并以商品 `236231603269` 验证 14 色、8 尺码和 112 个 SKU,正在等待用户验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 7811d36..8b1f013 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -20,6 +20,7 @@ - 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。 - 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。 - v2 规则使用类型化动作和固定阶段钩子。已由 Agent 支持的选择器、别名、超时、滑动方向和有限次数可以只更新规则;新增动作类型或页面算法才需要升级 Agent。 +- 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。 - Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则未来可以使用独立的创建订单能力,任何规则都不能付款。 ## 采集任务 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index c8df10e..211ba31 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -87,9 +87,11 @@ Android 的 `pddProductDetailV1` 采集器执行以下固定流程: 1. 以规则中的包名、精确 Activity 和节点选择器验证商品详情页,并持续识别登录、验证码、风控和失效商品页面。 2. 在商品页有限次纵向查找安全规格入口;规格面板必须同时具备规格维度和确认摘要或可滚动区域等强证据。 -3. 颜色列表先向起点归边,再按可见行蛇形去重遍历;每次点击都在最新无障碍树中重新定位唯一控件,确认选中后连续读取相同价格。 -4. 尺码仅通过有限次纵向滑动读取,不点击尺码;颜色价格展开到该颜色下的可用尺码 SKU。 -5. 缺失颜色价格、尺码、第三规格维度或超过 SKU 上限时提交有界的 `completed_partial`;不猜测缺失值。采集期间离开商品页或出现验证码、登录、风控时明确失败。 +3. 规格面板以有界稳定读取恢复到顶部;横向颜色容器和纵向面板容器必须由已识别规格节点的祖先关系锁定,不能只按屏幕中最大滚动区域猜测。 +4. 颜色先归左,再按视觉行执行左到右、右到左交替的蛇形遍历;每次点击都在最新无障碍树中重新定位唯一文字控件,确认选中后连续读取相同价格。 +5. 滚动优先调用目标容器的无障碍前进/后退动作;回退坐标手势时必须等待系统完成回调。视口签名包含规格文字和 bounds,连续稳定后才确认到边。 +6. 尺码仅通过有限次纵向滑动读取,不点击尺码;首次定位后保存容器和选项结构,标题滚出屏幕后仍可续页。颜色价格展开到该颜色下的可用尺码 SKU。 +7. 缺失颜色价格、尺码、第三规格维度或超过 SKU 上限时提交有界的 `completed_partial`;不猜测缺失值。采集期间离开商品页或出现验证码、登录、风控时明确失败。 无障碍节点只投影为 Agent 进程内的瞬时不可变模型,不序列化、不上传、不写入文件。完整状态机接入后 Agent 才上报 `collector.pdd.product-detail.v1`。 diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index 3015b0c..ccf0605 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -31,6 +31,7 @@ | T21 | [#23](https://git.ilapage.cn/OPC/goauto/issues/23) | Android PDD 商品详情采集器 | T20 | | T22 | [#24](https://git.ilapage.cn/OPC/goauto/issues/24) | 内置规则模板与管理端校验 | T20、T21 | | T23 | [#25](https://git.ilapage.cn/OPC/goauto/issues/25) | v2 一加真机验收 | T20~T22 | +| T24 | [#26](https://git.ilapage.cn/OPC/goauto/issues/26) | 修复 PDD 规格面板蛇形遍历与尺码续页 | T20~T23 | ## 延期 diff --git a/docs/10-real-device-acceptance.md b/docs/10-real-device-acceptance.md index d4f3788..eac41e8 100644 --- a/docs/10-real-device-acceptance.md +++ b/docs/10-real-device-acceptance.md @@ -60,6 +60,16 @@ T17 只验证 PDD 商品采集最小闭环:Agent 注册与心跳、任务串 - 标题和“已拼1件”已提交;页面未暴露店铺名和评价数量,因此按约定保存 `completed_partial` 和 `missing=[shopName, reviewCount]`,没有猜测字段。 - T23 所需的浏览器跳转、颜色文字点击、逐颜色价格、全部可见尺码和颜色 × 尺码展开均已有真机证据,实施验证完成,等待用户验收。 +## 2026-08-15 T24 长规格列表复核 + +- 商品 `236231603269` 的任务 23 初次结果只有颜色并标记 `missing:size`;真机面板实际存在尺码,确认属于 Agent 遍历提前结束,而非商品无尺码。 +- 修正后颜色容器与纵向规格面板通过当前规格节点的祖先关系分别锁定;规格面板先稳定归顶,颜色按视觉行交替方向蛇形遍历。 +- 已确认 Android 坐标手势原来在 450ms 完成前被读取,导致旧视口连续两次被误判为列表边缘。现在优先调用目标容器的无障碍滚动动作;坐标手势回退等待完成回调。 +- 最终重置复采状态为 `completed`、缺失清单为空:14 个颜色、14 个颜色价格、8 个尺码和 112 个完整可用 SKU;同一颜色价格按业务规则复制到各尺码。 +- 尺码包含 `S` 至 `4XL` 和页面实际提供的定制选项;Agent 全程只读尺码,没有点击尺码、订单或支付控件。 +- 标题候选改为与销量节点存在空间关联的文本,不再把页面任意最长文本当作商品标题;旧结果已由重置事务清除。 +- 全程只持久化结构化结果,未保存或上传原始控件树和截图。T24 实施与真机验证完成,等待用户验收。 + ## 支持范围 当前 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 ae962ac..88c2b17 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~T22 已验收:v2 契约、能力协商、Android 商品详情采集器、服务端内置模板和管理端安全编辑表单已经接入。T23 已在一加真机跑通浏览器跳转、规格面板强识别、颜色文字安全点击、逐颜色稳定价格、全部可见尺码读取和颜色 × 尺码 SKU 展开。任务 22 使用商品 `972800403573` 得到 2 个颜色、2 个尺码、2 个颜色价格和 4 个完整可用 SKU;同时通过真机问题修正排除了 `#一次选多款#` 这类功能入口。T23 实施验证完成,等待用户验收。 +T19~T22 已验收:v2 契约、能力协商、Android 商品详情采集器、服务端内置模板和管理端安全编辑表单已经接入。T23 已在一加真机跑通浏览器跳转、规格面板强识别、颜色文字安全点击、逐颜色稳定价格、全部可见尺码读取和颜色 × 尺码 SKU 展开。T24 进一步迁移并强化了源项目的逐行蛇形算法:用规格节点祖先锁定横纵容器、以文字和 bounds 判断视口、等待手势完成、尺码标题滚出后按容器结构续页。任务 23 使用商品 `236231603269` 得到 14 个颜色、8 个尺码、14 个颜色价格和 112 个完整可用 SKU,状态为 `completed`。T23、T24 实施验证完成,等待用户验收。