diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6c38e4c..6d09516 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 = 24 - versionName = "0.9.11" + versionCode = 25 + versionName = "0.9.12" 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 7b25178..63a510b 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 @@ -881,12 +881,53 @@ class PddProductDetailCollector( missing: MutableSet, unsupported: MutableSet, ): PddCollectorResult? { + val diagnosticStartedAt = now() + val diagnosticRowValues = linkedMapOf>() + val diagnosticParsedValues = linkedSetOf() + var diagnosticNonClickableCandidates = 0 + var diagnosticSelectedValues = 0 + var diagnosticHorizontalSwipes = 0 + var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND + + fun observeColorDiscovery(screen: ParsedPddScreen, rows: List>) { + rows.forEachIndexed { index, row -> + diagnosticRowValues.getOrPut(index) { linkedSetOf() }.addAll(row.map(VisibleSpecValue::text)) + } + val values = rows.flatten() + diagnosticParsedValues += values.map(VisibleSpecValue::text) + diagnosticSelectedValues = maxOf( + diagnosticSelectedValues, + values.count { it.node.selected || it.node.checked }, + ) + diagnosticNonClickableCandidates = maxOf( + diagnosticNonClickableCandidates, + visibleNonClickableColorCandidateCount(screen, config, values.map(VisibleSpecValue::text).toSet()), + ) + } + + fun finishColorDiscovery(reason: AgentDiagnosticReason = diagnosticTermination) { + recordColorDiscovery( + reason = reason, + rowValueCounts = diagnosticRowValues.mapValues { it.value.size }, + parsedValueCount = diagnosticParsedValues.size, + nonClickableCandidateCount = diagnosticNonClickableCandidates, + selectedValueCount = diagnosticSelectedValues, + horizontalSwipes = diagnosticHorizontalSwipes, + elapsedMs = now() - diagnosticStartedAt, + ) + } + moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.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 initialRows = colorRows(initial) + observeColorDiscovery(initial, initialRows) + val rowCount = initialRows.size + if (rowCount == 0) { + finishColorDiscovery(AgentDiagnosticReason.COLOR_EDGE_REACHED) + return null + } val attempted = mutableSetOf() for (rowIndex in 0 until rowCount) { val moveRight = rowIndex % 2 == 0 @@ -902,6 +943,7 @@ class PddProductDetailCollector( 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 @@ -909,6 +951,7 @@ class PddProductDetailCollector( // 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 } @@ -937,18 +980,101 @@ class PddProductDetailCollector( 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 + if (stable >= config.limits.getValue("stableEdgeReads")) { + if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND) { + diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED + } + break + } + if (pass == config.limits.getValue("specHorizontalSwipes")) { + if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND) { + diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT + } + break + } + 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)) break + 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 } } + finishColorDiscovery() return null } + private fun recordColorDiscovery( + reason: AgentDiagnosticReason, + rowValueCounts: Map, + parsedValueCount: Int, + nonClickableCandidateCount: Int, + selectedValueCount: Int, + horizontalSwipes: Int, + elapsedMs: Long, + ) { + if (taskId <= 0) return + fun record(metricReason: AgentDiagnosticReason, attempt: Int = 0, count: Int) { + diagnostic( + AgentDiagnosticEvent( + taskId = taskId, + stage = AgentDiagnosticStage.COLOR_DISCOVERY, + reason = metricReason, + attempt = attempt, + elapsedMs = elapsedMs, + candidateCount = count, + ), + ) + } + rowValueCounts.forEach { (rowIndex, count) -> + record(AgentDiagnosticReason.COLOR_ROW_VALUE_COUNT, attempt = rowIndex + 1, count = count) + } + record(AgentDiagnosticReason.COLOR_FOUND, count = parsedValueCount) + record(AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE, count = nonClickableCandidateCount) + record(AgentDiagnosticReason.COLOR_SELECTED_VALUE_COUNT, count = selectedValueCount) + record(AgentDiagnosticReason.COLOR_HORIZONTAL_SWIPE_COUNT, count = horizontalSwipes) + record(AgentDiagnosticReason.COLOR_VERTICAL_SWIPE_COUNT, count = 0) + if (reason != AgentDiagnosticReason.COLOR_FOUND) { + record(reason, attempt = horizontalSwipes, count = parsedValueCount) + } + } + + private fun visibleNonClickableColorCandidateCount( + screen: ParsedPddScreen, + config: PddCollectorConfig, + parsedLabels: Set, + ): Int { + val visible = screen.sourceNodes.filter(SnapshotNode::visible) + val compactColorAliases = config.colorAliases.map { it.replace(" ", "") } + val heading = visible.asSequence() + .filterNot(SnapshotNode::clickable) + .filter { node -> compactColorAliases.any { alias -> node.label.replace(" ", "").contains(alias) } } + .minByOrNull { it.bounds.top } ?: return 0 + val allHeadingAliases = (config.colorAliases + config.sizeAliases + config.textAliases.dimension.exactNames + + config.textAliases.dimension.adaptiveAliases).map { it.replace(" ", "") } + val lowerBound = visible.asSequence() + .filterNot(SnapshotNode::clickable) + .filter { it.bounds.top >= heading.bounds.bottom } + .filter { node -> allHeadingAliases.any { alias -> node.label.replace(" ", "").contains(alias) } } + .minOfOrNull { it.bounds.top } ?: Int.MAX_VALUE + val paths = visible.map(SnapshotNode::path).toSet() + return visible.asSequence() + .filter { !it.clickable && it.enabled && it.label.isNotBlank() } + .filter { it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lowerBound } + .filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 } + .filterNot { it.label in parsedLabels } + .filterNot { candidate -> paths.any { it.startsWith("${candidate.path}/") } } + .count() + } + private fun moveSpecPanelToTop( goodsId: String, config: PddCollectorConfig, diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index 91a15ae..e087bef 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.BuildConfig enum class AgentDiagnosticStage { DETAIL_ENTRY, SPEC_PANEL_ENTRY, + COLOR_DISCOVERY, SIZE_DISCOVERY, PAGE_STABILITY, SHARE_CLICK, @@ -35,6 +36,17 @@ enum class AgentDiagnosticReason { SIZE_SCAN_LIMIT, SIZE_CONTAINER_UNAVAILABLE, SIZE_SWIPE_FAILED, + COLOR_FOUND, + COLOR_EDGE_REACHED, + COLOR_SCAN_LIMIT, + COLOR_ROW_REFLOWED, + COLOR_SWIPE_FAILED, + COLOR_CONTAINER_UNAVAILABLE, + COLOR_VALUES_NOT_CLICKABLE, + COLOR_ROW_VALUE_COUNT, + COLOR_SELECTED_VALUE_COUNT, + COLOR_HORIZONTAL_SWIPE_COUNT, + COLOR_VERTICAL_SWIPE_COUNT, PACKAGE_MISMATCH, ACTIVITY_MISMATCH, SELECTOR_MISMATCH, 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 b4bece2..062df62 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 @@ -18,6 +18,7 @@ import cn.ilapage.goauto.agent.automation.UiSnapshot import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage +import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -535,6 +536,62 @@ class PddProductDetailCollectorTest { assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null }) } + @Test + fun `color discovery records only bounded aggregate evidence`() { + val colors = listOf("A色", "B色", "C色", "D色") + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = colors, + prices = colors.associateWith { 1000L }, + rowSize = 2, + nonClickableColorCandidates = listOf("隐藏候选"), + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 124, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val colorEvents = events.filter { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(listOf(2, 2), colorEvents.filter { it.reason == AgentDiagnosticReason.COLOR_ROW_VALUE_COUNT }.map { it.candidateCount }) + assertEquals(4, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_FOUND }.candidateCount) + assertEquals(1, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE }.candidateCount) + assertEquals(1, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_SELECTED_VALUE_COUNT }.candidateCount) + assertTrue(requireNotNull(colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_HORIZONTAL_SWIPE_COUNT }.candidateCount) > 0) + assertEquals(0, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_VERTICAL_SWIPE_COUNT }.candidateCount) + assertTrue(colorEvents.any { it.reason == AgentDiagnosticReason.COLOR_EDGE_REACHED }) + val persistedText = colorEvents.joinToString() + colors.forEach { assertFalse(persistedText.contains(it)) } + assertFalse(persistedText.contains("隐藏候选")) + assertFalse(persistedText.contains("10.00")) + } + + @Test + fun `color diagnostic write failure does not change collection result`() { + var failureCount = 0 + val recorder = SafeAgentDiagnosticRecorder( + persist = { error("diagnostic database unavailable") }, + onFailure = { failureCount++ }, + ) + var clock = 0L + + val result = PddProductDetailCollector( + FakeCollectorDriver(), + { clock }, + { clock += it }, + taskId = 124, + diagnostic = recorder::record, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertTrue(failureCount > 0) + } + @Test fun selectedColorsDisappearingAndReflowingDoNotCreateFalseMissingRows() { val colors = listOf("A色", "B色", "C色") @@ -1093,6 +1150,7 @@ class PddProductDetailCollectorTest { private val orderConfirmationAfterEntry: Boolean = false, private val orderRestorePagesBeforeSpecs: Int = 0, private val sizeHeadingLabel: String = "尺码", + private val nonClickableColorCandidates: List = emptyList(), ) : PddCollectorDriver { var captureCount = 0 var clickCount = 0 @@ -1182,6 +1240,17 @@ class PddProductDetailCollectorTest { parentPath = "scroll", ) } + nonClickableColorCandidates.forEachIndexed { index, label -> + nodes += node( + "scroll/non-clickable-color-$index", + label, + 30 + index * 230, + 650, + 220 + index * 230, + 690, + parentPath = "scroll", + ) + } } if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, 700, 300, 750, parentPath = "scroll") sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->