feat(#124): add color discovery diagnostics
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+131
-5
@@ -881,12 +881,53 @@ class PddProductDetailCollector(
|
||||
missing: MutableSet<String>,
|
||||
unsupported: MutableSet<String>,
|
||||
): PddCollectorResult? {
|
||||
val diagnosticStartedAt = now()
|
||||
val diagnosticRowValues = linkedMapOf<Int, MutableSet<String>>()
|
||||
val diagnosticParsedValues = linkedSetOf<String>()
|
||||
var diagnosticNonClickableCandidates = 0
|
||||
var diagnosticSelectedValues = 0
|
||||
var diagnosticHorizontalSwipes = 0
|
||||
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
|
||||
|
||||
fun observeColorDiscovery(screen: ParsedPddScreen, rows: List<List<VisibleSpecValue>>) {
|
||||
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<String>()
|
||||
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<Int, Int>,
|
||||
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<String>,
|
||||
): 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AgentDiagnosticEvent>()
|
||||
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<String> = 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 ->
|
||||
|
||||
Reference in New Issue
Block a user