From 99ca400a744f10b273540bb97eee957524d5db31 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:18:03 +0800 Subject: [PATCH] feat: collect PDD product details on Android (#23) --- .../automation/GoAutoAccessibilityService.kt | 76 ++- .../automation/PddProductDetailCollector.kt | 446 ++++++++++++++++++ .../goauto/agent/automation/RuleContract.kt | 4 +- .../agent/service/AgentForegroundService.kt | 9 +- .../agent/PddProductDetailCollectorTest.kt | 251 ++++++++++ docs/00-project-profile.md | 2 +- docs/08-agent-api-contract.md | 10 + docs/11-pdd-detail-rule-migration-analysis.md | 4 +- 8 files changed, 793 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt 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 4f5326c..fb68f4f 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 @@ -5,11 +5,12 @@ import android.accessibilityservice.AccessibilityServiceInfo import android.accessibilityservice.GestureDescription import android.graphics.Path import android.graphics.Rect +import android.os.Build import android.os.Bundle import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo -class GoAutoAccessibilityService : AccessibilityService(), UiDriver { +class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver { @Volatile private var activeWindowClassName: String? = null @@ -79,11 +80,25 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver { walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node } - val node = candidates.maxByOrNull { candidate -> + val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT + val directionalCandidates = if (horizontal) candidates.filter { candidate -> + Rect().also(candidate::getBoundsInScreen).let { it.width() > it.height() } + } else candidates.filter { candidate -> + Rect().also(candidate::getBoundsInScreen).let { it.height() >= it.width() } + } + 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 @@ -107,6 +122,55 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver { ) } + override fun capture(): UiSnapshot { + val root = rootInActiveWindow ?: return UiSnapshot(currentPackage(), currentActivity(), emptyList()) + val nodes = mutableListOf() + fun snapshot(node: AccessibilityNodeInfo, path: String, parentPath: String?) { + val bounds = Rect().also(node::getBoundsInScreen) + nodes += SnapshotNode( + path = path, + parentPath = parentPath, + text = node.text?.toString(), + contentDescription = node.contentDescription?.toString(), + resourceId = node.viewIdResourceName, + className = node.className?.toString(), + bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom), + clickable = node.isClickable, + scrollable = node.isScrollable, + selected = node.isSelected, + checked = node.isChecked, + enabled = node.isEnabled, + visible = node.isVisibleToUser, + ) + for (index in 0 until node.childCount) { + node.getChild(index)?.let { snapshot(it, "$path/$index", path) } + } + } + snapshot(root, "0", null) + return UiSnapshot(root.packageName?.toString(), currentActivity(), nodes) + } + + override fun clickFresh(target: SnapshotNode): FreshActionResult { + val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND + val candidates = mutableListOf() + walk(root) { node -> + val label = node.preferredOrDescendantLabel() + val bounds = Rect().also(node::getBoundsInScreen) + if (label == target.label && + node.className?.toString() == target.className && + kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 && + kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32 + ) candidates += node + } + if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND + if (candidates.size != 1) return FreshActionResult.AMBIGUOUS + var node = candidates.single() + while (!node.isClickable) node = node.parent ?: return FreshActionResult.FAILED + return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) FreshActionResult.SUCCESS else FreshActionResult.FAILED + } + + override fun swipeSpec(direction: SwipeDirection): Boolean = swipe(SemanticTarget.SPEC_PANEL, direction) + private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) { visit(node) for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) } @@ -119,6 +183,14 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver { (selector.className == null || className?.toString() == selector.className) && (selector.clickable == null || isClickable == selector.clickable) + private fun AccessibilityNodeInfo.preferredOrDescendantLabel(): String { + (text ?: contentDescription)?.toString()?.trim()?.takeIf(String::isNotEmpty)?.let { return it } + for (index in 0 until childCount) { + getChild(index)?.preferredOrDescendantLabel()?.takeIf(String::isNotEmpty)?.let { return it } + } + return "" + } + 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 new file mode 100644 index 0000000..713961a --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -0,0 +1,446 @@ +package cn.ilapage.goauto.agent.automation + +import java.math.BigDecimal +import java.math.RoundingMode + +data class NodeBounds(val left: Int, val top: Int, val right: Int, val bottom: Int) { + val width: Int get() = (right - left).coerceAtLeast(0) + val height: Int get() = (bottom - top).coerceAtLeast(0) + val centerX: Int get() = left + width / 2 + val centerY: Int get() = top + height / 2 +} + +data class SnapshotNode( + val path: String, + val parentPath: String?, + val text: String?, + val contentDescription: String?, + val resourceId: String?, + val className: String?, + val bounds: NodeBounds, + val clickable: Boolean, + val scrollable: Boolean, + val selected: Boolean, + val checked: Boolean, + val enabled: Boolean, + val visible: Boolean, +) { + val label: String get() = text?.trim().takeUnless { it.isNullOrEmpty() } + ?: contentDescription?.trim().orEmpty() +} + +data class UiSnapshot( + val packageName: String?, + val activityName: String?, + val nodes: List, +) + +enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED } + +interface PddCollectorDriver { + fun capture(): UiSnapshot + fun clickFresh(target: SnapshotNode): FreshActionResult + fun swipeSpec(direction: SwipeDirection): Boolean +} + +data class VisibleSpecValue(val text: String, val available: Boolean, val node: SnapshotNode) +data class VisibleDimension(val key: String, val name: String, val values: List) + +data class ParsedPddScreen( + val summary: ProductSummary, + val dimensions: List, + val selectedSummary: String?, + val priceCent: Long?, + val specPanelOpen: Boolean, + val specEntry: SnapshotNode?, + val pageEvidenceMatched: Boolean, + val problem: PageProblem?, +) + +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("确定", "确认", "购买", "下单", "订单", "支付", "付款", "客服", "店铺", "收藏", "已选", "请选择", "数量") + + fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen { + val visibleNodes = snapshot.nodes.filter { it.visible } + val visible = visibleNodes.mapNotNull { node -> + val descendants = descendants(node, visibleNodes) + val resolved = node.label.ifBlank { + if (node.clickable) descendants.map(SnapshotNode::label).firstOrNull(String::isNotBlank).orEmpty() else "" + } + resolved.takeIf(String::isNotBlank)?.let { + node.copy( + text = resolved, + contentDescription = null, + selected = node.selected || descendants.any(SnapshotNode::selected), + checked = node.checked || descendants.any(SnapshotNode::checked), + ) + } + } + 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 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 } + .filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 } + .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() + 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 firstHeadingTop = headings.firstOrNull()?.bounds?.top ?: Int.MAX_VALUE + val price = visible.asSequence() + .filter { it.bounds.top < firstHeadingTop } + .mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) } + .firstOrNull() + val specEntry = if (panelOpen) null else visible + .filter { it.clickable && isSpecEntry(it.label, config) } + .maxByOrNull { it.bounds.top } + return ParsedPddScreen( + summary = ProductSummary( + pddGoodsId = goodsId, + title = title(labels), + shopName = labels.firstOrNull { it.endsWith("旗舰店") || it.endsWith("专卖店") || it.endsWith("专营店") }, + salesText = labels.firstOrNull { salesPattern.containsMatchIn(it) }, + reviewCount = if (labels.any { it == "暂无评价" || it == "暂无评论" }) 0L + else labels.firstNotNullOfOrNull { label -> reviewPattern.find(label)?.value?.let(CollectionAssembler::parseCount) }, + ), + dimensions = dimensions, + selectedSummary = labels.firstOrNull { it.startsWith("已选") }, + priceCent = price, + specPanelOpen = panelOpen, + specEntry = specEntry, + pageEvidenceMatched = evidence == null || ( + snapshot.packageName == evidence.packageName && + snapshot.activityName == evidence.activityName && + snapshot.nodes.any { it.visible && it.matches(evidence.selector) } + ), + problem = problem, + ) + } + + private fun SnapshotNode.matches(selector: NodeSelector): Boolean = + (selector.resourceId == null || resourceId == selector.resourceId) && + (selector.text == null || text == selector.text) && + (selector.contentDescription == null || contentDescription == selector.contentDescription) && + (selector.className == null || className == selector.className) && + (selector.clickable == null || clickable == selector.clickable) + + private fun descendants(node: SnapshotNode, nodes: List): List { + val prefix = "${node.path}/" + return nodes.filter { it.path.startsWith(prefix) } + } + + private fun isHeading(label: String, config: PddCollectorConfig): Boolean { + val compact = label.replace(" ", "").replace(Regex("[((]\\d+[))]$"), "") + if (compact.startsWith("请选择") || compact.startsWith("已选") || compact == "确认款式") 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("口味") + } + + 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" + else -> "unsupported" + } + + private fun isSpecEntry(label: String, config: PddCollectorConfig): Boolean { + val aliases = config.colorAliases + config.sizeAliases + return label.startsWith("请选择") || label.startsWith("选择") || + (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 String.containsUnavailableWord() = contains("售罄") || contains("缺货") || contains("不可选") + + private fun priceCent(raw: String): Long? = runCatching { + BigDecimal(raw).multiply(BigDecimal(100)).setScale(0, RoundingMode.HALF_UP).longValueExact() + }.getOrNull() +} + +data class PddCollectorResult( + val successful: Boolean, + val code: String, + val message: String, + val payload: CollectionPayload? = null, +) + +private data class StablePriceResult(val priceCent: Long?, val problem: PageProblem? = null) + +class PddProductDetailCollector( + private val driver: PddCollectorDriver, + private val now: () -> Long = System::currentTimeMillis, + private val pause: (Long) -> Unit = Thread::sleep, +) { + fun collect(goodsId: String, rule: CollectionRule): PddCollectorResult { + val config = rule.collector ?: return failure("RULE_INVALID", "v2 规则缺少采集器配置") + val evidence = rule.pageEvidence ?: return failure("RULE_INVALID", "v2 规则缺少商品页证据") + val deadline = now() + config.timeoutsMs.getValue("overall") + var current = waitFor({ screen -> screen.pageEvidenceMatched }, config.timeoutsMs.getValue("page"), goodsId, config, evidence) + ?: return failure("RULE_NOT_MATCHED", "未通过 PDD 商品详情页证据校验") + current.problem?.let { return failure(it.code, it.message) } + var summary = current.summary + 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 商品规格入口") + } + pause(350) + current = parse(goodsId, config, evidence) + current.problem?.let { return failure(it.code, it.message) } + summary = mergeSummary(summary, current.summary) + } + val first = current + if (!first.specPanelOpen) { + when (driver.clickFresh(requireNotNull(first.specEntry))) { + 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", "规格面板没有出现强证据") + opened.problem?.let { return failure(it.code, it.message) } + summary = mergeSummary(summary, opened.summary) + val hook = HookExecutor(object : UiDriver by NoopUiDriver { + override fun swipe(target: SemanticTarget, direction: SwipeDirection): Boolean = + target == SemanticTarget.SPEC_PANEL && driver.swipeSpec(direction) + }, pause).execute(rule.hooks[HookStage.AFTER_SPEC_PANEL_OPEN].orEmpty()) + if (!hook.successful) return failure(hook.code, hook.message) + + val colors = linkedMapOf() + val prices = linkedMapOf() + val missing = linkedSetOf() + val unsupported = linkedSetOf() + collectColors(goodsId, config, evidence, deadline, colors, prices, missing, unsupported)?.let { return it } + val sizes = linkedMapOf() + collectSizes(goodsId, config, evidence, deadline, sizes, unsupported)?.let { return it } + + if (colors.isEmpty()) missing += "color" + if (sizes.isEmpty()) missing += "size" + unsupported.forEach { missing += "unsupportedDimension:$it" } + colors.keys.filterNot(prices::containsKey).forEach { missing += "price:$it" } + val availableColors = colors.filterValues { it }.keys + val availableSizes = sizes.filterValues { it }.keys + val requestedSkuCount = availableColors.size.toLong() * maxOf(1, availableSizes.size).toLong() + val maxSkuCount = config.limits.getValue("maxSkuCount") + if (requestedSkuCount > maxSkuCount) missing += "skuLimit:$requestedSkuCount>$maxSkuCount" + val colorPrices = availableColors.mapNotNull { color -> prices[color]?.let { ColorPrice(color, it) } } + val skus = colorPrices.flatMap { item -> + val values = if (availableSizes.isEmpty()) listOf(null) else availableSizes.map { it } + values.map { size -> + val specs = linkedMapOf("color" to item.color) + if (size != null) specs["size"] = size + CollectedSku(specs, item.priceCent) + } + }.take(maxSkuCount) + val dimensions = buildList { + if (colors.isNotEmpty()) add(SpecDimension("color", "颜色", colors.keys.toList())) + if (sizes.isNotEmpty()) add(SpecDimension("size", "尺码", sizes.keys.toList())) + } + listOf( + "title" to summary.title, + "shopName" to summary.shopName, + "salesText" to summary.salesText, + "reviewCount" to summary.reviewCount, + ).filter { it.second == null }.forEach { missing += it.first } + val payload = CollectionPayload( + status = if (missing.isEmpty()) "completed" else "completed_partial", + product = summary, + dimensions = dimensions, + colorPrices = colorPrices, + skus = skus, + missing = missing.toList(), + ) + return PddCollectorResult(true, "OK", "PDD 商品详情采集完成", payload) + } + + private fun collectColors( + goodsId: String, + config: PddCollectorConfig, + evidence: PageEvidence, + deadline: Long, + colors: LinkedHashMap, + prices: LinkedHashMap, + missing: MutableSet, + unsupported: MutableSet, + ): PddCollectorResult? { + moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it } + var previous = emptyList() + var stable = 0 + val attempted = mutableSetOf() + repeat(config.limits.getValue("specHorizontalSwipes") + 1) { pass -> + 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 } + 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) + } + return null + } + + private fun moveColorsToStart( + goodsId: String, + config: PddCollectorConfig, + evidence: PageEvidence, + deadline: Long, + ): PddCollectorResult? { + var previous = emptyList() + var stable = 0 + repeat(config.limits.getValue("specHorizontalSwipes")) { + 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 商品详情页") + val signature = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } + 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) + } + return null + } + + private fun collectSizes( + goodsId: String, + config: PddCollectorConfig, + evidence: PageEvidence, + deadline: Long, + sizes: LinkedHashMap, + unsupported: MutableSet, + ): PddCollectorResult? { + var previous = emptyList() + var stable = 0 + 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 } + visible.forEach { sizes[it.text] = sizes[it.text] == true || it.available } + val signature = screen.dimensions.flatMap { dimension -> dimension.values.map { "${dimension.key}:${it.text}" } } + 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 + pause(350) + } + return null + } + + private fun stablePrice(goodsId: String, color: String, config: PddCollectorConfig, evidence: PageEvidence): StablePriceResult { + val selectionDeadline = now() + config.timeoutsMs.getValue("selection") + var priceDeadline: Long? = null + var previous: Long? = null + var stable = 0 + while (now() <= (priceDeadline ?: selectionDeadline)) { + val screen = parse(goodsId, config, evidence) + screen.problem?.let { return StablePriceResult(null, it) } + if (!screen.pageEvidenceMatched) return StablePriceResult(null, PageProblem("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")) + val selectionExposed = screen.dimensions.flatMap { it.values }.any { it.node.selected || it.node.checked } || screen.selectedSummary != null + val selected = screen.dimensions.flatMap { it.values }.any { it.text == color && (it.node.selected || it.node.checked) } || + screen.selectedSummary?.contains(color) == true + if (!selectionExposed || selected) { + if (priceDeadline == null) priceDeadline = now() + config.timeoutsMs.getValue("price") + val current = screen.priceCent + stable = if (current != null && current == previous) stable + 1 else if (current != null) 1 else 0 + previous = current + if (stable >= config.limits.getValue("stablePriceReads")) return StablePriceResult(current) + } + pause(100) + } + return StablePriceResult(null) + } + + private fun waitFor( + predicate: (ParsedPddScreen) -> Boolean, + timeoutMs: Int, + goodsId: String, + config: PddCollectorConfig, + evidence: PageEvidence, + ): ParsedPddScreen? { + val deadline = now() + timeoutMs + do { + val screen = parse(goodsId, config, evidence) + if (screen.problem != null || predicate(screen)) return screen + pause(100) + } while (now() <= deadline) + return null + } + + 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 mergeSummary(first: ProductSummary, second: ProductSummary) = ProductSummary( + first.pddGoodsId, + first.title ?: second.title, + first.shopName ?: second.shopName, + first.salesText ?: second.salesText, + first.reviewCount ?: second.reviewCount, + ) + + private fun failure(code: String, message: String) = PddCollectorResult(false, code, message) +} + +private object NoopUiDriver : UiDriver { + override fun currentPackage(): String? = null + override fun currentActivity(): String? = null + override fun visibleTexts(): List = emptyList() + override fun find(selector: NodeSelector): List = emptyList() + override fun click(node: UiNodeRef): Boolean = false + override fun input(node: UiNodeRef, value: String): Boolean = false + override fun back(): Boolean = false + override fun swipe(target: SemanticTarget, direction: SwipeDirection): Boolean = false +} 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 0faff59..fa607d9 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 @@ -74,9 +74,7 @@ object AgentCapabilities { const val SWIPE_V1 = "action.swipe.v1" const val PDD_PRODUCT_DETAIL_V1 = "collector.pdd.product-detail.v1" - // T20 provides the contract and swipe primitive. T21 adds the collector - // capability only after the complete state machine is wired in. - val supported: List = listOf(SCHEMA_V2, SWIPE_V1) + val supported: List = listOf(SCHEMA_V2, SWIPE_V1, PDD_PRODUCT_DETAIL_V1) } object RuleParser { 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 920ca16..6fe1129 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 @@ -22,6 +22,7 @@ import cn.ilapage.goauto.agent.automation.CollectionAssembler import cn.ilapage.goauto.agent.automation.AgentCapabilities import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService import cn.ilapage.goauto.agent.automation.PddLinkLauncher +import cn.ilapage.goauto.agent.automation.PddProductDetailCollector import cn.ilapage.goauto.agent.automation.RuleExecutor import cn.ilapage.goauto.agent.automation.RuleParser import cn.ilapage.goauto.agent.automation.RuleValidationException @@ -204,7 +205,13 @@ class AgentForegroundService : Service() { } val execution = RuleExecutor(accessibility).execute(rule) if (!execution.successful) throw TaskFailure(execution.code, execution.message) - val result = CollectionAssembler.assemble(task.goodsIdSnapshot, execution.extracted) + val result = if (rule.schemaVersion == 2) { + val collection = PddProductDetailCollector(accessibility).collect(task.goodsIdSnapshot, rule) + if (!collection.successful) throw TaskFailure(collection.code, collection.message) + requireNotNull(collection.payload) + } else { + CollectionAssembler.assemble(task.goodsIdSnapshot, execution.extracted) + } api.submitResult(task.taskId, UUID.randomUUID().toString(), result, token) stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true) } catch (error: TaskFailure) { 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 new file mode 100644 index 0000000..0dc0cf3 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -0,0 +1,251 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.automation.CollectionRule +import cn.ilapage.goauto.agent.automation.FreshActionResult +import cn.ilapage.goauto.agent.automation.NodeBounds +import cn.ilapage.goauto.agent.automation.NodeSelector +import cn.ilapage.goauto.agent.automation.PageEvidence +import cn.ilapage.goauto.agent.automation.PddCollectorConfig +import cn.ilapage.goauto.agent.automation.PddCollectorDriver +import cn.ilapage.goauto.agent.automation.PddProductDetailCollector +import cn.ilapage.goauto.agent.automation.PddScreenParser +import cn.ilapage.goauto.agent.automation.SnapshotNode +import cn.ilapage.goauto.agent.automation.SwipeDirection +import cn.ilapage.goauto.agent.automation.UiSnapshot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PddProductDetailCollectorTest { + @Test + fun parsesSummaryAndMultipleColorRows() { + val driver = FakeCollectorDriver(colors = listOf("红色", "蓝色", "黑色", "白色"), rowSize = 2) + val parsed = PddScreenParser.parse(driver.capture(), config(), GOODS_ID, evidence()) + + assertTrue(parsed.pageEvidenceMatched) + assertTrue(parsed.specPanelOpen) + assertEquals("这是一个足够长的拼多多测试商品标题", parsed.summary.title) + assertEquals("测试旗舰店", parsed.summary.shopName) + assertEquals(12000L, parsed.summary.reviewCount) + assertEquals(listOf("红色", "蓝色", "黑色", "白色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text }) + } + + @Test + fun collectsEveryColorPriceAndAllSizesUsingFreshTrees() { + val driver = FakeCollectorDriver( + colorPages = listOf(listOf("红色", "蓝色"), listOf("蓝色", "黑色")), + sizePages = listOf(listOf("S", "M"), listOf("M", "L")), + prices = mapOf("红色" to 1099L, "蓝色" to 1299L, "黑色" to 1399L), + ) + var clock = 0L + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val payload = requireNotNull(result.payload) + assertEquals("completed", payload.status) + assertEquals(listOf("红色", "蓝色", "黑色"), payload.colorPrices.map { it.color }) + assertEquals(listOf("S", "M", "L"), payload.dimensions.first { it.key == "size" }.values) + assertEquals(9, payload.skus.size) + assertTrue(driver.captureCount > driver.clickCount) + } + + @Test + fun selectionFailureAndUnstablePriceBecomePartialWithoutGuessing() { + val driver = FakeCollectorDriver( + colors = listOf("红色", "蓝色"), + sizes = listOf("M"), + prices = mapOf("红色" to 1099L, "蓝色" to 1299L), + failedClicks = setOf("红色"), + unstableColors = setOf("蓝色"), + ) + var clock = 0L + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + val payload = requireNotNull(result.payload) + + assertTrue(result.successful) + assertEquals("completed_partial", payload.status) + assertTrue(payload.missing.contains("selection:红色")) + assertTrue(payload.missing.contains("price:蓝色")) + assertTrue(payload.skus.isEmpty()) + } + + @Test + fun thirdDimensionAndSkuLimitSubmitBoundedPartialResult() { + val driver = FakeCollectorDriver( + colors = listOf("红色", "蓝色"), + sizes = listOf("S", "M"), + prices = mapOf("红色" to 1000L, "蓝色" to 1100L), + extraDimension = true, + ) + var clock = 0L + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect( + GOODS_ID, + rule(config(maxSkuCount = 2)), + ) + val payload = requireNotNull(result.payload) + + assertEquals("completed_partial", payload.status) + assertEquals(2, payload.skus.size) + assertTrue(payload.missing.any { it.startsWith("unsupportedDimension:") }) + assertTrue(payload.missing.any { it.startsWith("skuLimit:") }) + } + + @Test + fun captchaStopsCollectionWithSpecificError() { + val driver = FakeCollectorDriver(colors = listOf("红色"), specialText = "请完成安全验证") + var clock = 0L + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertFalse(result.successful) + assertEquals("PDD_CAPTCHA_REQUIRED", result.code) + assertEquals(0, driver.clickCount) + } + + @Test + fun collectorNeverClicksOrderOrPaymentControls() { + val driver = FakeCollectorDriver( + colors = listOf("红色"), + prices = mapOf("红色" to 1000L), + includeDangerousActions = true, + ) + var clock = 0L + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertEquals(listOf("红色"), driver.clickedLabels.distinct()) + } + + private class FakeCollectorDriver( + colors: List = listOf("红色"), + sizes: List = listOf("S"), + private val colorPages: List> = listOf(colors), + private val sizePages: List> = listOf(sizes), + private val prices: Map = colors.associateWith { 1000L }, + private val failedClicks: Set = emptySet(), + private val unstableColors: Set = emptySet(), + private val extraDimension: Boolean = false, + private val specialText: String? = null, + private val includeDangerousActions: Boolean = false, + private val rowSize: Int = 4, + ) : PddCollectorDriver { + var captureCount = 0 + var clickCount = 0 + val clickedLabels = mutableListOf() + private var selected: String? = null + private var horizontalPage = 0 + private var verticalPage = 0 + private var priceRead = 0 + + override fun capture(): UiSnapshot { + captureCount++ + val nodes = mutableListOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("title", "这是一个足够长的拼多多测试商品标题", 20, 100, 1000, 180), + node("shop", "测试旗舰店", 20, 190, 300, 240), + 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("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, + ) + } + nodes += node("size-heading", "尺码", 20, 700, 300, 750) + sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size -> + nodes += node("size-$size-$captureCount", size, 30 + index * 230, 770, 220 + index * 230, 840, clickable = true) + } + if (extraDimension) { + nodes += node("capacity-heading", "容量", 20, 900, 300, 950) + nodes += node("capacity", "大容量", 30, 970, 220, 1040, clickable = true) + } + if (includeDangerousActions) { + nodes += node("order", "提交订单", 30, 1080, 500, 1160, clickable = true) + nodes += node("pay", "立即支付", 520, 1080, 1020, 1160, clickable = true) + } + selected?.let { color -> + val cents = prices[color] + if (cents != null) { + priceRead++ + val shown = if (color in unstableColors && priceRead % 2 == 0) cents + 1 else cents + nodes += node("price", "¥${shown / 100}.${(shown % 100).toString().padStart(2, '0')}", 20, 330, 250, 380) + } + } + specialText?.let { nodes += node("special", it, 20, 50, 900, 100) } + return UiSnapshot(PDD_PACKAGE, ACTIVITY, nodes) + } + + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clickCount++ + clickedLabels += target.label + if (target.label in failedClicks) return FreshActionResult.FAILED + val current = capture().nodes.filter { it.label == target.label && it.clickable } + if (current.isEmpty()) return FreshActionResult.NOT_FOUND + if (current.size > 1) return FreshActionResult.AMBIGUOUS + selected = target.label + priceRead = 0 + return FreshActionResult.SUCCESS + } + + override fun swipeSpec(direction: SwipeDirection): Boolean { + when (direction) { + SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex) + SwipeDirection.UP -> verticalPage = (verticalPage + 1).coerceAtMost(sizePages.lastIndex) + else -> Unit + } + return true + } + } + + companion object { + private const val GOODS_ID = "719834019024" + private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" + private const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity" + + private fun node( + path: String, + text: String, + left: Int, + top: Int, + right: Int, + bottom: Int, + resourceId: String? = null, + className: String = "android.widget.TextView", + clickable: Boolean = false, + scrollable: Boolean = false, + selected: Boolean = false, + ) = SnapshotNode( + path, null, text, null, resourceId, className, NodeBounds(left, top, right, bottom), + clickable, scrollable, selected, false, true, true, + ) + + private fun evidence() = PageEvidence(PDD_PACKAGE, ACTIVITY, NodeSelector(resourceId = "android:id/content", className = "android.widget.FrameLayout")) + + private fun config(maxSkuCount: Int = 500) = PddCollectorConfig( + "pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color", + listOf("颜色分类", "颜色", "款式"), listOf("尺码", "尺寸", "套餐"), + mapOf("page" to 1000, "specPanel" to 1000, "selection" to 500, "price" to 500, "overall" to 10000), + mapOf("goodsPageVerticalSwipes" to 1, "specHorizontalSwipes" to 3, "specVerticalSwipes" to 3, "stableEdgeReads" to 1, "stablePriceReads" to 2, "maxSkuCount" to maxSkuCount), + ) + + private fun rule(config: PddCollectorConfig = config()) = CollectionRule( + schemaVersion = 2, + steps = emptyList(), + ruleType = "pddProductDetail", + pageEvidence = evidence(), + collector = config, + ) + } +} diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index ea160a4..a489d44 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -32,4 +32,4 @@ ## 当前阶段 -当前 MVP 的 T01~T07、T09~T19 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T20 正在实现 v2 规则契约和设备能力协商,随后由 T21~T23 完成采集器、管理端规则模板和真机验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。 +当前 MVP 的 T01~T07、T09~T20 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T21 正在实现 Android v2 商品详情采集器,随后由 T22~T23 完成管理端规则模板和真机验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index ea89bc8..187a505 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -80,6 +80,16 @@ v2 完整示例见 [PDD 商品详情规则](rules/pdd-product-detail-v2.proposed Agent 使用可扩展的类型化动作注册表,而不是任意脚本。采集规则不能创建订单;未来采购规则可以引用单独审核的创建订单能力,但任何规则都不能执行付款。 +Android 的 `pddProductDetailV1` 采集器执行以下固定流程: + +1. 以规则中的包名、精确 Activity 和节点选择器验证商品详情页,并持续识别登录、验证码、风控和失效商品页面。 +2. 在商品页有限次纵向查找安全规格入口;规格面板必须同时具备规格维度和确认摘要或可滚动区域等强证据。 +3. 颜色列表先向起点归边,再按可见行蛇形去重遍历;每次点击都在最新无障碍树中重新定位唯一控件,确认选中后连续读取相同价格。 +4. 尺码仅通过有限次纵向滑动读取,不点击尺码;颜色价格展开到该颜色下的可用尺码 SKU。 +5. 缺失颜色价格、尺码、第三规格维度或超过 SKU 上限时提交有界的 `completed_partial`;不猜测缺失值。采集期间离开商品页或出现验证码、登录、风控时明确失败。 + +无障碍节点只投影为 Agent 进程内的瞬时不可变模型,不序列化、不上传、不写入文件。完整状态机接入后 Agent 才上报 `collector.pdd.product-detail.v1`。 + ## 管理端:采集任务 ```http diff --git a/docs/11-pdd-detail-rule-migration-analysis.md b/docs/11-pdd-detail-rule-migration-analysis.md index c269ab8..4445123 100644 --- a/docs/11-pdd-detail-rule-migration-analysis.md +++ b/docs/11-pdd-detail-rule-migration-analysis.md @@ -46,7 +46,7 @@ └─ collector:pddProductDetailV1 + 别名、超时、次数和 SKU 上限 │ ▼ -Android 受限状态机 +Android 类型化状态机 页面分类 → 规格入口 → 面板强校验 → 逐颜色确认并采价 → 只读尺码 → 组装结果 │ ▼ @@ -118,4 +118,4 @@ Agent 不限制为“只能采集”,而是注册带版本的类型化能力 ## 当前状态 -本分析和规则草案属于 T19 的设计交付。GoAuto 当前服务端仍只接受 `schemaVersion: 1`,Android 也没有 v2 采集器;在 T19 实现完成并通过测试前,草案不能创建为生产可用规则。 +T19 已验收;T20 已实现并验收 v2 规则契约、阶段钩子和设备能力协商。T21 已接入纯 Kotlin 内存节点模型、页面证据、规格入口、颜色归边与蛇形遍历、点击后刷新、选中确认、稳定价格、只读尺码、第三维部分结果和 SKU 上限,当前等待用户验收。管理端内置模板和真机验证分别属于 T22、T23;在两项完成前,规则文件仍标记为 proposed。