fix(#113): restore hidden PDD spec headings
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 22
|
||||
versionName = "0.9.9"
|
||||
versionCode = 23
|
||||
versionName = "0.9.10"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+34
-1
@@ -379,6 +379,32 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return swipe(SemanticTarget.SPEC_PANEL, direction)
|
||||
}
|
||||
|
||||
override fun pullDownSpecPanel(anchor: SnapshotNode): Boolean {
|
||||
if (!anchor.scrollable) return false
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val matches = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (
|
||||
node.isVisibleToUser && node.isEnabled && node.isScrollable &&
|
||||
node.className?.toString() == anchor.className &&
|
||||
kotlin.math.abs(bounds.centerX() - anchor.bounds.centerX) <= 32 &&
|
||||
kotlin.math.abs(bounds.centerY() - anchor.bounds.centerY) <= 32
|
||||
) matches += node
|
||||
}
|
||||
if (matches.size != 1) return false
|
||||
val bounds = Rect().also(matches.single()::getBoundsInScreen)
|
||||
Log.i("GoAutoCollector", "spec-panel-restore anchored=true bounds=$bounds class=${matches.single().className}")
|
||||
return swipeNode(
|
||||
matches.single(),
|
||||
SwipeDirection.DOWN,
|
||||
durationMs = 550,
|
||||
preferScrollAction = false,
|
||||
downStartPercent = 55,
|
||||
downEndPercent = 88,
|
||||
)
|
||||
}
|
||||
|
||||
override fun pullDownGoodsPage(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
Log.i("GoAutoCollector", "pull-down result=unsupported sdk=${Build.VERSION.SDK_INT}")
|
||||
@@ -440,6 +466,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
direction: SwipeDirection,
|
||||
durationMs: Long = 450,
|
||||
preferScrollAction: Boolean = true,
|
||||
downStartPercent: Int = 25,
|
||||
downEndPercent: Int = 75,
|
||||
): Boolean {
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (bounds.width() < 2 || bounds.height() < 2) return false
|
||||
@@ -458,7 +486,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
val centerY = bounds.centerY()
|
||||
val points = when (direction) {
|
||||
SwipeDirection.UP -> listOf(centerX, bottom, centerX, top)
|
||||
SwipeDirection.DOWN -> listOf(centerX, top, centerX, bottom)
|
||||
SwipeDirection.DOWN -> listOf(
|
||||
centerX,
|
||||
bounds.top + bounds.height() * downStartPercent.coerceIn(1, 98) / 100,
|
||||
centerX,
|
||||
bounds.top + bounds.height() * downEndPercent.coerceIn(downStartPercent + 1, 99) / 100,
|
||||
)
|
||||
SwipeDirection.LEFT -> listOf(right, centerY, left, centerY)
|
||||
SwipeDirection.RIGHT -> listOf(left, centerY, right, centerY)
|
||||
}
|
||||
|
||||
+96
-3
@@ -65,13 +65,14 @@ interface PddCollectorDriver {
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
|
||||
fun pullDownSpecPanel(anchor: SnapshotNode): Boolean = swipeSpec(SwipeDirection.DOWN, anchor)
|
||||
fun pullDownGoodsPage(): Boolean
|
||||
fun back(): Boolean
|
||||
}
|
||||
|
||||
data class VisibleSpecValue(val text: String, val available: Boolean, val node: SnapshotNode)
|
||||
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
|
||||
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION }
|
||||
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION }
|
||||
|
||||
data class ParsedPddScreen(
|
||||
val summary: ProductSummary,
|
||||
@@ -95,6 +96,7 @@ data class ParsedPddScreen(
|
||||
val panelOptionCount: Int,
|
||||
val hasSelectionSummary: Boolean,
|
||||
val hasQuantityControls: Boolean,
|
||||
val hasOrderSubmitAction: Boolean,
|
||||
val problem: PageProblem?,
|
||||
val sourceNodes: List<SnapshotNode>,
|
||||
) {
|
||||
@@ -188,8 +190,25 @@ object PddScreenParser {
|
||||
}
|
||||
val hasClose = compactLabels.any { it in setOf("关闭", "關閉") }
|
||||
val quickConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea && hasQuickBuy && hasQuantityControls
|
||||
val orderSubmitCandidates = visibleNodes.asSequence()
|
||||
.filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 }
|
||||
.filter { it.bounds.centerY.toDouble() >= screenHeight * 0.75 }
|
||||
.filter { node ->
|
||||
val compact = (listOf(node.label) + descendants(node, visibleNodes).map(SnapshotNode::label))
|
||||
.joinToString("") { it.replace(" ", "") }
|
||||
compact.contains("提交订单") || compact.contains("提交訂單")
|
||||
}
|
||||
.toList()
|
||||
.filter { candidate ->
|
||||
orderSubmitCandidatesAncestorFree(candidate, visibleNodes)
|
||||
}
|
||||
val hasOrderSubmitAction = orderSubmitCandidates.size == 1
|
||||
val orderConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea &&
|
||||
hasQuantityControls && hasOrderSubmitAction && boundedScrollables.size == 1
|
||||
val fallbackScrollable = boundedScrollables.singleOrNull().takeIf {
|
||||
allHeadings.isEmpty() && hasSelectionSummary && hasPanelTitle && hasPanelAction
|
||||
allHeadings.isEmpty() && (
|
||||
(hasSelectionSummary && hasPanelTitle && hasPanelAction) || orderConfirmationEvidence
|
||||
)
|
||||
}
|
||||
val panelScrollable = headedPanelScrollable ?: fallbackScrollable
|
||||
val panelVisible = panelScrollable?.let { region ->
|
||||
@@ -221,6 +240,7 @@ object PddScreenParser {
|
||||
// selected value or restore a previously scrolled confirmation panel.
|
||||
val specPanelType = when {
|
||||
quickConfirmationEvidence -> SpecPanelType.QUICK_CONFIRMATION
|
||||
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
|
||||
panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
@@ -281,6 +301,7 @@ object PddScreenParser {
|
||||
panelOptionCount = dimensions.sumOf { it.values.size },
|
||||
hasSelectionSummary = hasSelectionSummary,
|
||||
hasQuantityControls = hasQuantityControls,
|
||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||
problem = problem,
|
||||
sourceNodes = visibleNodes,
|
||||
)
|
||||
@@ -291,6 +312,16 @@ object PddScreenParser {
|
||||
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
|
||||
}
|
||||
|
||||
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>): Boolean {
|
||||
val candidatePrefix = "${candidate.path}/"
|
||||
return source.none { node ->
|
||||
node.path.startsWith(candidatePrefix) && node.visible && node.enabled && node.clickable &&
|
||||
(listOf(node.label) + descendants(node, source).map(SnapshotNode::label))
|
||||
.joinToString("") { it.replace(" ", "") }
|
||||
.let { it.contains("提交订单") || it.contains("提交訂單") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SnapshotNode.matches(selector: NodeSelector): Boolean =
|
||||
(selector.resourceId == null || resourceId == selector.resourceId) &&
|
||||
(selector.text == null || text == selector.text) &&
|
||||
@@ -588,6 +619,67 @@ class PddProductDetailCollector(
|
||||
reviewPage = screen
|
||||
break
|
||||
}
|
||||
if (screen.pageEvidenceMatched && screen.specPanelType == SpecPanelType.ORDER_CONFIRMATION) {
|
||||
val maxRestores = minOf(3, config.limits.getValue("specVerticalSwipes"))
|
||||
var restored = screen
|
||||
var restoreAttempts = 0
|
||||
var previousSignature = screenSignature(screen)
|
||||
var stableReads = 0
|
||||
var restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_LIMIT
|
||||
var restoreContainer = screen.specPanelContainer
|
||||
if (restoreContainer == null) {
|
||||
restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE
|
||||
} else {
|
||||
while (restoreAttempts < maxRestores) {
|
||||
val currentContainer = restoreContainer
|
||||
if (currentContainer == null) {
|
||||
restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE
|
||||
break
|
||||
}
|
||||
if (!driver.pullDownSpecPanel(currentContainer)) {
|
||||
restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_GESTURE_FAILED
|
||||
break
|
||||
}
|
||||
restoreAttempts++
|
||||
pause(350)
|
||||
val refreshed = parse(goodsId, config, evidence)
|
||||
panelAttempts++
|
||||
lastPanelScreen = refreshed
|
||||
refreshed.problem?.let { return failure(it.code, it.message) }
|
||||
if (!refreshed.pageEvidenceMatched) {
|
||||
return failure("RULE_NOT_MATCHED", "规格面板回顶期间离开 PDD 商品详情页")
|
||||
}
|
||||
restored = refreshed
|
||||
if (refreshed.dimensions.isNotEmpty()) {
|
||||
restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_SUCCEEDED
|
||||
break
|
||||
}
|
||||
restoreContainer = refreshed.specPanelContainer
|
||||
if (restoreContainer == null) {
|
||||
restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE
|
||||
break
|
||||
}
|
||||
val signature = screenSignature(refreshed)
|
||||
stableReads = if (signature == previousSignature) stableReads + 1 else 0
|
||||
previousSignature = signature
|
||||
if (stableReads >= config.limits.getValue("stableEdgeReads")) {
|
||||
restoreReason = AgentDiagnosticReason.SPEC_PANEL_RESTORE_EDGE
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
recordSpecPanelEntry(
|
||||
restoreReason,
|
||||
restored,
|
||||
restoreAttempts,
|
||||
now() - panelStartedAt,
|
||||
screenChanged,
|
||||
restoreAttempts > 0,
|
||||
restoreReason == AgentDiagnosticReason.SPEC_PANEL_RESTORE_SUCCEEDED,
|
||||
)
|
||||
opened = if (restored.dimensions.isEmpty()) restored.copy(specPanelContainer = null) else restored
|
||||
break
|
||||
}
|
||||
if (
|
||||
screen.pageEvidenceMatched &&
|
||||
screen.specPanelType == SpecPanelType.QUICK_CONFIRMATION &&
|
||||
@@ -991,7 +1083,8 @@ class PddProductDetailCollector(
|
||||
val shape = screen?.let {
|
||||
"type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" +
|
||||
"option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" +
|
||||
"quantity=${it.hasQuantityControls.toInt()};changed=${screenChanged.toInt()};" +
|
||||
"quantity=${it.hasQuantityControls.toInt()};submit=${it.hasOrderSubmitAction.toInt()};" +
|
||||
"changed=${screenChanged.toInt()};" +
|
||||
"recovery=${when {
|
||||
recoverySucceeded -> "success"
|
||||
reason == AgentDiagnosticReason.SPEC_PANEL_RECOVERY_UNAVAILABLE -> "unavailable"
|
||||
|
||||
@@ -23,6 +23,11 @@ enum class AgentDiagnosticReason {
|
||||
SPEC_PANEL_RECOVERED,
|
||||
SPEC_PANEL_RECOVERY_UNAVAILABLE,
|
||||
SPEC_PANEL_RECOVERY_FAILED,
|
||||
SPEC_PANEL_RESTORE_SUCCEEDED,
|
||||
SPEC_PANEL_RESTORE_EDGE,
|
||||
SPEC_PANEL_RESTORE_LIMIT,
|
||||
SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE,
|
||||
SPEC_PANEL_RESTORE_GESTURE_FAILED,
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
SPEC_ENTRY_CLICK_NO_EFFECT,
|
||||
SIZE_FOUND,
|
||||
|
||||
+181
-2
@@ -335,6 +335,67 @@ class PddProductDetailCollectorTest {
|
||||
assertEquals(null, parsed.quickConfirmationEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderConfirmationUsesReadOnlySubmitEvidenceAndUniqueRecyclerView() {
|
||||
val snapshot = orderConfirmationSnapshot()
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertTrue(parsed.specPanelOpen)
|
||||
assertEquals(SpecPanelType.ORDER_CONFIRMATION, parsed.specPanelType)
|
||||
assertEquals("panel", parsed.specPanelContainer?.path)
|
||||
assertTrue(parsed.dimensions.isEmpty())
|
||||
assertTrue(parsed.hasOrderSubmitAction)
|
||||
assertEquals(null, parsed.quickConfirmationEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderConfirmationRejectsAmbiguousScrollableContainers() {
|
||||
val snapshot = orderConfirmationSnapshot().let {
|
||||
it.copy(
|
||||
nodes = it.nodes + node(
|
||||
"secondary-panel",
|
||||
"",
|
||||
0,
|
||||
700,
|
||||
1080,
|
||||
1300,
|
||||
scrollable = true,
|
||||
className = "androidx.recyclerview.widget.RecyclerView",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertFalse(parsed.specPanelOpen)
|
||||
assertEquals(SpecPanelType.UNKNOWN, parsed.specPanelType)
|
||||
assertEquals(null, parsed.specPanelContainer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun referenceCategoryIsParsedAsSizeAfterPanelRestore() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("summary", "已选:绿色 100", 20, 300, 700, 360),
|
||||
node("title", "确认款式", 20, 370, 300, 420),
|
||||
node("panel", "", 0, 430, 1080, 1800, scrollable = true),
|
||||
node("panel/color-heading", "颜色分类", 20, 500, 300, 550, parentPath = "panel"),
|
||||
node("panel/green", "绿色", 30, 580, 260, 650, clickable = true, parentPath = "panel"),
|
||||
node("panel/size-heading", "参考分类", 20, 720, 300, 770, parentPath = "panel"),
|
||||
node("panel/100", "100 建议身高90-100", 30, 800, 360, 870, clickable = true, parentPath = "panel"),
|
||||
node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertEquals(listOf("100 建议身高90-100"), parsed.dimensions.single { it.key == "size" }.values.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun boundedScrollablePanelUsesHeadingsInsideItsOwnRegion() {
|
||||
val snapshot = UiSnapshot(
|
||||
@@ -825,6 +886,92 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(event.targetClassName?.contains("recovery=success") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderConfirmationUsesAtMostThreePanelGesturesAndFindsReferenceCategory() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("绿色"),
|
||||
sizes = listOf("100 建议身高90-100"),
|
||||
startWithPanelClosed = true,
|
||||
orderConfirmationAfterEntry = true,
|
||||
orderRestorePagesBeforeSpecs = 2,
|
||||
sizeHeadingLabel = "参考分类",
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
driver,
|
||||
{ clock },
|
||||
{ clock += it },
|
||||
taskId = 82,
|
||||
diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(2, driver.restoreGestures)
|
||||
assertEquals(listOf("100 建议身高90-100"), result.payload?.dimensions?.single { it.key == "size" }?.values)
|
||||
assertFalse(driver.clickedLabels.any { it.contains("已选") || it.contains("提交订单") || it.contains("支付") })
|
||||
assertEquals(
|
||||
AgentDiagnosticReason.SPEC_PANEL_RESTORE_SUCCEEDED,
|
||||
events.single { it.stage == AgentDiagnosticStage.SPEC_PANEL_ENTRY }.reason,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderConfirmationStopsAfterThreeChangingViewsWithoutGuessingActions() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
startWithPanelClosed = true,
|
||||
orderConfirmationAfterEntry = true,
|
||||
orderRestorePagesBeforeSpecs = 10,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
driver,
|
||||
{ clock },
|
||||
{ clock += it },
|
||||
taskId = 83,
|
||||
diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(3, driver.restoreGestures)
|
||||
assertEquals(listOf("单独购买"), driver.clickedLabels)
|
||||
assertEquals(
|
||||
AgentDiagnosticReason.SPEC_PANEL_RESTORE_LIMIT,
|
||||
events.single { it.stage == AgentDiagnosticStage.SPEC_PANEL_ENTRY }.reason,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderConfirmationStopsWhenBoundedRestoreGestureFails() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
startWithPanelClosed = true,
|
||||
orderConfirmationAfterEntry = true,
|
||||
orderRestorePagesBeforeSpecs = 2,
|
||||
verticalSwipeSucceeds = false,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
driver,
|
||||
{ clock },
|
||||
{ clock += it },
|
||||
taskId = 84,
|
||||
diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(0, driver.restoreGestures)
|
||||
assertEquals(listOf("单独购买"), driver.clickedLabels)
|
||||
assertEquals(
|
||||
AgentDiagnosticReason.SPEC_PANEL_RESTORE_GESTURE_FAILED,
|
||||
events.single { it.stage == AgentDiagnosticStage.SPEC_PANEL_ENTRY }.reason,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedReviewMisrouteStopsAfterSingleRecovery() {
|
||||
val driver = FakeCollectorDriver(startWithPanelClosed = true, alwaysEntryOpensReview = true)
|
||||
@@ -931,6 +1078,9 @@ class PddProductDetailCollectorTest {
|
||||
private val verticalSwipeSucceeds: Boolean = true,
|
||||
private val quickConfirmationAfterEntry: Boolean = false,
|
||||
private val hideQuickConfirmationSpec: Boolean = false,
|
||||
private val orderConfirmationAfterEntry: Boolean = false,
|
||||
private val orderRestorePagesBeforeSpecs: Int = 0,
|
||||
private val sizeHeadingLabel: String = "尺码",
|
||||
) : PddCollectorDriver {
|
||||
var captureCount = 0
|
||||
var clickCount = 0
|
||||
@@ -939,6 +1089,7 @@ class PddProductDetailCollectorTest {
|
||||
var pullDownCount = 0
|
||||
var backCount = 0
|
||||
var entryClickCount = 0
|
||||
var restoreGestures = 0
|
||||
private var selected: String? = null
|
||||
private var previousSelected: String? = null
|
||||
private var horizontalPage = 0
|
||||
@@ -986,6 +1137,9 @@ class PddProductDetailCollectorTest {
|
||||
add(node("buy-now", "现在买", 500, 1800, 1080, 2180, clickable = true))
|
||||
})
|
||||
}
|
||||
if (orderConfirmationAfterEntry && restoreGestures < orderRestorePagesBeforeSpecs) {
|
||||
return orderConfirmationSnapshot(restoreGestures)
|
||||
}
|
||||
val nodes = mutableListOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("title", "这是一个足够长的拼多多测试商品标题", 20, 100, 1000, 180),
|
||||
@@ -1017,7 +1171,7 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!continuationPage) nodes += node("scroll/size-heading", "尺码", 20, 700, 300, 750, parentPath = "scroll")
|
||||
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, 700, 300, 750, parentPath = "scroll")
|
||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||
nodes += node("scroll/size-$size-$captureCount", size, 30 + index * 230, 770, 220 + index * 230, 840, clickable = true, parentPath = "scroll")
|
||||
}
|
||||
@@ -1086,6 +1240,13 @@ class PddProductDetailCollectorTest {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun pullDownSpecPanel(anchor: SnapshotNode): Boolean {
|
||||
if (!anchor.scrollable || !verticalSwipeSucceeds) return false
|
||||
restoreGestures++
|
||||
swipes += SwipeDirection.DOWN to anchor
|
||||
return true
|
||||
}
|
||||
|
||||
override fun pullDownGoodsPage(): Boolean { pullDownCount++; return true }
|
||||
|
||||
override fun back(): Boolean {
|
||||
@@ -1119,6 +1280,24 @@ class PddProductDetailCollectorTest {
|
||||
clickable, scrollable, selected, false, true, true,
|
||||
)
|
||||
|
||||
private fun orderConfirmationSnapshot(row: Int = 0) = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2328, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("close", "关闭", 975, 273, 1050, 348, clickable = true, className = "android.widget.ImageView"),
|
||||
node("summary", "已选:绿色两件套 100 建议身高90-100", 396, 654, 1053, 768),
|
||||
node("quantity", "1", 480, 804, 567, 879, className = "android.widget.EditText"),
|
||||
node("decrease", "减少数量", 396, 804, 474, 879, clickable = true, className = "android.widget.ImageView"),
|
||||
node("increase", "增加数量", 567, 804, 645, 879, clickable = true, className = "android.widget.ImageView"),
|
||||
node("panel", "", 0, 1052, 1080, 2079, scrollable = true, className = "androidx.recyclerview.widget.RecyclerView"),
|
||||
node("panel/row", "确认内容$row", 20, 1200, 1000, 1280, clickable = true, parentPath = "panel"),
|
||||
node("payment", "使用微信支付,更换先用后付可0元下单", 112, 2100, 930, 2157),
|
||||
node("submit", "", 0, 2181, 1080, 2328, clickable = true, className = "android.widget.FrameLayout"),
|
||||
node("submit/text", "提交订单 ¥9.82", 306, 2225, 627, 2284, parentPath = "submit"),
|
||||
),
|
||||
)
|
||||
|
||||
private fun evidence() = PageEvidence(PDD_PACKAGE, ACTIVITY, NodeSelector(resourceId = "android:id/content", className = "android.widget.FrameLayout"))
|
||||
|
||||
private fun detailSnapshot(
|
||||
@@ -1135,7 +1314,7 @@ class PddProductDetailCollectorTest {
|
||||
|
||||
private fun config(maxSkuCount: Int = 500) = PddCollectorConfig(
|
||||
"pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color",
|
||||
listOf("颜色分类", "颜色", "款式"), listOf("尺码", "尺寸", "套餐"),
|
||||
listOf("颜色分类", "颜色", "款式"), listOf("尺码", "尺寸", "套餐", "参考分类"),
|
||||
mapOf("page" to 1000, "specPanel" to 1000, "selection" to 500, "price" to 500, "overall" to 10000),
|
||||
mapOf("goodsPageVerticalSwipes" to 1, "specHorizontalSwipes" to 3, "specVerticalSwipes" to 3, "stableEdgeReads" to 1, "stablePriceReads" to 2, "maxSkuCount" to maxSkuCount),
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 2c2d84b24108217e4242337a6afc69db64ff924c
|
||||
synchronized_at: 2026-08-27T06:57:31Z
|
||||
wiki_revision: f3d85bf36ae7543aaa3fa1c6926e9bc0f221fb1e
|
||||
synchronized_at: 2026-08-27T09:22:25Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -89,6 +89,7 @@ synchronized_at: 2026-08-27T06:57:31Z
|
||||
- v2 任务首次未进入精确商品详情页时,可以按规则显式重开同一浏览器 URL 一次;登录、验证码、风控、无效链接和详情页内采集失败不触发该恢复。
|
||||
- 假售罄恢复只使用主商品规格/购买强证据判断页面是否正常;顶部“相似商品”可以作为受控兜底证据,推荐卡片的标题、价格和销量不得冒充主商品证据。
|
||||
- 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。
|
||||
- PDD 已选规格的订单确认形态只可由详情页、选择摘要、唯一数量控件、支付区、唯一底部提交动作和唯一主要滚动容器的组合证据确认;底部提交动作只读且永久禁止点击。Agent 只在该已确认容器内最多三次向下拖动回顶,每次重新读取节点;“参考分类”作为尺码的精确标题别名处理。
|
||||
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则未来可以使用独立的创建订单能力,任何规则都不能付款。
|
||||
|
||||
## 采集任务
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package rulecontract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -68,6 +69,9 @@ func TestBuiltInPDDProductDetailTemplateIsValid(t *testing.T) {
|
||||
if strings.Join(required, ",") != "action.swipe.v1,collector.pdd.product-detail.v1,rule.schema.v2" {
|
||||
t.Fatalf("unexpected template capabilities: %v", required)
|
||||
}
|
||||
if !bytes.Contains(raw, []byte(`"size":["尺码","尺寸","套餐","参考分类"]`)) {
|
||||
t.Fatal("built-in template must classify 参考分类 as size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
|
||||
|
||||
@@ -17,7 +17,7 @@ var pddProductDetailTemplate = json.RawMessage(`{
|
||||
"hooks":{"afterSpecPanelOpen":[]},
|
||||
"collector":{
|
||||
"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color",
|
||||
"dimensionAliases":{"color":["颜色分类","颜色","款式","颜色款式","花色"],"size":["尺码","尺寸","套餐"]},
|
||||
"dimensionAliases":{"color":["颜色分类","颜色","款式","颜色款式","花色"],"size":["尺码","尺寸","套餐","参考分类"]},
|
||||
"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},
|
||||
"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user