fix(#124): discover vertical color rows
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+117
-75
@@ -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
|
||||
|
||||
@@ -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` 有界纵向翻页;颜色结束后规格面板归顶,再进入尺码发现。选项重排或已选项从无障碍节点消失时,不得静默跳过仍未尝试的可用颜色。
|
||||
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
|
||||
- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user