feat: collect PDD product details on Android (#23)
This commit is contained in:
+74
-2
@@ -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
|
||||
|
||||
+446
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user