fix: continue bounded size discovery after color selection (#111)

This commit is contained in:
QiuSW
2026-08-27 16:35:42 +08:00
parent e606096034
commit a10f9833e7
5 changed files with 191 additions and 15 deletions
+2 -2
View File
@@ -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"
@@ -343,7 +343,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
val matches = mutableListOf<AccessibilityNodeInfo>()
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
@@ -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<String, Long>()
val missing = linkedSetOf<String>()
val unsupported = linkedSetOf<String>()
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<String, Boolean>()
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<String, Boolean>,
prices: LinkedHashMap<String, Long>,
missing: MutableSet<String>,
unsupported: MutableSet<String>,
): 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<String>()
var previous: List<String>? = 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<String, Boolean>,
unsupported: MutableSet<String>,
): PddCollectorResult? {
var previous = emptyList<String>()
val startedAt = now()
var previous: List<String>? = 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<VisibleSpecValue>): SizeContinuation? {
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
val containers = values.mapNotNull { value ->
@@ -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,
@@ -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<AgentDiagnosticEvent>()
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<AgentDiagnosticEvent>()
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<AgentDiagnosticEvent>()
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<AgentDiagnosticEvent>()
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<String, Int> = emptyMap(),
private val acceptedClicksWithoutEffect: Set<String> = 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)