fix: harden PDD v2 real-device collection (#25)

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