feat: retry PDD detail navigation once (#28)

This commit is contained in:
QiuSW
2026-08-15 17:44:34 +08:00
parent dbeb151f1d
commit c4e0aa75c8
17 changed files with 204 additions and 29 deletions
@@ -55,3 +55,30 @@ class PddLinkLauncher(private val context: Context) {
)
}
}
class PddDetailEntryRunner(
private val openLink: () -> Boolean,
private val navigate: () -> RuleExecutionResult,
private val collect: () -> PddCollectorResult,
private val pause: (Long) -> Unit = Thread::sleep,
private val trace: (String) -> Unit = {},
) {
fun run(recovery: ReopenBrowserRecovery?): PddCollectorResult {
val attempts = if (recovery?.enabled == true) recovery.maxAttempts + 1 else 1
repeat(attempts) { attempt ->
if (!openLink()) {
return if (attempt == 0) failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
else failure("PDD_DETAIL_ENTRY_FAILED", "重新打开浏览器中的 PDD 链接失败")
}
if (attempt > 0) pause(requireNotNull(recovery).settleMs)
val navigation = navigate()
if (!navigation.successful) return failure(navigation.code, navigation.message)
val result = collect()
if (result.successful || result.code != "PDD_DETAIL_ENTRY_FAILED" || attempt == attempts - 1) return result
trace("detail-entry recovery=reopen-browser attempt=${attempt + 1}")
}
return failure("PDD_DETAIL_ENTRY_FAILED", "未进入 PDD 商品详情页")
}
private fun failure(code: String, message: String) = PddCollectorResult(false, code, message)
}
@@ -368,7 +368,7 @@ class PddProductDetailCollector(
val evidence = rule.pageEvidence ?: return failure("RULE_INVALID", "v2 规则缺少商品页证据")
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 商品详情页证据校验")
?: return failure("PDD_DETAIL_ENTRY_FAILED", "未进入 PDD 商品详情页")
val recovery = rule.transientSoldOutRecovery
if (recovery?.enabled == true && current.isTransientSoldOut(recovery.exactText)) {
trace("transient-sold-out recovery=start pulls=${recovery.pullDownCount}")
@@ -67,6 +67,12 @@ data class TransientSoldOutRecovery(
val maxAttempts: Int,
)
data class ReopenBrowserRecovery(
val enabled: Boolean,
val maxAttempts: Int,
val settleMs: Long,
)
data class CollectionRule(
val schemaVersion: Int,
val steps: List<RuleStep>,
@@ -75,6 +81,7 @@ data class CollectionRule(
val hooks: Map<HookStage, List<HookAction>> = emptyMap(),
val collector: PddCollectorConfig? = null,
val transientSoldOutRecovery: TransientSoldOutRecovery? = null,
val reopenBrowserRecovery: ReopenBrowserRecovery? = null,
)
class RuleValidationException(val code: String, message: String) : IllegalArgumentException(message)
@@ -118,7 +125,7 @@ object RuleParser {
}
private fun parseV2(root: JSONObject): CollectionRule {
rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "pageRecovery", "collector"), "v2 规则")
rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "navigationRecovery", "pageRecovery", "collector"), "v2 规则")
if (root.optString("ruleType") != "pddProductDetail") {
invalid("v2 规则必须声明 ruleType=pddProductDetail")
}
@@ -136,9 +143,22 @@ object RuleParser {
invalid("pageEvidence 必须包含 PDD 包名、精确 Activity 和非空 selector")
}
val hooks = parseHooks(root.optJSONObject("hooks"))
val navigationRecovery = parseNavigationRecovery(root.optJSONObject("navigationRecovery"))
val recovery = parsePageRecovery(root.optJSONObject("pageRecovery"))
val collector = parseCollector(root.optJSONObject("collector") ?: invalid("collector 必填"))
return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector, recovery)
return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector, recovery, navigationRecovery)
}
private fun parseNavigationRecovery(value: JSONObject?): ReopenBrowserRecovery? {
if (value == null) return null
rejectUnknown(value, setOf("reopenBrowser"), "navigationRecovery")
val recovery = value.optJSONObject("reopenBrowser") ?: return null
rejectUnknown(recovery, setOf("enabled", "maxAttempts", "settleMs"), "navigationRecovery.reopenBrowser")
val maxAttempts = recovery.optInt("maxAttempts")
val settleMs = recovery.optLong("settleMs")
if (maxAttempts != 1) invalid("navigationRecovery.reopenBrowser.maxAttempts 只能为 1")
if (settleMs !in 500..5000) invalid("navigationRecovery.reopenBrowser.settleMs 必须为 500..5000")
return ReopenBrowserRecovery(recovery.optBoolean("enabled"), maxAttempts, settleMs)
}
private fun parsePageRecovery(value: JSONObject?): TransientSoldOutRecovery? {
@@ -23,6 +23,7 @@ import cn.ilapage.goauto.agent.automation.CollectionAssembler
import cn.ilapage.goauto.agent.automation.AgentCapabilities
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
import cn.ilapage.goauto.agent.automation.RuleExecutor
import cn.ilapage.goauto.agent.automation.RuleParser
@@ -198,22 +199,25 @@ class AgentForegroundService : Service() {
updateNotification("执行任务 #${task.taskId}")
val accessibility = GoAutoAccessibilityService.instance
?: throw TaskFailure("ACCESSIBILITY_NOT_READY", "GoAuto 无障碍采集服务未开启或尚未绑定")
if (!PddLinkLauncher(this).open(task.urlSnapshot)) {
throw TaskFailure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
}
val rule = try { RuleParser.parse(task.ruleSnapshot) } catch (error: RuleValidationException) {
throw TaskFailure(error.code, error.message ?: "规则快照无效")
}
val execution = RuleExecutor(accessibility).execute(rule)
if (!execution.successful) throw TaskFailure(execution.code, execution.message)
val result = if (rule.schemaVersion == 2) {
val collection = PddProductDetailCollector(
accessibility,
trace = { message -> Log.i("GoAutoCollector", message) },
).collect(task.goodsIdSnapshot, rule)
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
val collection = PddDetailEntryRunner(
openLink = { PddLinkLauncher(this).open(task.urlSnapshot) },
navigate = { RuleExecutor(accessibility).execute(rule) },
collect = { PddProductDetailCollector(accessibility, trace = trace).collect(task.goodsIdSnapshot, rule) },
trace = trace,
).run(rule.reopenBrowserRecovery)
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
requireNotNull(collection.payload)
} else {
if (!PddLinkLauncher(this).open(task.urlSnapshot)) {
throw TaskFailure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
}
val execution = RuleExecutor(accessibility).execute(rule)
if (!execution.successful) throw TaskFailure(execution.code, execution.message)
CollectionAssembler.assemble(task.goodsIdSnapshot, execution.extracted)
}
api.submitResult(task.taskId, UUID.randomUUID().toString(), result, token)
@@ -1,6 +1,10 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PddPageClassifier
import cn.ilapage.goauto.agent.automation.PddCollectorResult
import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner
import cn.ilapage.goauto.agent.automation.ReopenBrowserRecovery
import cn.ilapage.goauto.agent.automation.RuleExecutionResult
import cn.ilapage.goauto.agent.automation.RuleParser
import cn.ilapage.goauto.agent.automation.RuleValidationException
import org.junit.Assert.assertEquals
@@ -8,6 +12,60 @@ import org.junit.Assert.assertNull
import org.junit.Test
class PddNavigationTest {
@Test
fun `reopens browser once after detail entry failure then succeeds`() {
var opens = 0
var collects = 0
var paused = 0L
val result = PddDetailEntryRunner(
openLink = { opens++; true },
navigate = { RuleExecutionResult(true, "OK", "ok") },
collect = {
collects++
if (collects == 1) PddCollectorResult(false, "PDD_DETAIL_ENTRY_FAILED", "home")
else PddCollectorResult(true, "OK", "detail")
},
pause = { paused += it },
).run(ReopenBrowserRecovery(true, 1, 1000))
assertEquals(true, result.successful)
assertEquals(2, opens)
assertEquals(2, collects)
assertEquals(1000L, paused)
}
@Test
fun `does not reopen browser for login or when recovery disabled`() {
listOf(
ReopenBrowserRecovery(true, 1, 1000) to "PDD_LOGIN_REQUIRED",
ReopenBrowserRecovery(false, 1, 1000) to "PDD_DETAIL_ENTRY_FAILED",
).forEach { (recovery, code) ->
var opens = 0
val result = PddDetailEntryRunner(
openLink = { opens++; true },
navigate = { RuleExecutionResult(true, "OK", "ok") },
collect = { PddCollectorResult(false, code, "failed") },
pause = {},
).run(recovery)
assertEquals(code, result.code)
assertEquals(1, opens)
}
}
@Test
fun `persistent detail entry failure stops after one recovery`() {
var opens = 0
val result = PddDetailEntryRunner(
openLink = { opens++; true },
navigate = { RuleExecutionResult(true, "OK", "ok") },
collect = { PddCollectorResult(false, "PDD_DETAIL_ENTRY_FAILED", "home") },
pause = {},
).run(ReopenBrowserRecovery(true, 1, 1000))
assertEquals("PDD_DETAIL_ENTRY_FAILED", result.code)
assertEquals(2, opens)
}
@Test
fun `classifies login captcha risk and invalid link`() {
assertEquals("PDD_LOGIN_REQUIRED", PddPageClassifier.classify(PDD, null, listOf("手机号登录", "登录后继续"))?.code)
@@ -119,6 +119,7 @@ class RuleExecutorTest {
assertTrue(result.successful)
assertEquals(2, driver.swipeCount)
assertEquals(2, rule.transientSoldOutRecovery?.pullDownCount)
assertEquals(1, rule.reopenBrowserRecovery?.maxAttempts)
}
@Test
@@ -136,6 +137,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"}},
"navigationRecovery":{"reopenBrowser":{"enabled":true,"maxAttempts":1,"settleMs":1000}},
"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}}