diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f73ead9..7ad0ee1 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 = 20 - versionName = "0.9.7" + versionCode = 21 + versionName = "0.9.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index 1cc5169..087389c 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -343,7 +343,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto val matches = mutableListOf() walk(root) { node -> val bounds = Rect().also(node::getBoundsInScreen) - if (node.preferredOrDescendantLabel() == anchor.label && + val anchorMatches = if (anchor.scrollable) { + node.isScrollable + } else { + node.preferredOrDescendantLabel() == anchor.label + } + if (anchorMatches && node.className?.toString() == anchor.className && kotlin.math.abs(bounds.centerX() - anchor.bounds.centerX) <= 32 && kotlin.math.abs(bounds.centerY() - anchor.bounds.centerY) <= 32 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 783acc4..3609a35 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 @@ -82,6 +82,7 @@ data class ParsedPddScreen( val specEntrySource: String?, val quickConfirmationEntry: SnapshotNode?, val reviewPageOpen: Boolean, + val specPanelContainer: SnapshotNode?, val pageEvidenceMatched: Boolean, val rootAvailable: Boolean, val packageMatched: Boolean, @@ -230,6 +231,7 @@ object PddScreenParser { specEntrySource = specEntrySource, quickConfirmationEntry = quickConfirmationEntry, reviewPageOpen = reviewPageOpen, + specPanelContainer = panelScrollable, pageEvidenceMatched = evidence == null || (packageMatched && activityMatched && selectorMatchCount > 0), rootAvailable = rootAvailable, packageMatched = packageMatched, @@ -596,9 +598,10 @@ class PddProductDetailCollector( val prices = linkedMapOf() val missing = linkedSetOf() val unsupported = linkedSetOf() - collectColors(goodsId, config, evidence, deadline, colors, prices, missing, unsupported)?.let { return it } + val specPanelContainer = openedScreen.specPanelContainer + collectColors(goodsId, config, evidence, deadline, specPanelContainer, colors, prices, missing, unsupported)?.let { return it } val sizes = linkedMapOf() - collectSizes(goodsId, config, evidence, deadline, sizes, unsupported)?.let { return it } + collectSizes(goodsId, config, evidence, deadline, specPanelContainer, sizes, unsupported)?.let { return it } if (colors.isEmpty()) missing += "color" if (sizes.isEmpty()) missing += "size" @@ -644,12 +647,13 @@ class PddProductDetailCollector( config: PddCollectorConfig, evidence: PageEvidence, deadline: Long, + specPanelContainer: SnapshotNode?, colors: LinkedHashMap, prices: LinkedHashMap, missing: MutableSet, unsupported: MutableSet, ): PddCollectorResult? { - moveSpecPanelToTop(goodsId, config, evidence, deadline)?.let { return it } + 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) } @@ -722,8 +726,9 @@ class PddProductDetailCollector( config: PddCollectorConfig, evidence: PageEvidence, deadline: Long, + specPanelContainer: SnapshotNode?, ): PddCollectorResult? { - var previous = emptyList() + var previous: List? = null var stable = 0 repeat(config.limits.getValue("specVerticalSwipes")) { if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时") @@ -731,10 +736,10 @@ class PddProductDetailCollector( 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 + stable = if (previous != null && signature == previous) stable + 1 else 0 previous = signature if (stable >= config.limits.getValue("stableEdgeReads")) return null - val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: return null + val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: specPanelContainer ?: return null if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null pause(350) } @@ -771,12 +776,15 @@ class PddProductDetailCollector( config: PddCollectorConfig, evidence: PageEvidence, deadline: Long, + specPanelContainer: SnapshotNode?, sizes: LinkedHashMap, unsupported: MutableSet, ): PddCollectorResult? { - var previous = emptyList() + val startedAt = now() + var previous: List? = null var stable = 0 var continuation: SizeContinuation? = null + var swipes = 0 repeat(config.limits.getValue("specVerticalSwipes") + 1) { pass -> if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时") val screen = parse(goodsId, config, evidence) @@ -789,19 +797,63 @@ class PddProductDetailCollector( visible.forEach { sizes[it.text] = sizes[it.text] == true || it.available } 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 + stable = if (previous != null && signature == previous) stable + 1 else 0 previous = signature - if (stable >= config.limits.getValue("stableEdgeReads") || pass == config.limits.getValue("specVerticalSwipes")) return null + if (stable >= config.limits.getValue("stableEdgeReads")) { + recordSizeDiscovery( + if (sizes.isEmpty()) AgentDiagnosticReason.SIZE_EDGE_REACHED else AgentDiagnosticReason.SIZE_FOUND, + swipes, + sizes.size, + now() - startedAt, + ) + return null + } + if (pass == config.limits.getValue("specVerticalSwipes")) { + recordSizeDiscovery( + if (sizes.isEmpty()) AgentDiagnosticReason.SIZE_SCAN_LIMIT else AgentDiagnosticReason.SIZE_FOUND, + swipes, + sizes.size, + now() - startedAt, + ) + return null + } val anchor = visible.firstOrNull()?.node ?: screen.dimensions.filter { it.key == "color" }.flatMap { it.values }.firstOrNull()?.node - ?: return null + ?: specPanelContainer + if (anchor == null) { + recordSizeDiscovery(AgentDiagnosticReason.SIZE_CONTAINER_UNAVAILABLE, swipes, sizes.size, now() - startedAt) + return null + } trace("sizes swipe=UP anchorBounds=${anchor.bounds}") - if (!driver.swipeSpec(SwipeDirection.UP, anchor)) return null + if (!driver.swipeSpec(SwipeDirection.UP, anchor)) { + recordSizeDiscovery(AgentDiagnosticReason.SIZE_SWIPE_FAILED, swipes, sizes.size, now() - startedAt) + return null + } + swipes++ pause(350) } return null } + private fun recordSizeDiscovery( + reason: AgentDiagnosticReason, + swipes: Int, + candidateCount: Int, + elapsedMs: Long, + ) { + if (taskId <= 0) return + diagnostic( + AgentDiagnosticEvent( + taskId = taskId, + stage = AgentDiagnosticStage.SIZE_DISCOVERY, + reason = reason, + attempt = swipes, + elapsedMs = elapsedMs, + candidateCount = candidateCount, + ), + ) + } + private fun buildSizeContinuation(screen: ParsedPddScreen, values: List): SizeContinuation? { val byPath = screen.sourceNodes.associateBy(SnapshotNode::path) val containers = values.mapNotNull { value -> 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 8d5108b..5524507 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 @@ -8,6 +8,7 @@ import cn.ilapage.goauto.agent.BuildConfig enum class AgentDiagnosticStage { DETAIL_ENTRY, + SIZE_DISCOVERY, PAGE_STABILITY, SHARE_CLICK, SHARE_PANEL, @@ -17,6 +18,11 @@ enum class AgentDiagnosticStage { enum class AgentDiagnosticReason { DETAIL_ENTRY_MATCHED, + SIZE_FOUND, + SIZE_EDGE_REACHED, + SIZE_SCAN_LIMIT, + SIZE_CONTAINER_UNAVAILABLE, + SIZE_SWIPE_FAILED, 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 3105880..c4a43d4 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 @@ -401,6 +401,116 @@ class PddProductDetailCollectorTest { assertEquals(listOf("S", "M", "L", "XL"), requireNotNull(result.payload).dimensions.first { it.key == "size" }.values) } + @Test + fun `size discovery uses confirmed panel after only selected color disappears`() { + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("唯一颜色"), + sizePages = listOf(emptyList(), listOf("S", "M")), + hideSelectedColorOption = true, + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 111, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val payload = requireNotNull(result.payload) + assertEquals("completed", payload.status) + assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values) + assertTrue(payload.missing.none { it == "size" }) + assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second?.scrollable == true }) + val diagnostic = events.single { it.stage == AgentDiagnosticStage.SIZE_DISCOVERY } + assertEquals(AgentDiagnosticReason.SIZE_FOUND, diagnostic.reason) + assertEquals(2, diagnostic.candidateCount) + } + + @Test + fun `size discovery reports unavailable confirmed panel without unsafe swipe`() { + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("唯一颜色"), + sizes = emptyList(), + hideSelectedColorOption = true, + scrollablePanel = false, + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 111, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertEquals("completed_partial", requireNotNull(result.payload).status) + assertTrue(requireNotNull(result.payload).missing.contains("size")) + assertEquals( + AgentDiagnosticReason.SIZE_CONTAINER_UNAVAILABLE, + events.single { it.stage == AgentDiagnosticStage.SIZE_DISCOVERY }.reason, + ) + assertTrue(driver.swipes.none { it.first == SwipeDirection.UP }) + } + + @Test + fun `size discovery reports failed bounded panel swipe`() { + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("唯一颜色"), + sizes = emptyList(), + hideSelectedColorOption = true, + verticalSwipeSucceeds = false, + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 111, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertEquals("completed_partial", requireNotNull(result.payload).status) + assertEquals( + AgentDiagnosticReason.SIZE_SWIPE_FAILED, + events.single { it.stage == AgentDiagnosticStage.SIZE_DISCOVERY }.reason, + ) + assertEquals(1, driver.swipes.count { it.first == SwipeDirection.UP }) + } + + @Test + fun `size discovery stops at stable panel edge when size is absent`() { + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("唯一颜色"), + sizes = emptyList(), + hideSelectedColorOption = true, + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 111, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertEquals("completed_partial", requireNotNull(result.payload).status) + assertEquals( + AgentDiagnosticReason.SIZE_EDGE_REACHED, + events.single { it.stage == AgentDiagnosticStage.SIZE_DISCOVERY }.reason, + ) + assertEquals(1, driver.swipes.count { it.first == SwipeDirection.UP }) + } + @Test fun selectionFailureAndUnstablePriceBecomePartialWithoutGuessing() { val driver = FakeCollectorDriver( @@ -667,6 +777,8 @@ class PddProductDetailCollectorTest { private val priceDelayReads: Map = emptyMap(), private val acceptedClicksWithoutEffect: Set = emptySet(), private val fixedSnapshot: UiSnapshot? = null, + private val scrollablePanel: Boolean = true, + private val verticalSwipeSucceeds: Boolean = true, ) : PddCollectorDriver { var captureCount = 0 var clickCount = 0 @@ -715,7 +827,7 @@ class PddProductDetailCollectorTest { node("reviews", "商品评价(1.2万)", 320, 250, 650, 300), node("selected", "已选 ${displayedSelected().orEmpty()}", 20, 320, 700, 370), node("panel-title", "确认款式", 20, 370, 300, 410), - node("scroll", "", 0, 380, 1080, 1900, scrollable = true), + node("scroll", "", 0, 380, 1080, 1900, scrollable = scrollablePanel), ) val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0 if (!continuationPage) { @@ -794,6 +906,7 @@ class PddProductDetailCollectorTest { override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { swipes += direction to anchor + 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)