feat: retry PDD detail navigation once (#28)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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? {
|
||||
|
||||
+13
-9
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user