fix: continue bounded size discovery after color selection (#111)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+63
-11
@@ -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,
|
||||
|
||||
+114
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user