diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b2f9b63..fd4636f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index faa5467..aef4171 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -828,6 +828,7 @@ class PddProductDetailCollector( val unsupported = linkedSetOf() 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() 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() - for (rowIndex in 0 until rowCount) { - val moveRight = rowIndex % 2 == 0 - val rowColors = linkedMapOf() - var previous = emptyList() - 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() - 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? = 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, 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 diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index 5dda7f1..5a7726d 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -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 = listOf("红色"), sizes: List = listOf("S"), private val colorPages: List> = listOf(colors), + private val colorVerticalPages: List>? = null, private val sizePages: List> = listOf(sizes), private val prices: Map = colors.associateWith { 1000L }, private val failedClicks: Set = 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 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 9e8a54d..aa29517 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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 # 业务规则与术语 @@ -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` 有界纵向翻页;颜色结束后规格面板归顶,再进入尺码发现。选项重排或已选项从无障碍节点消失时,不得静默跳过仍未尝试的可用颜色。 - 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。 - 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。