feat: collect PDD product details on Android (#23)

This commit is contained in:
QiuSW
2026-08-15 15:18:03 +08:00
parent 9d4a0b9845
commit 99ca400a74
8 changed files with 793 additions and 9 deletions
@@ -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<SnapshotNode>()
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<AccessibilityNodeInfo>()
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
@@ -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<SnapshotNode>,
)
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<VisibleSpecValue>)
data class ParsedPddScreen(
val summary: ProductSummary,
val dimensions: List<VisibleDimension>,
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<VisibleDimension> {
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<SnapshotNode>): List<SnapshotNode> {
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>): 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<String, Boolean>()
val prices = linkedMapOf<String, Long>()
val missing = linkedSetOf<String>()
val unsupported = linkedSetOf<String>()
collectColors(goodsId, config, evidence, deadline, colors, prices, missing, unsupported)?.let { return it }
val sizes = linkedMapOf<String, Boolean>()
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<String?>(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<String, Boolean>,
prices: LinkedHashMap<String, Long>,
missing: MutableSet<String>,
unsupported: MutableSet<String>,
): PddCollectorResult? {
moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it }
var previous = emptyList<String>()
var stable = 0
val attempted = mutableSetOf<String>()
repeat(config.limits.getValue("specHorizontalSwipes") + 1) { pass ->
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
var visible: List<VisibleSpecValue>
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<String>()
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<String, Boolean>,
unsupported: MutableSet<String>,
): PddCollectorResult? {
var previous = emptyList<String>()
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<VisibleSpecValue>): List<VisibleSpecValue> {
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<String> = emptyList()
override fun find(selector: NodeSelector): List<UiNodeRef> = 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
}
@@ -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<String> = listOf(SCHEMA_V2, SWIPE_V1)
val supported: List<String> = listOf(SCHEMA_V2, SWIPE_V1, PDD_PRODUCT_DETAIL_V1)
}
object RuleParser {
@@ -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) {
@@ -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<String> = listOf("红色"),
sizes: List<String> = listOf("S"),
private val colorPages: List<List<String>> = listOf(colors),
private val sizePages: List<List<String>> = listOf(sizes),
private val prices: Map<String, Long> = colors.associateWith { 1000L },
private val failedClicks: Set<String> = emptySet(),
private val unstableColors: Set<String> = 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<String>()
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,
)
}
}
+1 -1
View File
@@ -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 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
+10
View File
@@ -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
@@ -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。