fix: complete PDD spec panel traversal (#26)

This commit is contained in:
QiuSW
2026-08-15 17:03:52 +08:00
parent 7b96810200
commit 72d614524b
11 changed files with 407 additions and 105 deletions
+4 -2
View File
@@ -20,13 +20,15 @@
- ColorOS 首次 ADB 安装需要在“安装增强防护”页执行“更多 → 开始深度扫描 → 继续安装”。
- 当前 MVP 只完成并维护一加/ColorOS 真机兼容,不包含华为 ROM。
## T23 v2 真机验证进展
## T23~T24 v2 真机验证
- 一加 PKG110 / Android 16 已验证 v2 能力上报、正式任务领取、浏览器到 PDD `NewPageActivity`、规格面板打开、颜色文字点击和逐颜色稳定价格。
- 图片型颜色卡片只点击文字节点,不点击整卡或“打开大图/查看大图”。
- 规格值会排除“#一次选多款#”等面板功能入口,避免把操作按钮伪装成尺码和 SKU。
- 商品 `719834019024` 已采到 4 个颜色、4 个 2690 分颜色价格和 4 个结构化 SKU,缺少尺码、店铺和评价时正确提交 `completed_partial`。
- 当前仍需一个有效的颜色 + 尺码商品完成全部尺码和多维 SKU 真机复核;不以已失效或只含单维规格的商品宣称通过。
- 商品 `236231603269` 已验证规格面板归顶、锚定横纵容器、逐行蛇形颜色遍历和尺码续页:14 个颜色、14 个颜色价格、8 个尺码和 112 个完整可用 SKU,任务最终为 `completed`。
- 滚动优先使用目标容器的无障碍 `ACTION_SCROLL_FORWARD/BACKWARD`;回退坐标手势时等待系统完成回调后才读取新树,避免把未结束手势误判成列表边缘。
- 商品标题回退只接受与销量节点空间关联的文本,不再把页面中任意最长文本当成标题。
```powershell
.\gradlew.bat test
@@ -9,8 +9,12 @@ import android.graphics.Path
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver {
private val activityTracker by lazy {
@@ -92,37 +96,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
val node = (directionalCandidates.ifEmpty { candidates }).maxByOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height().toLong() }
} ?: return false
val bounds = Rect().also(node::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2) return false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
val action = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) {
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
} else {
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
}
return node.performAction(action)
}
val left = bounds.left + bounds.width() * 25 / 100
val right = bounds.left + bounds.width() * 75 / 100
val top = bounds.top + bounds.height() * 25 / 100
val bottom = bounds.top + bounds.height() * 75 / 100
val centerX = bounds.centerX()
val centerY = bounds.centerY()
val (startX, startY, endX, endY) = when (direction) {
SwipeDirection.UP -> listOf(centerX, bottom, centerX, top)
SwipeDirection.DOWN -> listOf(centerX, top, centerX, bottom)
SwipeDirection.LEFT -> listOf(right, centerY, left, centerY)
SwipeDirection.RIGHT -> listOf(left, centerY, right, centerY)
}
val path = Path().apply {
moveTo(startX.toFloat(), startY.toFloat())
lineTo(endX.toFloat(), endY.toFloat())
}
return dispatchGesture(
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 450)).build(),
null,
null,
)
Log.i("GoAutoCollector", "swipe direction=$direction anchored=false bounds=${Rect().also(node::getBoundsInScreen)} class=${node.className}")
return swipeNode(node, direction)
}
override fun capture(): UiSnapshot {
@@ -172,7 +147,88 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
}
override fun swipeSpec(direction: SwipeDirection): Boolean = swipe(SemanticTarget.SPEC_PANEL, direction)
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction)
val root = rootInActiveWindow ?: return false
val matches = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
val bounds = Rect().also(node::getBoundsInScreen)
if (node.preferredOrDescendantLabel() == anchor.label &&
node.className?.toString() == anchor.className &&
kotlin.math.abs(bounds.centerX() - anchor.bounds.centerX) <= 32 &&
kotlin.math.abs(bounds.centerY() - anchor.bounds.centerY) <= 32
) matches += node
}
if (matches.size != 1) return swipe(SemanticTarget.SPEC_PANEL, direction)
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
val scrollableAncestors = mutableListOf<AccessibilityNodeInfo>()
var current: AccessibilityNodeInfo? = matches.single()
while (current != null) {
if (current.isScrollable) scrollableAncestors += current
current = current.parent
}
val anchoredTarget = if (horizontal) {
scrollableAncestors.firstOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width() > it.height() }
}
} else {
scrollableAncestors.maxByOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height().toLong() }
}
}
if (anchoredTarget != null) {
val bounds = Rect().also(anchoredTarget::getBoundsInScreen)
Log.i("GoAutoCollector", "swipe direction=$direction anchored=true bounds=$bounds class=${anchoredTarget.className}")
return swipeNode(anchoredTarget, direction)
}
return swipe(SemanticTarget.SPEC_PANEL, direction)
}
private fun swipeNode(node: AccessibilityNodeInfo, direction: SwipeDirection): Boolean {
val bounds = Rect().also(node::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2) return false
val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) {
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
} else {
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
}
if (node.performAction(scrollAction)) return true
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
val left = bounds.left + bounds.width() * 25 / 100
val right = bounds.left + bounds.width() * 75 / 100
val top = bounds.top + bounds.height() * 25 / 100
val bottom = bounds.top + bounds.height() * 75 / 100
val centerX = bounds.centerX()
val centerY = bounds.centerY()
val points = when (direction) {
SwipeDirection.UP -> listOf(centerX, bottom, centerX, top)
SwipeDirection.DOWN -> listOf(centerX, top, centerX, bottom)
SwipeDirection.LEFT -> listOf(right, centerY, left, centerY)
SwipeDirection.RIGHT -> listOf(left, centerY, right, centerY)
}
val path = Path().apply {
moveTo(points[0].toFloat(), points[1].toFloat())
lineTo(points[2].toFloat(), points[3].toFloat())
}
val completed = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queued = dispatchGesture(
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 450)).build(),
object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
completed.set(true)
latch.countDown()
}
override fun onCancelled(gestureDescription: GestureDescription?) {
latch.countDown()
}
},
null,
)
if (!queued) return false
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
visit(node)
@@ -40,7 +40,7 @@ enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED }
interface PddCollectorDriver {
fun capture(): UiSnapshot
fun clickFresh(target: SnapshotNode): FreshActionResult
fun swipeSpec(direction: SwipeDirection): Boolean
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
}
data class VisibleSpecValue(val text: String, val available: Boolean, val node: SnapshotNode)
@@ -56,6 +56,7 @@ data class ParsedPddScreen(
val quickConfirmationEntry: SnapshotNode?,
val pageEvidenceMatched: Boolean,
val problem: PageProblem?,
val sourceNodes: List<SnapshotNode>,
)
object PddScreenParser {
@@ -63,9 +64,10 @@ object PddScreenParser {
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
private val reviewPattern = Regex("(?:商品评价\\s*[((]?\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?|[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*条?评价|评价\\s*[0-9]+)")
private val excludedOptionWords = listOf(
"确定", "确认", "购买", "下单", "订单", "支付", "付款", "客服", "店铺", "收藏",
"确定", "确认", "购买", "下单", "订单", "支付", "付款",
"已选", "请选择", "数量", "打开大图", "查看大图", "一次选多款",
)
private val excludedExactOptionLabels = setOf("客服", "联系客服", "店铺", "收藏", "关闭")
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
@@ -113,7 +115,7 @@ object PddScreenParser {
.filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower }
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
.filterNot { isExactHeadingLabel(it.label, config) }
.filterNot { node -> excludedOptionWords.any { node.label.contains(it) } }
.filterNot { node -> isExcludedOptionLabel(node.label) }
.map {
val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
VisibleSpecValue(it.label, it.enabled && !stateText.containsUnavailableWord(), it)
@@ -172,9 +174,15 @@ object PddScreenParser {
snapshot.nodes.any { it.visible && it.matches(evidence.selector) }
),
problem = problem,
sourceNodes = visibleNodes,
)
}
internal fun isExcludedOptionLabel(label: String): Boolean {
val compact = label.replace(" ", "").trim()
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
}
private fun SnapshotNode.matches(selector: NodeSelector): Boolean =
(selector.resourceId == null || resourceId == selector.resourceId) &&
(selector.text == null || text == selector.text) &&
@@ -287,12 +295,23 @@ object PddScreenParser {
.minWithOrNull(compareBy<SnapshotNode> { safeOptionRank(it, source) }.thenBy { it.bounds.top }.thenBy { it.bounds.left })
}
private fun title(nodes: List<SnapshotNode>, labels: List<String>): String? =
private fun title(nodes: List<SnapshotNode>, @Suppress("UNUSED_PARAMETER") 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)
?: nodes.filter { node ->
node.className?.endsWith("TextView") == true && !node.clickable && node.label.length in 12..500
}.filterNot { node ->
listOf("通知", "支付", "已拼", "评价", "请选择", "确认", "收货地址", "快递", "拒收").any(node.label::contains)
}.mapNotNull { candidate ->
val distance = nodes.asSequence()
.filter { salesPattern.containsMatchIn(it.label) }
.map { sales -> kotlin.math.abs(candidate.bounds.centerY - sales.bounds.centerY) }
.filter { it <= 600 }
.minOrNull()
?: return@mapNotNull null
candidate to distance
}.minWithOrNull(compareBy<Pair<SnapshotNode, Int>> { it.second }.thenByDescending { it.first.label.length })
?.first
?.label
private fun shopName(nodes: List<SnapshotNode>, labels: List<String>): String? {
labels.firstOrNull { it.endsWith("旗舰店") || it.endsWith("专卖店") || it.endsWith("专营店") }?.let { return it }
@@ -321,11 +340,20 @@ data class PddCollectorResult(
)
private data class StablePriceResult(val priceCent: Long?, val problem: PageProblem? = null)
private data class SizeContinuation(
val containerPath: String,
val optionClasses: Set<String?>,
val minHeight: Int,
val maxHeight: Int,
val maxWidth: Int,
val colorLabels: Set<String>,
)
class PddProductDetailCollector(
private val driver: PddCollectorDriver,
private val now: () -> Long = System::currentTimeMillis,
private val pause: (Long) -> Unit = Thread::sleep,
private val trace: (String) -> Unit = {},
) {
fun collect(goodsId: String, rule: CollectionRule): PddCollectorResult {
val config = rule.collector ?: return failure("RULE_INVALID", "v2 规则缺少采集器配置")
@@ -336,6 +364,14 @@ class PddProductDetailCollector(
current.problem?.let { return failure(it.code, it.message) }
var summary = current.summary
var specEntry = current.specEntry
val entryReadyDeadline = minOf(deadline, now() + 2_000)
while (!current.specPanelOpen && specEntry == null && now() <= entryReadyDeadline) {
pause(100)
current = parse(goodsId, config, evidence)
current.problem?.let { return failure(it.code, it.message) }
summary = mergeSummary(summary, current.summary)
specEntry = current.specEntry
}
for (swipeCount in 0..config.limits.getValue("goodsPageVerticalSwipes")) {
if (current.specPanelOpen || (summary.shopName != null && summary.reviewCount != null && specEntry != null)) break
if (swipeCount == config.limits.getValue("goodsPageVerticalSwipes") || !driver.swipeSpec(SwipeDirection.UP)) break
@@ -439,40 +475,83 @@ class PddProductDetailCollector(
missing: MutableSet<String>,
unsupported: MutableSet<String>,
): PddCollectorResult? {
moveSpecPanelToTop(goodsId, config, evidence, deadline)?.let { return it }
moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it }
val initial = parse(goodsId, config, evidence)
initial.problem?.let { return failure(it.code, it.message) }
val rowCount = colorRows(initial).size
if (rowCount == 0) return null
val attempted = mutableSetOf<String>()
for (rowIndex in 0 until rowCount) {
val moveRight = rowIndex % 2 == 0
val rowColors = linkedMapOf<String, Boolean>()
var previous = emptyList<String>()
var stable = 0
for (pass in 0..config.limits.getValue("specHorizontalSwipes")) {
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
var currentRow = emptyList<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 }
val rows = colorRows(screen)
if (rowIndex >= rows.size) {
missing += "colorRow:${rowIndex + 1}"
break
}
currentRow = rows[rowIndex].sortedBy { it.node.bounds.left }
.let { if (moveRight) it else it.reversed() }
currentRow.forEach { rowColors[it.text] = rowColors[it.text] == true || it.available }
val value = currentRow.firstOrNull { it.available && it.text !in attempted } ?: break
attempted += value.text
when (driver.clickFresh(value.node)) {
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
missing += "selection:${value.text}"
continue
}
FreshActionResult.SUCCESS -> Unit
}
val sampled = stablePrice(goodsId, value.text, config, evidence)
sampled.problem?.let { return failure(it.code, it.message) }
sampled.priceCent?.let { prices[value.text] = it } ?: run { missing += "price:${value.text}" }
}
val signature = optionSignature(currentRow)
stable = if (signature == previous) stable + 1 else 0
previous = signature
if (stable >= config.limits.getValue("stableEdgeReads") || pass == config.limits.getValue("specHorizontalSwipes")) break
val anchor = currentRow.firstOrNull()?.node ?: break
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
if (!driver.swipeSpec(direction, anchor)) break
pause(350)
}
val naturalOrder = rowColors.entries.toList().let { if (moveRight) it else it.reversed() }
naturalOrder.forEach { (text, available) -> colors[text] = colors[text] == true || available }
}
return null
}
private fun moveSpecPanelToTop(
goodsId: String,
config: PddCollectorConfig,
evidence: PageEvidence,
deadline: Long,
): PddCollectorResult? {
var previous = emptyList<String>()
var stable = 0
val attempted = mutableSetOf<String>()
repeat(config.limits.getValue("specHorizontalSwipes") + 1) { pass ->
repeat(config.limits.getValue("specVerticalSwipes")) {
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 }
val screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
val signature = viewportSignature(screen)
stable = if (signature == previous) stable + 1 else 0
previous = signature
if (stable >= config.limits.getValue("stableEdgeReads") || pass == config.limits.getValue("specHorizontalSwipes")) return null
if (!driver.swipeSpec(SwipeDirection.LEFT)) return null
pause(200)
if (stable >= config.limits.getValue("stableEdgeReads")) return null
val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: return null
if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null
pause(350)
}
return null
}
@@ -490,12 +569,14 @@ class PddProductDetailCollector(
val screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
val signature = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text }
val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
val signature = optionSignature(values)
stable = if (signature == previous) stable + 1 else 0
previous = signature
if (stable >= config.limits.getValue("stableEdgeReads")) return null
if (!driver.swipeSpec(SwipeDirection.RIGHT)) return null
pause(200)
val anchor = values.firstOrNull()?.node ?: return null
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) return null
pause(350)
}
return null
}
@@ -510,24 +591,71 @@ class PddProductDetailCollector(
): PddCollectorResult? {
var previous = emptyList<String>()
var stable = 0
var continuation: SizeContinuation? = null
repeat(config.limits.getValue("specVerticalSwipes") + 1) { pass ->
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
val screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val visible = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes)
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation)
visible.forEach { sizes[it.text] = sizes[it.text] == true || it.available }
val signature = screen.dimensions.flatMap { dimension -> dimension.values.map { "${dimension.key}:${it.text}" } }
val signature = if (visible.isNotEmpty()) optionSignature(visible) else viewportSignature(screen)
trace("sizes pass=$pass dimensions=${screen.dimensions.joinToString { "${it.key}:${it.values.size}" }} signature=${signature.size} collected=${sizes.size}")
stable = if (signature == previous) stable + 1 else 0
previous = signature
if (stable >= config.limits.getValue("stableEdgeReads") || pass == config.limits.getValue("specVerticalSwipes")) return null
if (!driver.swipeSpec(SwipeDirection.UP)) return null
val anchor = visible.firstOrNull()?.node
?: screen.dimensions.filter { it.key == "color" }.flatMap { it.values }.firstOrNull()?.node
?: return null
trace("sizes swipe=UP anchorBounds=${anchor.bounds}")
if (!driver.swipeSpec(SwipeDirection.UP, anchor)) return null
pause(350)
}
return null
}
private fun buildSizeContinuation(screen: ParsedPddScreen, values: List<VisibleSpecValue>): SizeContinuation? {
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
val containers = values.mapNotNull { value ->
var current = value.node.parentPath?.let(byPath::get)
while (current != null && !current.scrollable) current = current.parentPath?.let(byPath::get)
current
}
val container = containers.groupBy(SnapshotNode::path).maxByOrNull { it.value.size }?.value?.firstOrNull() ?: return null
return SizeContinuation(
containerPath = container.path,
optionClasses = values.map { it.node.className }.toSet(),
minHeight = values.minOf { it.node.bounds.height },
maxHeight = values.maxOf { it.node.bounds.height },
maxWidth = values.maxOf { it.node.bounds.width },
colorLabels = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text }.toSet(),
)
}
private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?): List<VisibleSpecValue> {
if (context == null) return emptyList()
val headingLabels = setOf("颜色分类", "颜色", "花色", "款式", "尺码", "尺寸", "规格", "型号", "套餐", "容量", "类型", "版本", "口味")
val priceOnly = Regex("^(?:券后|到手价|拼单价|单买价|价格)?[¥¥]\\s*\\d+(?:\\.\\d{1,2})?$")
return screen.sourceNodes.asSequence()
.filter { it.visible && it.enabled && it.clickable }
.filter { it.path.startsWith("${context.containerPath}/") }
.filter { it.className in context.optionClasses }
.filter { it.bounds.height in (context.minHeight / 2).coerceAtLeast(1)..context.maxHeight * 2 }
.filter { it.bounds.width <= maxOf(context.maxWidth * 2, 600) }
.filter { it.label.isNotBlank() && it.label.length <= 80 }
.filterNot { it.label in context.colorLabels || it.label.replace(" ", "") in headingLabels }
.filterNot { PddScreenParser.isExcludedOptionLabel(it.label) || priceOnly.matches(it.label.replace(" ", "")) }
.map { node ->
VisibleSpecValue(node.label, node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains), node)
}
.groupBy(VisibleSpecValue::text)
.map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } }
.sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left }))
}
private fun stablePrice(goodsId: String, color: String, config: PddCollectorConfig, evidence: PageEvidence): StablePriceResult {
val selectionDeadline = now() + config.timeoutsMs.getValue("selection")
var priceDeadline: Long? = null
@@ -571,11 +699,32 @@ class PddProductDetailCollector(
private fun parse(goodsId: String, config: PddCollectorConfig, evidence: PageEvidence) =
PddScreenParser.parse(driver.capture(), config, goodsId, evidence)
private fun snakeOrder(values: List<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 colorRows(screen: ParsedPddScreen): List<List<VisibleSpecValue>> {
val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
val rows = mutableListOf<MutableList<VisibleSpecValue>>()
val centers = mutableListOf<Int>()
values.forEach { value ->
val tolerance = (value.node.bounds.height / 2).coerceIn(24, 80)
val index = centers.indexOfFirst { kotlin.math.abs(value.node.bounds.centerY - it) <= tolerance }
if (index < 0) {
rows += mutableListOf(value)
centers += value.node.bounds.centerY
} else {
rows[index] += value
centers[index] = rows[index].map { it.node.bounds.centerY }.average().toInt()
}
}
return rows.onEach { it.sortBy { value -> value.node.bounds.left } }
}
private fun optionSignature(values: List<VisibleSpecValue>): List<String> = values.map { value ->
val bounds = value.node.bounds
"${value.text}:${bounds.left},${bounds.top},${bounds.right},${bounds.bottom}"
}
private fun viewportSignature(screen: ParsedPddScreen): List<String> = screen.dimensions.flatMap { dimension ->
optionSignature(dimension.values).map { "${dimension.key}:$it" }
}
private fun mergeSummary(first: ProductSummary, second: ProductSummary) = ProductSummary(
@@ -15,6 +15,7 @@ import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.MainActivity
import cn.ilapage.goauto.agent.R
@@ -206,7 +207,10 @@ class AgentForegroundService : Service() {
val execution = RuleExecutor(accessibility).execute(rule)
if (!execution.successful) throw TaskFailure(execution.code, execution.message)
val result = if (rule.schemaVersion == 2) {
val collection = PddProductDetailCollector(accessibility).collect(task.goodsIdSnapshot, rule)
val collection = PddProductDetailCollector(
accessibility,
trace = { message -> Log.i("GoAutoCollector", message) },
).collect(task.goodsIdSnapshot, rule)
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
requireNotNull(collection.payload)
} else {
@@ -31,6 +31,23 @@ class PddProductDetailCollectorTest {
assertEquals(listOf("红色", "蓝色", "黑色", "白色"), parsed.dimensions.first { it.key == "color" }.values.map { it.text })
}
@Test
fun unrelatedLongTextIsNotUsedAsProductTitle() {
val driver = FakeCollectorDriver()
val snapshot = driver.capture().copy(nodes = driver.capture().nodes + node(
"unrelated",
"这是一段远离商品销量区域的非商品页面长文本,不应该被识别成商品标题",
20,
1400,
1000,
1500,
))
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals("这是一个足够长的拼多多测试商品标题", parsed.summary.title)
}
@Test
fun normalScrollableGoodsPageIsNotTreatedAsSpecPanel() {
val snapshot = UiSnapshot(
@@ -170,6 +187,28 @@ class PddProductDetailCollectorTest {
assertEquals(listOf("M码80-120斤"), parsed.dimensions.first { it.key == "size" }.values.map { it.text })
}
@Test
fun legitimateSizeContainingCustomerServiceTextIsPreserved() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("summary", "请选择:尺码", 20, 300, 800, 360),
node("title", "确认款式", 20, 370, 300, 410),
node("color-heading", "颜色分类", 20, 420, 300, 470),
node("red", "红色", 30, 500, 260, 580, clickable = true),
node("size-heading", "尺码", 20, 650, 300, 700),
node("custom", "定制图案文字 联系客服", 30, 730, 520, 810, clickable = true),
node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals(listOf("定制图案文字 联系客服"), parsed.dimensions.first { it.key == "size" }.values.map { it.text })
}
@Test
fun clickableNextHeadingContainerCannotBecomePreviousDimensionValue() {
val snapshot = UiSnapshot(
@@ -212,6 +251,37 @@ class PddProductDetailCollectorTest {
assertTrue(driver.captureCount > driver.clickCount)
}
@Test
fun colorsAreClickedByVisualRowsInSnakeOrder() {
val colors = listOf("A色", "B色", "C色", "D色", "E色", "F色")
val driver = FakeCollectorDriver(colors = colors, prices = colors.associateWith { 1000L }, rowSize = 3)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
assertEquals(listOf("A色", "B色", "C色", "F色", "E色", "D色"), driver.clickedLabels)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN && it.second != null })
assertTrue(driver.swipes.any { it.first == SwipeDirection.RIGHT && it.second != null })
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
}
@Test
fun sizesContinueAfterHeadingScrollsOutOfView() {
val driver = FakeCollectorDriver(
colors = listOf("红色"),
sizePages = listOf(listOf("S", "M"), listOf("L", "XL")),
hideDimensionHeadingsAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
assertEquals(listOf("S", "M", "L", "XL"), requireNotNull(result.payload).dimensions.first { it.key == "size" }.values)
}
@Test
fun selectionFailureAndUnstablePriceBecomePartialWithoutGuessing() {
val driver = FakeCollectorDriver(
@@ -290,10 +360,12 @@ class PddProductDetailCollectorTest {
private val specialText: String? = null,
private val includeDangerousActions: Boolean = false,
private val rowSize: Int = 4,
private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false,
) : PddCollectorDriver {
var captureCount = 0
var clickCount = 0
val clickedLabels = mutableListOf<String>()
val swipes = mutableListOf<Pair<SwipeDirection, SnapshotNode?>>()
private var selected: String? = null
private var horizontalPage = 0
private var verticalPage = 0
@@ -309,30 +381,34 @@ class PddProductDetailCollectorTest {
node("reviews", "商品评价(1.2万)", 320, 250, 650, 300),
node("selected", "已选 ${selected.orEmpty()}", 20, 320, 700, 370),
node("panel-title", "确认款式", 20, 370, 300, 410),
node("color-heading", "颜色分类", 20, 400, 300, 450),
node("scroll", "", 0, 380, 1080, 1900, scrollable = true),
)
colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)].forEachIndexed { index, color ->
val row = index / rowSize
val column = index % rowSize
nodes += node(
"color-$color-$captureCount",
color,
30 + column * 230,
470 + row * 90,
220 + column * 230,
540 + row * 90,
clickable = true,
selected = selected == color,
)
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
if (!continuationPage) {
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)].forEachIndexed { index, color ->
val row = index / rowSize
val column = index % rowSize
nodes += node(
"scroll/color-$color-$captureCount",
color,
30 + column * 230,
470 + row * 90,
220 + column * 230,
540 + row * 90,
clickable = true,
selected = selected == color,
parentPath = "scroll",
)
}
}
nodes += node("size-heading", "尺码", 20, 700, 300, 750)
if (!continuationPage) nodes += node("scroll/size-heading", "尺码", 20, 700, 300, 750, parentPath = "scroll")
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
nodes += node("size-$size-$captureCount", size, 30 + index * 230, 770, 220 + index * 230, 840, clickable = true)
nodes += node("scroll/size-$size-$captureCount", size, 30 + index * 230, 770, 220 + index * 230, 840, clickable = true, parentPath = "scroll")
}
if (extraDimension) {
nodes += node("capacity-heading", "容量", 20, 900, 300, 950)
nodes += node("capacity", "大容量", 30, 970, 220, 1040, clickable = true)
nodes += node("scroll/capacity-heading", "容量", 20, 900, 300, 950, parentPath = "scroll")
nodes += node("scroll/capacity", "大容量", 30, 970, 220, 1040, clickable = true, parentPath = "scroll")
}
if (includeDangerousActions) {
nodes += node("order", "提交订单", 30, 1080, 500, 1160, clickable = true)
@@ -363,7 +439,8 @@ class PddProductDetailCollectorTest {
return FreshActionResult.SUCCESS
}
override fun swipeSpec(direction: SwipeDirection): Boolean {
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
swipes += direction to anchor
when (direction) {
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
SwipeDirection.UP -> verticalPage = (verticalPage + 1).coerceAtMost(sizePages.lastIndex)