fix(#124): discover vertical color rows

This commit is contained in:
QiuSW
2026-08-28 12:05:34 +08:00
parent d718c957bc
commit c2a1363561
4 changed files with 173 additions and 84 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 26
versionName = "0.9.13"
versionCode = 27
versionName = "0.9.14"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -828,6 +828,7 @@ class PddProductDetailCollector(
val unsupported = linkedSetOf<String>()
val specPanelContainer = openedScreen.specPanelContainer
collectColors(goodsId, config, evidence, deadline, specPanelContainer, colors, prices, missing, unsupported)?.let { return it }
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
val sizes = linkedMapOf<String, Boolean>()
collectSizes(goodsId, config, evidence, deadline, specPanelContainer, sizes, unsupported)?.let { return it }
@@ -928,8 +929,7 @@ class PddProductDetailCollector(
diagnosticSelectedSummaryPresent = initial.selectedSummary != null
val initialRows = colorRows(initial)
observeColorDiscovery(initial, initialRows)
val rowCount = initialRows.size
if (rowCount == 0) {
if (initialRows.isEmpty()) {
finishColorDiscovery(
if (diagnosticNonClickableCandidates > 0) AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE
else AgentDiagnosticReason.COLOR_EDGE_REACHED,
@@ -937,84 +937,124 @@ class PddProductDetailCollector(
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")) {
fun collectVisibleColors(): PddCollectorResult? {
while (true) {
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)
observeColorDiscovery(screen, rows)
if (rowIndex >= rows.size) {
// Some PDD builds remove the selected option from the
// clickable accessibility nodes and immediately reflow
// the remaining options. A row disappearing after a
// successful selection is therefore not evidence that
// its values were missed. Real gaps are still reported
// below as missing colors or prices.
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
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
val beforeClick = screen
val clickResult = driver.clickFresh(value.node)
trace("color-click color=${traceLabel(value.text)} result=$clickResult")
when (clickResult) {
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, beforeClick, config, evidence)
sampled.problem?.let { return failure(it.code, it.message) }
sampled.priceCent?.let { prices[value.text] = it } ?: run {
trace("color-price color=${traceLabel(value.text)} outcome=missing reason=${sampled.reason ?: "unknown"} evidence=${sampled.selectionEvidence ?: "none"}")
missing += "price:${value.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 商品详情页")
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val rows = colorRows(screen)
observeColorDiscovery(screen, rows)
rows.flatten().forEach { value -> colors[value.text] = colors[value.text] == true || value.available }
val ordered = rows.flatMapIndexed { index, row ->
row.sortedBy { it.node.bounds.left }.let { if (index % 2 == 0) it else it.reversed() }
}
val signature = optionSignature(currentRow)
stable = if (signature == previous) stable + 1 else 0
previous = signature
if (stable >= config.limits.getValue("stableEdgeReads")) {
if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND) {
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
val value = ordered.firstOrNull { it.available && it.text !in attempted } ?: return null
attempted += value.text
val beforeClick = screen
val clickResult = driver.clickFresh(value.node)
trace("color-click color=${traceLabel(value.text)} result=$clickResult")
when (clickResult) {
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
missing += "selection:${value.text}"
continue
}
break
FreshActionResult.SUCCESS -> Unit
}
if (pass == config.limits.getValue("specHorizontalSwipes")) {
if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND) {
diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT
}
break
val sampled = stablePrice(goodsId, value.text, beforeClick, config, evidence)
sampled.problem?.let { return failure(it.code, it.message) }
sampled.priceCent?.let { prices[value.text] = it } ?: run {
trace("color-price color=${traceLabel(value.text)} outcome=missing reason=${sampled.reason ?: "unknown"} evidence=${sampled.selectionEvidence ?: "none"}")
missing += "price:${value.text}"
}
val anchor = currentRow.firstOrNull()?.node
if (anchor == null) {
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
break
}
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
if (!driver.swipeSpec(direction, anchor)) {
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
break
}
diagnosticHorizontalSwipes++
pause(350)
}
val naturalOrder = rowColors.entries.toList().let { if (moveRight) it else it.reversed() }
naturalOrder.forEach { (text, available) -> colors[text] = colors[text] == true || available }
}
var previousVerticalSignature: List<String>? = null
var verticalStable = 0
val discoverVertically = initialRows.size > 1
for (verticalPass in 0..config.limits.getValue("specVerticalSwipes")) {
collectVisibleColors()?.let { return it }
var screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
var rows = colorRows(screen)
observeColorDiscovery(screen, rows)
// A single-column color list is a vertical layout. Horizontal
// gestures cannot reveal more values there, so proceed directly
// to bounded vertical discovery. Multi-value rows retain the
// established left/right paging behavior.
if (rows.any { it.size > 1 }) {
val rowSeeds = rows.map { row -> row.map(VisibleSpecValue::text).toSet() }
rowSeeds.forEachIndexed { rowIndex, seed ->
val moveRight = rowIndex % 2 == 0
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
collectVisibleColors()?.let { return it }
screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
rows = colorRows(screen)
observeColorDiscovery(screen, rows)
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
?.takeIf { row -> row.any { it.text in seed } }
if (matchedRow == null) {
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
break
}
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
.let { if (moveRight) it else it.reversed() }
val signature = optionSignature(currentRow)
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
horizontalSignatureReads[signature] = signatureReads
if (signatureReads > config.limits.getValue("stableEdgeReads")) break
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) break
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
if (!driver.swipeSpec(direction, currentRow.first().node)) break
diagnosticHorizontalSwipes++
pause(350)
}
}
collectVisibleColors()?.let { return it }
screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
rows = colorRows(screen)
observeColorDiscovery(screen, rows)
}
if (!discoverVertically) {
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
break
}
val verticalSignature = optionSignature(rows.flatten())
verticalStable = if (previousVerticalSignature != null && verticalSignature == previousVerticalSignature) {
verticalStable + 1
} else {
0
}
previousVerticalSignature = verticalSignature
if (verticalStable >= config.limits.getValue("stableEdgeReads")) {
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
break
}
if (verticalPass == config.limits.getValue("specVerticalSwipes")) {
diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT
break
}
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
if (anchor == null) {
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
break
}
if (!driver.swipeSpec(SwipeDirection.UP, anchor)) {
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
break
}
pause(350)
}
finishColorDiscovery()
return null
@@ -1117,7 +1157,9 @@ 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 values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
val rows = colorRows(screen)
if (rows.size > 1 && rows.all { it.size == 1 }) return null
val values = rows.flatten()
val signature = optionSignature(values)
stable = if (signature == previous) stable + 1 else 0
previous = signature
@@ -559,6 +559,45 @@ class PddProductDetailCollectorTest {
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
}
@Test
fun `single-column colors continue across bounded vertical pages`() {
val colors = listOf("A色", "B色", "C色", "D色", "E色", "F色", "G色", "H色")
val driver = FakeCollectorDriver(
colors = colors,
colorVerticalPages = listOf(colors.take(6), colors.drop(4)),
prices = colors.associateWith { 1000L },
rowSize = 1,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
assertEquals(colors, driver.clickedLabels)
assertTrue(driver.swipes.none { it.first == SwipeDirection.LEFT || it.first == SwipeDirection.RIGHT })
assertTrue(driver.swipes.count { it.first == SwipeDirection.UP } in 1..12)
}
@Test
fun `grid colors discover new rows after vertical viewport changes`() {
val colors = listOf("A色", "B色", "C色", "D色", "E色", "F色")
val driver = FakeCollectorDriver(
colors = colors,
colorVerticalPages = listOf(colors.take(4), colors.drop(2)),
prices = colors.associateWith { 1000L },
rowSize = 2,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
assertEquals(colors.toSet(), driver.clickedLabels.toSet())
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP })
}
@Test
fun `color discovery records only bounded aggregate evidence`() {
val colors = listOf("A色", "B色", "C色", "D色")
@@ -1178,6 +1217,7 @@ class PddProductDetailCollectorTest {
colors: List<String> = listOf("红色"),
sizes: List<String> = listOf("S"),
private val colorPages: List<List<String>> = listOf(colors),
private val colorVerticalPages: List<List<String>>? = null,
private val sizePages: List<List<String>> = listOf(sizes),
private val prices: Map<String, Long> = colors.associateWith { 1000L },
private val failedClicks: Set<String> = emptySet(),
@@ -1278,9 +1318,13 @@ class PddProductDetailCollectorTest {
node("scroll", "", 0, 380, 1080, 1900, scrollable = scrollablePanel),
)
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
if (!continuationPage) {
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
visibleColors
.filterNot { hideSelectedColorOption && it == selected }
.forEachIndexed { index, color ->
val row = index / rowSize
@@ -1309,15 +1353,15 @@ class PddProductDetailCollectorTest {
)
}
}
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, 700, 300, 750, parentPath = "scroll")
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
nodes += node(
"scroll/size-$size-$captureCount",
size,
30 + index * 230,
770,
sizeHeadingTop + 70,
220 + index * 230,
840,
sizeHeadingTop + 140,
clickable = true,
selected = size == initialSelectedSize,
parentPath = "scroll",
@@ -1382,7 +1426,9 @@ class PddProductDetailCollectorTest {
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
when (direction) {
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
SwipeDirection.UP -> verticalPage = (verticalPage + 1).coerceAtMost(sizePages.lastIndex)
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
SwipeDirection.DOWN -> verticalPage = (verticalPage - 1).coerceAtLeast(0)
else -> Unit
}
return true
+3 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 4236dd1d219cc6235537adfd8d0411308c449971
synchronized_at: 2026-08-28T02:22:48Z
wiki_revision: b330f2be68cb2eddb929db2cb6021cf9f9b527ad
synchronized_at: 2026-08-28T04:02:59Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -172,6 +172,7 @@ synchronized_at: 2026-08-28T02:22:48Z
- 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)、[#62](https://git.ilapage.cn/OPC/goauto/issues/62)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI Provider 只有一个 OpenAI-compatible 配置,由管理员维护。根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只向管理员设置接口返回以便查看和替换;它仍不得写入代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Provider Base URL 不限制内网或公网,支持 HTTP/HTTPS;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。
- 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。
- PDD 采集规则的 `collector.textAliases` 是规格面板、维度标题、选择摘要、评价入口、售罄主商品证据和购买入口等只读识别文案的服务端下发事实源;缺省字段只为旧快照兼容使用 Agent 内置默认值。颜色/尺码分类不得再用代码字面量扩大 `dimensionAliases`。订单和支付文字可以参与只读页面判定,但 Agent 内不可配置的创建订单/确认订单/支付/付款点击拒绝清单始终优先,规则不能放宽;修改地址和收货地址在只读及点击字段中都禁止。
- PDD 颜色采集对首屏已识别为多行或网格的颜色区执行动态行发现:每次动作后重新解析全部可见行,单列不做无效横向滑动,并复用规则中的 `specVerticalSwipes` 与 `stableEdgeReads` 有界纵向翻页;颜色结束后规格面板归顶,再进入尺码发现。选项重排或已选项从无障碍节点消失时,不得静默跳过仍未尝试的可用颜色。
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。