feat: recover transient PDD sold-out pages (#27)
This commit is contained in:
+23
@@ -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
|
||||
|
||||
+28
-1
@@ -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}}
|
||||
}"""
|
||||
|
||||
Reference in New Issue
Block a user