feat: recover transient PDD sold-out pages (#27)

This commit is contained in:
QiuSW
2026-08-15 17:23:20 +08:00
parent 72d614524b
commit dbeb151f1d
14 changed files with 225 additions and 12 deletions
@@ -184,6 +184,29 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return swipe(SemanticTarget.SPEC_PANEL, direction)
}
override fun pullDownGoodsPage(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
val root = rootInActiveWindow ?: return false
val bounds = Rect().also(root::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2) return false
val centerX = bounds.centerX().toFloat()
val startY = (bounds.top + bounds.height() * 30 / 100).toFloat()
val endY = (bounds.top + bounds.height() * 78 / 100).toFloat()
val path = Path().apply { moveTo(centerX, startY); lineTo(centerX, endY) }
val completed = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queued = dispatchGesture(
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 550)).build(),
object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) { completed.set(true); latch.countDown() }
override fun onCancelled(gestureDescription: GestureDescription?) { latch.countDown() }
},
null,
)
if (!queued) return false
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun swipeNode(node: AccessibilityNodeInfo, direction: SwipeDirection): Boolean {
val bounds = Rect().also(node::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2) return false
@@ -41,6 +41,7 @@ interface PddCollectorDriver {
fun capture(): UiSnapshot
fun clickFresh(target: SnapshotNode): FreshActionResult
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
fun pullDownGoodsPage(): Boolean
}
data class VisibleSpecValue(val text: String, val available: Boolean, val node: SnapshotNode)
@@ -57,7 +58,14 @@ data class ParsedPddScreen(
val pageEvidenceMatched: Boolean,
val problem: PageProblem?,
val sourceNodes: List<SnapshotNode>,
)
) {
fun isTransientSoldOut(exactText: String): Boolean {
val hasExactText = sourceNodes.any { it.visible && it.label.replace(" ", "") == exactText.replace(" ", "") }
val hasNormalEvidence = specPanelOpen || specEntry != null || priceCent != null ||
summary.title != null || summary.shopName != null || summary.salesText != null
return pageEvidenceMatched && hasExactText && !hasNormalEvidence
}
}
object PddScreenParser {
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
@@ -361,6 +369,25 @@ class PddProductDetailCollector(
val deadline = now() + config.timeoutsMs.getValue("overall")
var current = waitFor({ screen -> screen.pageEvidenceMatched }, config.timeoutsMs.getValue("page"), goodsId, config, evidence)
?: return failure("RULE_NOT_MATCHED", "未通过 PDD 商品详情页证据校验")
val recovery = rule.transientSoldOutRecovery
if (recovery?.enabled == true && current.isTransientSoldOut(recovery.exactText)) {
trace("transient-sold-out recovery=start pulls=${recovery.pullDownCount}")
repeat(recovery.pullDownCount) { index ->
if (!driver.pullDownGoodsPage()) {
return failure("PDD_GOODS_SOLD_OUT", "商品页显示已售罄,页面下拉恢复失败")
}
if (index < recovery.pullDownCount - 1) pause(recovery.intervalMs)
}
pause(recovery.settleMs)
current = parse(goodsId, config, evidence)
if (current.isTransientSoldOut(recovery.exactText)) {
return failure("PDD_GOODS_SOLD_OUT", "商品页下拉恢复后仍显示已售罄")
}
if (!current.pageEvidenceMatched) {
return failure("RULE_NOT_MATCHED", "商品页下拉恢复后页面证据失效")
}
trace("transient-sold-out recovery=completed")
}
current.problem?.let { return failure(it.code, it.message) }
var summary = current.summary
var specEntry = current.specEntry
@@ -58,6 +58,15 @@ data class PddCollectorConfig(
val limits: Map<String, Int>,
)
data class TransientSoldOutRecovery(
val enabled: Boolean,
val exactText: String,
val pullDownCount: Int,
val intervalMs: Long,
val settleMs: Long,
val maxAttempts: Int,
)
data class CollectionRule(
val schemaVersion: Int,
val steps: List<RuleStep>,
@@ -65,6 +74,7 @@ data class CollectionRule(
val pageEvidence: PageEvidence? = null,
val hooks: Map<HookStage, List<HookAction>> = emptyMap(),
val collector: PddCollectorConfig? = null,
val transientSoldOutRecovery: TransientSoldOutRecovery? = null,
)
class RuleValidationException(val code: String, message: String) : IllegalArgumentException(message)
@@ -108,7 +118,7 @@ object RuleParser {
}
private fun parseV2(root: JSONObject): CollectionRule {
rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "collector"), "v2 规则")
rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "pageRecovery", "collector"), "v2 规则")
if (root.optString("ruleType") != "pddProductDetail") {
invalid("v2 规则必须声明 ruleType=pddProductDetail")
}
@@ -126,8 +136,27 @@ object RuleParser {
invalid("pageEvidence 必须包含 PDD 包名、精确 Activity 和非空 selector")
}
val hooks = parseHooks(root.optJSONObject("hooks"))
val recovery = parsePageRecovery(root.optJSONObject("pageRecovery"))
val collector = parseCollector(root.optJSONObject("collector") ?: invalid("collector 必填"))
return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector)
return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector, recovery)
}
private fun parsePageRecovery(value: JSONObject?): TransientSoldOutRecovery? {
if (value == null) return null
rejectUnknown(value, setOf("transientSoldOut"), "pageRecovery")
val recovery = value.optJSONObject("transientSoldOut") ?: return null
rejectUnknown(recovery, setOf("enabled", "exactText", "pullDownCount", "intervalMs", "settleMs", "maxAttempts"), "pageRecovery.transientSoldOut")
val exactText = recovery.optString("exactText")
if (exactText != "商品已售罄") invalid("pageRecovery.transientSoldOut.exactText 只能是商品已售罄")
val pullDownCount = recovery.optInt("pullDownCount")
val intervalMs = recovery.optLong("intervalMs")
val settleMs = recovery.optLong("settleMs")
val maxAttempts = recovery.optInt("maxAttempts")
if (pullDownCount !in 1..3) invalid("pageRecovery.transientSoldOut.pullDownCount 必须为 1..3")
if (intervalMs !in 500..2000) invalid("pageRecovery.transientSoldOut.intervalMs 必须为 500..2000")
if (settleMs !in 500..5000) invalid("pageRecovery.transientSoldOut.settleMs 必须为 500..5000")
if (maxAttempts != 1) invalid("pageRecovery.transientSoldOut.maxAttempts 只能为 1")
return TransientSoldOutRecovery(recovery.optBoolean("enabled"), exactText, pullDownCount, intervalMs, settleMs, maxAttempts)
}
private fun parseSteps(items: JSONArray?, defaultPackage: String?, allowPddActions: Boolean): List<RuleStep> {
@@ -11,6 +11,7 @@ import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
import cn.ilapage.goauto.agent.automation.PddScreenParser
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.TransientSoldOutRecovery
import cn.ilapage.goauto.agent.automation.UiSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -18,6 +19,44 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PddProductDetailCollectorTest {
@Test
fun pageLevelSoldOutRequiresExactTextAndNoNormalProductEvidence() {
val soldOut = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("sold-out", "商品已售罄", 200, 700, 880, 800),
))
val parsed = PddScreenParser.parse(soldOut, config(), GOODS_ID, evidence())
assertTrue(parsed.isTransientSoldOut("商品已售罄"))
val skuSoldOut = soldOut.copy(nodes = soldOut.nodes + node("entry", "请选择:颜色分类 尺码", 20, 900, 900, 980, clickable = true))
assertFalse(PddScreenParser.parse(skuSoldOut, config(), GOODS_ID, evidence()).isTransientSoldOut("商品已售罄"))
}
@Test
fun transientSoldOutIsPulledDownTwiceThenCollectionContinues() {
val driver = FakeCollectorDriver(soldOutUntilPulls = 2)
var clock = 0L
val recovery = TransientSoldOutRecovery(true, "商品已售罄", 2, 1000, 2000, 1)
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(recovery = recovery))
assertTrue(result.successful)
assertEquals(2, driver.pullDownCount)
}
@Test
fun persistentSoldOutFailsAfterSingleConfiguredRecoveryRound() {
val driver = FakeCollectorDriver(soldOutUntilPulls = 3)
var clock = 0L
val recovery = TransientSoldOutRecovery(true, "商品已售罄", 2, 1000, 2000, 1)
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(recovery = recovery))
assertFalse(result.successful)
assertEquals("PDD_GOODS_SOLD_OUT", result.code)
assertEquals(2, driver.pullDownCount)
}
@Test
fun parsesSummaryAndMultipleColorRows() {
val driver = FakeCollectorDriver(colors = listOf("红色", "蓝色", "黑色", "白色"), rowSize = 2)
@@ -361,11 +400,13 @@ class PddProductDetailCollectorTest {
private val includeDangerousActions: Boolean = false,
private val rowSize: Int = 4,
private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false,
private val soldOutUntilPulls: Int = 0,
) : PddCollectorDriver {
var captureCount = 0
var clickCount = 0
val clickedLabels = mutableListOf<String>()
val swipes = mutableListOf<Pair<SwipeDirection, SnapshotNode?>>()
var pullDownCount = 0
private var selected: String? = null
private var horizontalPage = 0
private var verticalPage = 0
@@ -373,6 +414,10 @@ class PddProductDetailCollectorTest {
override fun capture(): UiSnapshot {
captureCount++
if (pullDownCount < soldOutUntilPulls) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("sold-out", "商品已售罄", 200, 700, 880, 800),
))
val nodes = mutableListOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("title", "这是一个足够长的拼多多测试商品标题", 20, 100, 1000, 180),
@@ -448,6 +493,8 @@ class PddProductDetailCollectorTest {
}
return true
}
override fun pullDownGoodsPage(): Boolean { pullDownCount++; return true }
}
companion object {
@@ -482,12 +529,13 @@ class PddProductDetailCollectorTest {
mapOf("goodsPageVerticalSwipes" to 1, "specHorizontalSwipes" to 3, "specVerticalSwipes" to 3, "stableEdgeReads" to 1, "stablePriceReads" to 2, "maxSkuCount" to maxSkuCount),
)
private fun rule(config: PddCollectorConfig = config()) = CollectionRule(
private fun rule(config: PddCollectorConfig = config(), recovery: TransientSoldOutRecovery? = null) = CollectionRule(
schemaVersion = 2,
steps = emptyList(),
ruleType = "pddProductDetail",
pageEvidence = evidence(),
collector = config,
transientSoldOutRecovery = recovery,
)
}
}
@@ -118,6 +118,7 @@ class RuleExecutorTest {
assertTrue(result.successful)
assertEquals(2, driver.swipeCount)
assertEquals(2, rule.transientSoldOutRecovery?.pullDownCount)
}
@Test
@@ -135,6 +136,7 @@ class RuleExecutorTest {
"schemaVersion":2,"ruleType":"pddProductDetail",
"navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]},
"pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content"}},
"pageRecovery":{"transientSoldOut":{"enabled":true,"exactText":"商品已售罄","pullDownCount":2,"intervalMs":1000,"settleMs":2000,"maxAttempts":1}},
"hooks":{"afterSpecPanelOpen":[{"action":"swipe","target":"specPanel","direction":"up","count":2,"settleMs":350}]},
"collector":{"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color","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}}
}"""