fix: complete PDD spec panel traversal (#26)
This commit is contained in:
+4
-2
@@ -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
|
||||
|
||||
+88
-32
@@ -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)
|
||||
|
||||
+195
-46
@@ -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 {
|
||||
|
||||
+96
-19
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user