fix(agent): reject review-page spec entry misroutes (#166)
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 43
|
||||
versionName = "0.9.30"
|
||||
versionCode = 44
|
||||
versionName = "0.9.31"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+3
@@ -295,6 +295,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
if (candidates.isEmpty()) return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
if (candidates.size != 1) return FreshClickOutcome(FreshActionResult.AMBIGUOUS, FreshClickReason.TARGET_AMBIGUOUS, candidates.size)
|
||||
var node = candidates.single()
|
||||
if (target.clickable && !node.isClickable) {
|
||||
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND, candidates.size)
|
||||
}
|
||||
var ancestorDepth = 0
|
||||
while (!node.isClickable) {
|
||||
node = node.parent ?: return FreshClickOutcome(
|
||||
|
||||
+27
-10
@@ -107,6 +107,7 @@ data class ParsedPddScreen(
|
||||
val specPanelOpen: Boolean,
|
||||
val specPanelType: SpecPanelType,
|
||||
val specEntry: SnapshotNode?,
|
||||
val specEntryClickTarget: SnapshotNode?,
|
||||
val specEntrySource: String?,
|
||||
val quickConfirmationEntry: SnapshotNode?,
|
||||
val reviewPageOpen: Boolean,
|
||||
@@ -158,6 +159,7 @@ data class ParsedPddScreen(
|
||||
}
|
||||
|
||||
object PddScreenParser {
|
||||
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
|
||||
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
|
||||
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
|
||||
private val reviewPattern = Regex("(?:商品评价\\s*[((]?\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?|[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*条?评价|评价\\s*[0-9]+)")
|
||||
@@ -311,13 +313,17 @@ object PddScreenParser {
|
||||
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes, config) }
|
||||
.maxByOrNull { it.bounds.top }
|
||||
val bottomSpecEntry = if (panelOpen || explicitSpecEntry != null) null else safeBottomSpecEntry(visibleNodes, visible, config)
|
||||
val specEntry = explicitSpecEntry ?: bottomSpecEntry
|
||||
val candidateSpecEntry = explicitSpecEntry ?: bottomSpecEntry?.anchor
|
||||
val candidateClickTarget = explicitSpecEntry ?: bottomSpecEntry?.clickTarget
|
||||
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, candidateSpecEntry, config)
|
||||
val specEntry = candidateSpecEntry.takeUnless { reviewPageOpen }
|
||||
val specEntryClickTarget = candidateClickTarget.takeUnless { reviewPageOpen }
|
||||
val specEntrySource = when {
|
||||
reviewPageOpen -> null
|
||||
explicitSpecEntry != null -> "explicit_selection"
|
||||
bottomSpecEntry != null -> "bottom_purchase"
|
||||
else -> null
|
||||
}
|
||||
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, specEntry, config)
|
||||
val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null
|
||||
val rootAvailable = snapshot.packageName != null || snapshot.activityName != null || snapshot.nodes.isNotEmpty()
|
||||
val packageMatched = evidence == null || snapshot.packageName == evidence.packageName
|
||||
@@ -343,6 +349,7 @@ object PddScreenParser {
|
||||
specPanelOpen = panelOpen,
|
||||
specPanelType = specPanelType,
|
||||
specEntry = specEntry,
|
||||
specEntryClickTarget = specEntryClickTarget,
|
||||
specEntrySource = specEntrySource,
|
||||
quickConfirmationEntry = quickConfirmationEntry,
|
||||
reviewPageOpen = reviewPageOpen,
|
||||
@@ -446,7 +453,7 @@ object PddScreenParser {
|
||||
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::startsWith)
|
||||
}
|
||||
|
||||
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): SnapshotNode? {
|
||||
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): SafeSpecEntry? {
|
||||
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return null
|
||||
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
if (screenWidth <= 0 || screenHeight <= 0) return null
|
||||
@@ -460,15 +467,20 @@ object PddScreenParser {
|
||||
var clickable: SnapshotNode? = labelNode
|
||||
while (clickable != null && !clickable.clickable) clickable = clickable.parentPath?.let(byPath::get)
|
||||
val clickTarget = clickable?.takeIf { it.visible && it.enabled } ?: return@mapNotNull null
|
||||
if (hasReviewContext(labelNode, source, config) || hasReviewContext(clickTarget, source, config)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (clickTarget.bounds.centerX.toDouble() < screenWidth * 0.4 ||
|
||||
clickTarget.bounds.centerY.toDouble() < screenHeight * 0.8 ||
|
||||
clickTarget.bounds.bottom.toDouble() < screenHeight * 0.88 ||
|
||||
clickTarget.bounds.width.toDouble() < screenWidth * 0.08 ||
|
||||
clickTarget.bounds.height.toDouble() > screenHeight * 0.3
|
||||
) return@mapNotNull null
|
||||
(normalizedByPath[labelNode.path] ?: labelNode) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height
|
||||
val normalizedAnchor = normalizedByPath[labelNode.path] ?: labelNode
|
||||
val normalizedTarget = normalizedByPath[clickTarget.path] ?: return@mapNotNull null
|
||||
SafeSpecEntry(normalizedAnchor, normalizedTarget) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height
|
||||
}
|
||||
.minWithOrNull(compareBy<Pair<SnapshotNode, Long>> { it.second }.thenByDescending { it.first.bounds.centerX })
|
||||
.minWithOrNull(compareBy<Pair<SafeSpecEntry, Long>> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX })
|
||||
?.first
|
||||
}
|
||||
|
||||
@@ -492,17 +504,22 @@ object PddScreenParser {
|
||||
specEntry: SnapshotNode?,
|
||||
config: PddCollectorConfig,
|
||||
): Boolean {
|
||||
if (screenHeight <= 0 || specEntry != null) return false
|
||||
if (screenHeight <= 0) return false
|
||||
val hasTopTitle = visible.any { node ->
|
||||
node.label.replace(" ", "") in config.textAliases.review.entryAliases &&
|
||||
node.bounds.centerY <= screenHeight * 25 / 100
|
||||
}
|
||||
if (!hasTopTitle) return false
|
||||
val hasReviewContent = visible.any { node ->
|
||||
val reviewSignals = visible.map { node ->
|
||||
val compact = node.label.replace(" ", "")
|
||||
compact in setOf("全部", "有图/视频", "追评", "最新") || compact.contains("条评价")
|
||||
}
|
||||
return hasReviewContent || source.none { node ->
|
||||
when {
|
||||
compact in setOf("全部", "有图/视频", "追评", "最新") -> compact
|
||||
compact.contains("条评价") -> "count"
|
||||
else -> null
|
||||
}
|
||||
}.filterNotNull().distinct()
|
||||
if (reviewSignals.size >= 2) return true
|
||||
return specEntry == null && source.none { node ->
|
||||
node.visible && node.enabled && node.bounds.centerY >= screenHeight * 80 / 100 &&
|
||||
config.textAliases.purchase.buyWords.any(node.label::contains)
|
||||
}
|
||||
|
||||
+46
-9
@@ -252,30 +252,62 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
var screen = currentScreen(input)
|
||||
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (screen.specPanelOpen) return null
|
||||
val safeCandidates = listOfNotNull(screen.specEntry, screen.quickConfirmationEntry)
|
||||
.distinctBy(SnapshotNode::path)
|
||||
val safeCandidates = listOfNotNull(
|
||||
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
|
||||
screen.quickConfirmationEntry?.let { it to it },
|
||||
).distinctBy { it.second.path }
|
||||
// A rule alias may only narrow the semantic candidates already accepted
|
||||
// by PddScreenParser. It must never turn into a raw-page text lookup:
|
||||
// review cards and unrelated controls can share arbitrary labels.
|
||||
val candidates = action.textAliases?.let { aliases ->
|
||||
safeCandidates.filter { it.label in aliases }
|
||||
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
|
||||
} ?: safeCandidates
|
||||
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "规格入口匹配到多个控件")
|
||||
val target = candidates.singleOrNull() ?: return failure("RULE_NOT_MATCHED", "没有找到商品规格入口")
|
||||
when (driver.clickFresh(target)) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "规格入口匹配到多个控件")
|
||||
if (candidates.size > 1) return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
|
||||
val target = candidates.singleOrNull()?.second ?: return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口")
|
||||
val click = driver.clickFreshDetailed(target)
|
||||
when (click.result) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
else -> return failure("RULE_ACTION_FAILED", "商品规格入口点击失败")
|
||||
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
|
||||
}
|
||||
repeat(30) {
|
||||
screen = currentScreen(input)
|
||||
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (screen.specPanelOpen) return null
|
||||
pause(100)
|
||||
}
|
||||
return failure("RULE_NOT_MATCHED", "商品规格面板没有打开")
|
||||
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板")
|
||||
}
|
||||
|
||||
private fun specEntryMatchesAliases(screen: ParsedPddScreen, candidate: SnapshotNode, aliases: List<String>): Boolean {
|
||||
val prefix = "${candidate.path}/"
|
||||
return (sequenceOf(candidate) + screen.sourceNodes.asSequence().filter { it.path.startsWith(prefix) })
|
||||
.any { it.label in aliases }
|
||||
}
|
||||
|
||||
private fun leaveUnexpectedReviewPage(input: PurchaseExecutionInput): PurchaseExecutionOutcome {
|
||||
if (!driver.backPurchase()) return failure(SPEC_ENTRY_OPENED_REVIEW, "back_failed")
|
||||
repeat(30) {
|
||||
val returned = currentScreen(input)
|
||||
if (!returned.reviewPageOpen && returned.problem == null && returned.hasPurchaseProductEvidence()) {
|
||||
return failure(SPEC_ENTRY_OPENED_REVIEW, "returned_to_product")
|
||||
}
|
||||
pause(100)
|
||||
}
|
||||
return failure(SPEC_ENTRY_OPENED_REVIEW, "return_unconfirmed")
|
||||
}
|
||||
|
||||
private fun FreshClickReason.specEntrySubreason(): String = when (this) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE -> "root_unavailable"
|
||||
FreshClickReason.TARGET_NOT_FOUND -> "target_stale"
|
||||
FreshClickReason.TARGET_AMBIGUOUS -> "target_ambiguous"
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR -> "no_clickable_ancestor"
|
||||
FreshClickReason.ACTION_CLICK_FALSE -> "action_click_false"
|
||||
else -> "unknown"
|
||||
}
|
||||
|
||||
private fun selectSpecs(input: PurchaseExecutionInput, rule: PurchaseRule): PurchaseExecutionOutcome? {
|
||||
@@ -518,6 +550,11 @@ class PurchaseRehearsalExecutor(
|
||||
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
|
||||
private const val SPEC_SELECTION_POLL_MILLIS = 100L
|
||||
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
|
||||
private const val SPEC_ENTRY_NOT_FOUND = "PURCHASE_SPEC_ENTRY_NOT_FOUND"
|
||||
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
|
||||
private const val SPEC_ENTRY_CLICK_FAILED = "PURCHASE_SPEC_ENTRY_CLICK_FAILED"
|
||||
private const val SPEC_ENTRY_OPENED_REVIEW = "PURCHASE_SPEC_ENTRY_OPENED_REVIEW"
|
||||
private const val SPEC_PANEL_NOT_OPENED = "PURCHASE_SPEC_PANEL_NOT_OPENED"
|
||||
private const val SPEC_TARGET_NOT_VISIBLE = "PURCHASE_SPEC_TARGET_NOT_VISIBLE"
|
||||
private const val SPEC_TARGET_AMBIGUOUS = "PURCHASE_SPEC_TARGET_AMBIGUOUS"
|
||||
private const val SPEC_SAFE_TARGET_MISSING = "PURCHASE_SPEC_SAFE_TARGET_MISSING"
|
||||
|
||||
@@ -298,9 +298,50 @@ class PddProductDetailCollectorTest {
|
||||
|
||||
assertFalse(parsed.specPanelOpen)
|
||||
assertEquals("buy/label", parsed.specEntry?.path)
|
||||
assertEquals("buy", parsed.specEntryClickTarget?.path)
|
||||
assertEquals("bottom_purchase", parsed.specEntrySource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bottomPurchaseInsideReviewCardIsNeverSpecEntry() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("reviews", "", 500, 1750, 1080, 2180, clickable = true),
|
||||
node("reviews/title", "商品评价", 520, 1770, 900, 1840, parentPath = "reviews"),
|
||||
node("reviews/buy", "购买", 780, 2050, 1040, 2140, parentPath = "reviews"),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertEquals(null, parsed.specEntry)
|
||||
assertEquals(null, parsed.specEntrySource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reviewPageWinsOverAFlatBottomPurchaseCandidate() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("review-title", "商品评价", 300, 40, 780, 120),
|
||||
node("review-tab", "全部", 20, 180, 200, 250),
|
||||
node("review-count", "238条评价", 20, 280, 400, 350),
|
||||
node("recommend-buy", "购买", 760, 1900, 1040, 2140, clickable = true),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertTrue(parsed.reviewPageOpen)
|
||||
assertEquals(null, parsed.specEntry)
|
||||
assertEquals(null, parsed.specEntrySource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reviewCardContainingSizeAndDigitsIsNeverSpecEntry() {
|
||||
val snapshot = UiSnapshot(
|
||||
|
||||
@@ -382,10 +382,59 @@ class PurchaseRehearsalExecutorTest {
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertTrue(driver.clicked.contains("免拼购买"))
|
||||
assertTrue(driver.clickedPaths.contains("buy"))
|
||||
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase returns once and fails explicitly when spec entry opens reviews`() {
|
||||
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, openReviewOnBottomClick = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_OPENED_REVIEW", outcome.errorCode)
|
||||
assertEquals("returned_to_product", outcome.message)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(1, driver.clickedPaths.count { it == "buy" })
|
||||
assertFalse(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase reports stable spec entry click reasons`() {
|
||||
val cases = listOf(
|
||||
FreshClickReason.ROOT_UNAVAILABLE to "root_unavailable",
|
||||
FreshClickReason.TARGET_NOT_FOUND to "target_stale",
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR to "no_clickable_ancestor",
|
||||
FreshClickReason.ACTION_CLICK_FALSE to "action_click_false",
|
||||
)
|
||||
cases.forEach { (reason, expected) ->
|
||||
val driver = FakePurchaseDriver(forcedEntryClickReason = reason)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
|
||||
assertEquals(expected, outcome.message)
|
||||
assertFalse(driver.panel)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase stops when it cannot return from an unexpected review page`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
bottomPurchaseEntry = true,
|
||||
openReviewOnBottomClick = true,
|
||||
reviewBackSucceeds = false,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_OPENED_REVIEW", outcome.errorCode)
|
||||
assertEquals("back_failed", outcome.message)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(1, driver.clickedPaths.count { it == "buy" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec aliases cannot bypass semantic safe candidates`() {
|
||||
val driver = FakePurchaseDriver(includeReviewEntry = true)
|
||||
@@ -393,7 +442,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(unsafeRule), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertFalse(driver.clicked.contains("商品评价"))
|
||||
}
|
||||
|
||||
@@ -484,11 +533,14 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val duplicateOpen: Boolean = false,
|
||||
private val bottomPurchaseEntry: Boolean = false,
|
||||
private val includeReviewEntry: Boolean = false,
|
||||
private val openReviewOnBottomClick: Boolean = false,
|
||||
private val reviewBackSucceeds: Boolean = true,
|
||||
private val hiddenSizeUntilUpSwipes: Int = 0,
|
||||
private val openClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
private val openPddOnFailedClick: Boolean = false,
|
||||
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
private val forcedSizeClickReason: FreshClickReason? = null,
|
||||
private val forcedEntryClickReason: FreshClickReason? = null,
|
||||
private val sizeSelectsOnFailedClick: Boolean = false,
|
||||
private val selectedSizeSummaryOverride: String? = null,
|
||||
private val hideSizeSelectedState: Boolean = false,
|
||||
@@ -509,10 +561,13 @@ class PurchaseRehearsalExecutorTest {
|
||||
var openClickCount = 0
|
||||
var sizeClickCount = 0
|
||||
var pullDownCount = 0
|
||||
var backCount = 0
|
||||
private var soldOut = soldOut
|
||||
private var allSpecsUnavailable = allSpecsUnavailable
|
||||
private var productEvidenceLost = false
|
||||
private var reviewPage = false
|
||||
val clicked = mutableListOf<String>()
|
||||
val clickedPaths = mutableListOf<String>()
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (browser && !panel && color == null && size == null) {
|
||||
@@ -522,6 +577,14 @@ class PurchaseRehearsalExecutorTest {
|
||||
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
|
||||
}
|
||||
if (!panel) {
|
||||
if (reviewPage) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("review-title", "商品评价", 300, 40, 780, 120),
|
||||
node("review-tab", "全部", 20, 180, 200, 250),
|
||||
node("review-count", "238条评价", 20, 280, 400, 350),
|
||||
))
|
||||
}
|
||||
if (productEvidenceLost) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
|
||||
}
|
||||
@@ -581,6 +644,11 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
clickedPaths += target.path
|
||||
if (target.path == "buy") {
|
||||
if (openReviewOnBottomClick) reviewPage = true else panel = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
when (target.label) {
|
||||
"打开" -> {
|
||||
openClickCount++
|
||||
@@ -603,6 +671,15 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
|
||||
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
|
||||
val result = when (forcedEntryClickReason) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
|
||||
FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED
|
||||
FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS
|
||||
}
|
||||
return FreshClickOutcome(result, forcedEntryClickReason)
|
||||
}
|
||||
if (target.label in sizes && forcedSizeClickReason != null) {
|
||||
sizeClickCount++
|
||||
val result = when (forcedSizeClickReason) {
|
||||
@@ -654,6 +731,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
override fun backPurchase(): Boolean {
|
||||
backCount++
|
||||
if (reviewPage && !reviewBackSucceeds) return false
|
||||
if (reviewPage) reviewPage = false
|
||||
panel = false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 52119b8626496af10f232fb663595779427f37f0
|
||||
synchronized_at: 2026-08-31T03:09:59Z
|
||||
wiki_revision: 484a645058de780abe2ff2951afd25d38a03b74e
|
||||
synchronized_at: 2026-08-31T03:41:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -405,7 +405,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
| `DEVICE_BUSY` | 设备已有活动任务 | 否 |
|
||||
| `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 |
|
||||
|
||||
## 采购任务共享契约(#33、#34、#36、#42、#44、#53、#67、#164)
|
||||
## 采购任务共享契约(#33、#34、#36、#42、#44、#53、#67、#164、#166)
|
||||
|
||||
本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口、Android 演练执行及 #36 正式地址/订单动作已经落地。#36 尚未获得真机创建订单授权和验收。当前项目没有自动支付动作、入口或测试;后续支付能力必须另行建单并通过显式能力位、服务端开关、金额上限和人工授权门禁。
|
||||
|
||||
@@ -532,7 +532,9 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
|
||||
|
||||
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
|
||||
|
||||
`openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `RULE_NOT_MATCHED`。
|
||||
`openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `PURCHASE_SPEC_ENTRY_NOT_FOUND`。
|
||||
|
||||
底部购买入口同时保留语义文字锚点和已经验证的最近可点击卡片。评价文字出现在锚点、卡片、祖先或后代上下文时拒绝候选;fresh 点击必须绑定该卡片,卡片由可点击变为不可点击时按节点失效处理,不得继续向更宽父容器漂移。点击后只有识别到规格面板才算成功;若进入具有顶部评价标题及至少两项评价页信号的评价列表,Agent 最多执行一次系统返回,确认回到 PDD 商品页后以 `PURCHASE_SPEC_ENTRY_OPENED_REVIEW / returned_to_product` 失败并停止,不得自动再次点击同一入口。返回失败或无法确认分别使用稳定子原因 `back_failed` / `return_unconfirmed`。入口诊断不保存文字、坐标、控件树或截图。
|
||||
|
||||
Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attempt 和 Outbox;最终结果和 Outbox 在同一事务写入。演练执行器仍在安全点停止。Agent 0.2.0 另外声明 `purchase.live.v1`、`purchase.address-update.v1` 和 `purchase.order-create.v1`,仅在 `live` 规则同时包含受限 `updateShippingAddress`、`createOrder`、`readOrderResult` 动作时启用。
|
||||
|
||||
@@ -556,6 +558,11 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
|
||||
| `PURCHASE_SPEC_CLICK_FAILED` | 精确目标点击失败;`errorMessage` 为稳定子原因 |
|
||||
| `PURCHASE_SPEC_SELECTION_UNCONFIRMED` | 点击成功但无法确认精确选中状态 |
|
||||
| `PURCHASE_SPEC_REPROBE_REJECTED` | 规格探测资格已使用,重复探测被拒绝 |
|
||||
| `PURCHASE_SPEC_ENTRY_NOT_FOUND` | 没有找到安全且唯一的商品规格入口 |
|
||||
| `PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS` | 规格入口候选不唯一 |
|
||||
| `PURCHASE_SPEC_ENTRY_CLICK_FAILED` | 规格入口 fresh 点击失败;`errorMessage` 为稳定子原因 |
|
||||
| `PURCHASE_SPEC_ENTRY_OPENED_REVIEW` | 规格入口误入评价页;`errorMessage` 记录一次安全返回结果 |
|
||||
| `PURCHASE_SPEC_PANEL_NOT_OPENED` | 点击后未识别到商品规格面板 |
|
||||
| `PURCHASE_PRICE_OUT_OF_RANGE` | 当前商品单价超出允许范围 |
|
||||
| `PURCHASE_ADDRESS_UPDATE_FAILED` | 收货地址修改失败,未创建订单 |
|
||||
| `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 |
|
||||
|
||||
Reference in New Issue
Block a user