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}}
|
||||
|
||||
@@ -32,4 +32,4 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前 MVP 的 T01~T07、T09~T22 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T23 已完成 v2 一加真机实施验证,正在等待用户验收。T24 已完成规格面板锚定滚动、逐行蛇形颜色遍历和尺码续页实现,并以商品 `236231603269` 验证 14 色、8 尺码和 112 个 SKU,正在等待用户验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
|
||||
当前 MVP 的 T01~T07、T09~T22 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T23 已完成 v2 一加真机实施验证,正在等待用户验收。T24 已完成规格面板锚定滚动、逐行蛇形颜色遍历和尺码续页实现,并以商品 `236231603269` 验证 14 色、8 尺码和 112 个 SKU,正在等待用户验收。T25 已实现商品页假售罄的一次性下拉恢复,T26 已实现未进入商品详情页时的一次性浏览器重开恢复,均正在等待用户验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。
|
||||
- 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。
|
||||
- v2 规则使用类型化动作和固定阶段钩子。已由 Agent 支持的选择器、别名、超时、滑动方向和有限次数可以只更新规则;新增动作类型或页面算法才需要升级 Agent。
|
||||
- v2 任务首次未进入精确商品详情页时,可以按规则显式重开同一浏览器 URL 一次;登录、验证码、风控、无效链接和详情页内采集失败不触发该恢复。
|
||||
- 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。
|
||||
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则未来可以使用独立的创建订单能力,任何规则都不能付款。
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ v2 完整示例见 [PDD 商品详情规则](rules/pdd-product-detail-v2.proposed
|
||||
- `collector.collectorId: pddProductDetailV1`:引用 Agent 中经过测试的类型化能力,不下发可执行代码。
|
||||
- `hooks.afterSpecPanelOpen`:固定阶段的安全动作;当前只允许对语义目标 `specPanel` 执行 `swipe`,方向为上下左右、单动作次数 1~5、等待 0~2000 毫秒,单阶段最多 8 个动作。
|
||||
- `pageRecovery.transientSoldOut`:商品详情页精确显示“商品已售罄”且缺少正常商品证据时,允许对商品页执行一次有界恢复;默认下拉 2 次、间隔 1000 毫秒。规格面板内的 SKU 售罄不触发。恢复后仍售罄时失败码为 `PDD_GOODS_SOLD_OUT`。
|
||||
- `navigationRecovery.reopenBrowser`:首次跳转未通过精确商品详情页证据时,Agent 重新显式打开任务 `urlSnapshot` 并重放安全导航步骤;最多恢复 1 次,重开后等待 500~5000 毫秒。登录、验证码、风控、无效链接和进入详情页后的采集失败不触发。
|
||||
- `collector.dimensionAliases`、`timeoutsMs` 和 `limits`:分别管理规格标题别名、超时和遍历/SKU 上限。
|
||||
|
||||
例如规格面板打开后向上滑动两次,只修改规则:
|
||||
@@ -270,6 +271,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
| `PDD_RISK_CONTROL` | 风控页面 | 否 |
|
||||
| `PDD_LINK_INVALID` | 商品链接无效、商品不存在或已下架 | 否 |
|
||||
| `PDD_GOODS_SOLD_OUT` | 商品页一次性下拉恢复后仍显示商品已售罄,或恢复动作失败 | 否 |
|
||||
| `PDD_DETAIL_ENTRY_FAILED` | 一次性重开浏览器后仍未进入精确 PDD 商品详情页 | 否 |
|
||||
| `RULE_NOT_MATCHED` | 找不到唯一控件或页面 | 否 |
|
||||
| `RULE_AMBIGUOUS` | 规则同时匹配多个控件 | 否 |
|
||||
| `TASK_ALREADY_CLAIMED` | 未指定任务已被其他设备领取 | 否 |
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
| T23 | [#25](https://git.ilapage.cn/OPC/goauto/issues/25) | v2 一加真机验收 | T20~T22 |
|
||||
| T24 | [#26](https://git.ilapage.cn/OPC/goauto/issues/26) | 修复 PDD 规格面板蛇形遍历与尺码续页 | T20~T23 |
|
||||
| T25 | [#27](https://git.ilapage.cn/OPC/goauto/issues/27) | PDD 假售罄页面一次性下拉恢复 | T20~T24 |
|
||||
| T26 | [#28](https://git.ilapage.cn/OPC/goauto/issues/28) | PDD 商品详情进入失败后一次性重开浏览器恢复 | T20~T25 |
|
||||
|
||||
## 延期
|
||||
|
||||
|
||||
@@ -45,6 +45,13 @@
|
||||
"maxAttempts": 1
|
||||
}
|
||||
},
|
||||
"navigationRecovery": {
|
||||
"reopenBrowser": {
|
||||
"enabled": true,
|
||||
"maxAttempts": 1,
|
||||
"settleMs": 1000
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"afterSpecPanelOpen": []
|
||||
},
|
||||
|
||||
@@ -63,14 +63,15 @@ type IntRange struct {
|
||||
Max int `json:"max"`
|
||||
}
|
||||
type TemplateConstraints struct {
|
||||
BrowserPackages []string `json:"browserPackages"`
|
||||
HookDirections []string `json:"hookDirections"`
|
||||
AliasCount IntRange `json:"aliasCount"`
|
||||
AliasLength IntRange `json:"aliasLength"`
|
||||
TimeoutsMS map[string]IntRange `json:"timeoutsMs"`
|
||||
Limits map[string]IntRange `json:"limits"`
|
||||
Swipe map[string]IntRange `json:"swipe"`
|
||||
PageRecovery map[string]IntRange `json:"pageRecovery"`
|
||||
BrowserPackages []string `json:"browserPackages"`
|
||||
HookDirections []string `json:"hookDirections"`
|
||||
AliasCount IntRange `json:"aliasCount"`
|
||||
AliasLength IntRange `json:"aliasLength"`
|
||||
TimeoutsMS map[string]IntRange `json:"timeoutsMs"`
|
||||
Limits map[string]IntRange `json:"limits"`
|
||||
Swipe map[string]IntRange `json:"swipe"`
|
||||
PageRecovery map[string]IntRange `json:"pageRecovery"`
|
||||
NavigationRecovery map[string]IntRange `json:"navigationRecovery"`
|
||||
}
|
||||
type TemplateResponse struct {
|
||||
TemplateID string `json:"templateId"`
|
||||
@@ -110,6 +111,9 @@ func (service *Service) Template(templateID string) (TemplateResponse, error) {
|
||||
"pullDownCount": {Min: 1, Max: 3}, "intervalMs": {Min: 500, Max: 2000},
|
||||
"settleMs": {Min: 500, Max: 5000}, "maxAttempts": {Min: 1, Max: 1},
|
||||
},
|
||||
NavigationRecovery: map[string]IntRange{
|
||||
"maxAttempts": {Min: 1, Max: 1}, "settleMs": {Min: 500, Max: 5000},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -84,6 +84,16 @@ type pageRecovery struct {
|
||||
TransientSoldOut *transientSoldOutRecovery `json:"transientSoldOut,omitempty"`
|
||||
}
|
||||
|
||||
type reopenBrowserRecovery struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
SettleMS int `json:"settleMs"`
|
||||
}
|
||||
|
||||
type navigationRecovery struct {
|
||||
ReopenBrowser *reopenBrowserRecovery `json:"reopenBrowser,omitempty"`
|
||||
}
|
||||
|
||||
type v2Rule struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
RuleType string `json:"ruleType"`
|
||||
@@ -95,9 +105,10 @@ type v2Rule struct {
|
||||
ActivityName string `json:"activityName"`
|
||||
Selector selector `json:"selector"`
|
||||
} `json:"pageEvidence"`
|
||||
Hooks map[string][]hookAction `json:"hooks,omitempty"`
|
||||
PageRecovery *pageRecovery `json:"pageRecovery,omitempty"`
|
||||
Collector collectorConfig `json:"collector"`
|
||||
Hooks map[string][]hookAction `json:"hooks,omitempty"`
|
||||
NavigationRecovery *navigationRecovery `json:"navigationRecovery,omitempty"`
|
||||
PageRecovery *pageRecovery `json:"pageRecovery,omitempty"`
|
||||
Collector collectorConfig `json:"collector"`
|
||||
}
|
||||
|
||||
// Validate accepts the legacy v1 shape and strictly validates the v2 product
|
||||
@@ -254,6 +265,15 @@ func parseV2(raw []byte) (v2Rule, error) {
|
||||
return rule, errors.New("pageRecovery.transientSoldOut.maxAttempts 只能为 1")
|
||||
}
|
||||
}
|
||||
if rule.NavigationRecovery != nil && rule.NavigationRecovery.ReopenBrowser != nil {
|
||||
recovery := rule.NavigationRecovery.ReopenBrowser
|
||||
if recovery.MaxAttempts != 1 {
|
||||
return rule, errors.New("navigationRecovery.reopenBrowser.maxAttempts 只能为 1")
|
||||
}
|
||||
if recovery.SettleMS < 500 || recovery.SettleMS > 5000 {
|
||||
return rule, errors.New("navigationRecovery.reopenBrowser.settleMs 必须为 500..5000")
|
||||
}
|
||||
}
|
||||
if rule.Collector.CollectorID != "pddProductDetailV1" || rule.Collector.SpecEntryStrategy != "safeBottomSpecEntryV1" || rule.Collector.PriceParser != "pddRmbPriceV1" || rule.Collector.PriceGranularity != "color" {
|
||||
return rule, errors.New("collector 使用了 Agent 不支持的类型化能力")
|
||||
}
|
||||
|
||||
@@ -86,3 +86,19 @@ func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ValidatesReopenBrowserRecovery(t *testing.T) {
|
||||
base := validV2(`[]`)
|
||||
valid := strings.Replace(base, `"hooks"`, `"navigationRecovery":{"reopenBrowser":{"enabled":true,"maxAttempts":1,"settleMs":1000}},"hooks"`, 1)
|
||||
if err := Validate([]byte(valid)); err != nil {
|
||||
t.Fatalf("valid navigation recovery rejected: %v", err)
|
||||
}
|
||||
for _, invalid := range []string{
|
||||
strings.Replace(valid, `"maxAttempts":1`, `"maxAttempts":2`, 1),
|
||||
strings.Replace(valid, `"settleMs":1000`, `"settleMs":100`, 1),
|
||||
} {
|
||||
if err := Validate([]byte(invalid)); err == nil {
|
||||
t.Fatalf("invalid navigation recovery accepted: %s", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ var pddProductDetailTemplate = json.RawMessage(`{
|
||||
{"id":"system-open","page":"system-confirm","packageName":"android","action":"click","selector":{"text":"打开"},"timeoutMs":8000,"optional":true}
|
||||
]},
|
||||
"pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content","className":"android.widget.FrameLayout"}},
|
||||
"navigationRecovery":{"reopenBrowser":{"enabled":true,"maxAttempts":1,"settleMs":1000}},
|
||||
"pageRecovery":{"transientSoldOut":{"enabled":true,"exactText":"商品已售罄","pullDownCount":2,"intervalMs":1000,"settleMs":2000,"maxAttempts":1}},
|
||||
"hooks":{"afterSpecPanelOpen":[]},
|
||||
"collector":{
|
||||
|
||||
@@ -76,6 +76,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-labelledby="navigation-recovery-title">
|
||||
<div class="section-heading"><div><h3 id="navigation-recovery-title">商品详情进入恢复</h3><p>首次跳转落在 PDD 首页等非详情页时,重新用浏览器打开同一商品链接一次。</p></div><el-switch v-model="form.navigationRecovery.enabled" active-text="启用" inactive-text="关闭" /></div>
|
||||
<div v-if="form.navigationRecovery.enabled" class="form-grid two-columns">
|
||||
<el-form-item label="最大恢复次数"><el-input-number :model-value="1" :min="1" :max="1" disabled controls-position="right" /></el-form-item>
|
||||
<el-form-item label="重开后等待(毫秒)"><el-input-number v-model="form.navigationRecovery.settleMs" :min="range('navigationRecovery', 'settleMs').min" :max="range('navigationRecovery', 'settleMs').max" :step="100" controls-position="right" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-collapse class="json-collapse"><el-collapse-item title="查看只读 JSON 预览" name="preview"><pre class="json-preview-block" tabindex="0">{{ previewText }}</pre></el-collapse-item></el-collapse>
|
||||
</template>
|
||||
|
||||
@@ -98,7 +106,8 @@ const defaultConstraints = {
|
||||
timeoutsMs: { page: { min: 100, max: 60000 }, specPanel: { min: 100, max: 30000 }, selection: { min: 100, max: 10000 }, price: { min: 100, max: 10000 }, overall: { min: 1000, max: 600000 }},
|
||||
limits: { goodsPageVerticalSwipes: { min: 0, max: 10 }, specHorizontalSwipes: { min: 0, max: 30 }, specVerticalSwipes: { min: 0, max: 30 }, stableEdgeReads: { min: 1, max: 5 }, stablePriceReads: { min: 2, max: 5 }, maxSkuCount: { min: 1, max: 2000 }},
|
||||
swipe: { count: { min: 1, max: 5 }, settleMs: { min: 0, max: 2000 }},
|
||||
pageRecovery: { pullDownCount: { min: 1, max: 3 }, intervalMs: { min: 500, max: 2000 }, settleMs: { min: 500, max: 5000 }, maxAttempts: { min: 1, max: 1 }}
|
||||
pageRecovery: { pullDownCount: { min: 1, max: 3 }, intervalMs: { min: 500, max: 2000 }, settleMs: { min: 500, max: 5000 }, maxAttempts: { min: 1, max: 1 }},
|
||||
navigationRecovery: { maxAttempts: { min: 1, max: 1 }, settleMs: { min: 500, max: 5000 }}
|
||||
}
|
||||
const timeoutFields = [{ key: 'page', label: '商品页', step: 1000 }, { key: 'specPanel', label: '规格面板', step: 500 }, { key: 'selection', label: '选中确认', step: 100 }, { key: 'price', label: '价格稳定', step: 100 }, { key: 'overall', label: '任务总时长', step: 5000 }]
|
||||
const limitFields = [{ key: 'goodsPageVerticalSwipes', label: '商品页纵向滑动' }, { key: 'specHorizontalSwipes', label: '规格横向滑动' }, { key: 'specVerticalSwipes', label: '规格纵向滑动' }, { key: 'stableEdgeReads', label: '边界稳定读取' }, { key: 'stablePriceReads', label: '价格稳定读取' }, { key: 'maxSkuCount', label: '最大 SKU 数量' }]
|
||||
@@ -140,16 +149,16 @@ export default {
|
||||
},
|
||||
created() { this.form = this.emptyForm(); this.getList() },
|
||||
methods: {
|
||||
emptyForm() { return { mode: 'v2', name: '', contentText: '', browserPackage: '', activityName: '', colorAliases: [], sizeAliases: [], timeouts: {}, limits: {}, hook: { enabled: false, direction: 'up', count: 1, settleMs: 350 }, recovery: { enabled: false, pullDownCount: 2, intervalMs: 1000, settleMs: 2000 }} },
|
||||
emptyForm() { return { mode: 'v2', name: '', contentText: '', browserPackage: '', activityName: '', colorAliases: [], sizeAliases: [], timeouts: {}, limits: {}, hook: { enabled: false, direction: 'up', count: 1, settleMs: 350 }, recovery: { enabled: false, pullDownCount: 2, intervalMs: 1000, settleMs: 2000 }, navigationRecovery: { enabled: false, settleMs: 1000 }} },
|
||||
clone(value) { return JSON.parse(JSON.stringify(value)) },
|
||||
range(group, key) { return this.constraints[group]?.[key] || { min: 0, max: 999999 } },
|
||||
async ensureTemplate() { if (this.serverTemplate) return; const response = await getCollectionRuleTemplate(); this.serverTemplate = this.clone(response.data.content); this.constraints = response.data.constraints || defaultConstraints },
|
||||
fillV2(content, name = '') { this.contentBase = this.clone(content); const hook = content.hooks?.afterSpecPanelOpen?.[0]; const recovery = content.pageRecovery?.transientSoldOut; this.form = { mode: 'v2', name, contentText: '', browserPackage: content.navigation.steps[0].packageName, activityName: content.pageEvidence.activityName, colorAliases: [...content.collector.dimensionAliases.color], sizeAliases: [...content.collector.dimensionAliases.size], timeouts: { ...content.collector.timeoutsMs }, limits: { ...content.collector.limits }, hook: { enabled: Boolean(hook), direction: hook?.direction || 'up', count: hook?.count || 1, settleMs: hook?.settleMs ?? 350 }, recovery: { enabled: Boolean(recovery?.enabled), pullDownCount: recovery?.pullDownCount || 2, intervalMs: recovery?.intervalMs || 1000, settleMs: recovery?.settleMs || 2000 }} },
|
||||
buildV2Content() { if (!this.contentBase) return {}; const content = this.clone(this.contentBase); content.navigation.steps[0].packageName = this.form.browserPackage; content.pageEvidence.activityName = this.form.activityName.trim(); content.collector.dimensionAliases.color = this.form.colorAliases.map(item => item.trim()).filter(Boolean); content.collector.dimensionAliases.size = this.form.sizeAliases.map(item => item.trim()).filter(Boolean); content.collector.timeoutsMs = { ...this.form.timeouts }; content.collector.limits = { ...this.form.limits }; content.hooks.afterSpecPanelOpen = this.form.hook.enabled ? [{ action: 'swipe', target: 'specPanel', direction: this.form.hook.direction, count: this.form.hook.count, settleMs: this.form.hook.settleMs }] : []; content.pageRecovery = { transientSoldOut: { enabled: this.form.recovery.enabled, exactText: '商品已售罄', pullDownCount: this.form.recovery.pullDownCount, intervalMs: this.form.recovery.intervalMs, settleMs: this.form.recovery.settleMs, maxAttempts: 1 }}; return content },
|
||||
fillV2(content, name = '') { this.contentBase = this.clone(content); const hook = content.hooks?.afterSpecPanelOpen?.[0]; const recovery = content.pageRecovery?.transientSoldOut; const navigationRecovery = content.navigationRecovery?.reopenBrowser; this.form = { mode: 'v2', name, contentText: '', browserPackage: content.navigation.steps[0].packageName, activityName: content.pageEvidence.activityName, colorAliases: [...content.collector.dimensionAliases.color], sizeAliases: [...content.collector.dimensionAliases.size], timeouts: { ...content.collector.timeoutsMs }, limits: { ...content.collector.limits }, hook: { enabled: Boolean(hook), direction: hook?.direction || 'up', count: hook?.count || 1, settleMs: hook?.settleMs ?? 350 }, recovery: { enabled: Boolean(recovery?.enabled), pullDownCount: recovery?.pullDownCount || 2, intervalMs: recovery?.intervalMs || 1000, settleMs: recovery?.settleMs || 2000 }, navigationRecovery: { enabled: Boolean(navigationRecovery?.enabled), settleMs: navigationRecovery?.settleMs || 1000 }} },
|
||||
buildV2Content() { if (!this.contentBase) return {}; const content = this.clone(this.contentBase); content.navigation.steps[0].packageName = this.form.browserPackage; content.pageEvidence.activityName = this.form.activityName.trim(); content.collector.dimensionAliases.color = this.form.colorAliases.map(item => item.trim()).filter(Boolean); content.collector.dimensionAliases.size = this.form.sizeAliases.map(item => item.trim()).filter(Boolean); content.collector.timeoutsMs = { ...this.form.timeouts }; content.collector.limits = { ...this.form.limits }; content.hooks.afterSpecPanelOpen = this.form.hook.enabled ? [{ action: 'swipe', target: 'specPanel', direction: this.form.hook.direction, count: this.form.hook.count, settleMs: this.form.hook.settleMs }] : []; content.pageRecovery = { transientSoldOut: { enabled: this.form.recovery.enabled, exactText: '商品已售罄', pullDownCount: this.form.recovery.pullDownCount, intervalMs: this.form.recovery.intervalMs, settleMs: this.form.recovery.settleMs, maxAttempts: 1 }}; content.navigationRecovery = { reopenBrowser: { enabled: this.form.navigationRecovery.enabled, maxAttempts: 1, settleMs: this.form.navigationRecovery.settleMs }}; return content },
|
||||
async getList() { this.loading = true; try { const response = await listCollectionRules(this.query); this.rulesList = response.data.items; this.total = response.data.total } finally { this.loading = false } },
|
||||
handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, name: '' }; this.getList() },
|
||||
ruleType(content) { return content.schemaVersion === 2 ? 'PDD 商品详情 v2' : `旧版 v${content.schemaVersion || '?'}` },
|
||||
summary(content) { if (content.schemaVersion !== 2) { const text = JSON.stringify(content); return text.length > 100 ? `${text.slice(0, 100)}…` : text } const aliases = content.collector?.dimensionAliases; const hookCount = content.hooks?.afterSpecPanelOpen?.[0]?.count || 0; const recovery = content.pageRecovery?.transientSoldOut?.enabled ? '假售罄恢复已启用' : '假售罄恢复关闭'; return `颜色别名 ${aliases?.color?.length || 0} · 尺码别名 ${aliases?.size?.length || 0} · 附加滑动 ${hookCount} 次 · ${recovery}` },
|
||||
summary(content) { if (content.schemaVersion !== 2) { const text = JSON.stringify(content); return text.length > 100 ? `${text.slice(0, 100)}…` : text } const aliases = content.collector?.dimensionAliases; const hookCount = content.hooks?.afterSpecPanelOpen?.[0]?.count || 0; const recovery = content.pageRecovery?.transientSoldOut?.enabled ? '假售罄恢复已启用' : '假售罄恢复关闭'; const navigationRecovery = content.navigationRecovery?.reopenBrowser?.enabled ? '入口恢复已启用' : '入口恢复关闭'; return `颜色别名 ${aliases?.color?.length || 0} · 尺码别名 ${aliases?.size?.length || 0} · 附加滑动 ${hookCount} 次 · ${recovery} · ${navigationRecovery}` },
|
||||
async openCreate() { this.dialog = { open: true, loading: true, saving: false, ruleId: null }; try { await this.ensureTemplate(); this.fillV2(this.serverTemplate, 'PDD 商品详情采集') } finally { this.dialog.loading = false; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) } },
|
||||
async openEdit(row) { this.dialog = { open: true, loading: true, saving: false, ruleId: row.id }; try { if (row.content.schemaVersion === 2) { await this.ensureTemplate(); this.fillV2(row.content, row.name) } else { this.contentBase = null; this.form = { ...this.emptyForm(), mode: 'v1', name: row.name, contentText: JSON.stringify(row.content, null, 2) } } } finally { this.dialog.loading = false; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) } },
|
||||
async save() { const valid = await this.$refs.ruleForm.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const content = this.form.mode === 'v2' ? this.buildV2Content() : JSON.parse(this.form.contentText); const payload = { requestId: window.crypto.randomUUID(), name: this.form.name.trim(), content }; try { if (this.dialog.ruleId) await updateCollectionRule(this.dialog.ruleId, payload); else await createCollectionRule(payload); ElMessage.success(this.dialog.ruleId ? '规则已更新,新任务将使用新内容' : '规则已创建并立即生效'); this.dialog.open = false; await this.getList() } finally { this.dialog.saving = false } },
|
||||
|
||||
@@ -8,6 +8,7 @@ const template = {
|
||||
{ id: 'system-open', page: 'system-confirm', packageName: 'android', action: 'click', selector: { text: '打开' }, timeoutMs: 8000, optional: true },
|
||||
] },
|
||||
pageEvidence: { packageName: 'com.xunmeng.pinduoduo', activityName: 'com.xunmeng.pinduoduo.activity.NewPageActivity', selector: { resourceId: 'android:id/content', className: 'android.widget.FrameLayout' } },
|
||||
navigationRecovery: { reopenBrowser: { enabled: true, maxAttempts: 1, settleMs: 1000 } },
|
||||
pageRecovery: { transientSoldOut: { enabled: true, exactText: '商品已售罄', pullDownCount: 2, intervalMs: 1000, settleMs: 2000, maxAttempts: 1 } },
|
||||
hooks: { afterSpecPanelOpen: [] },
|
||||
collector: {
|
||||
@@ -34,11 +35,13 @@ test('v2 rule uses safe form and read-only JSON preview', async ({ page, context
|
||||
await expect(page.getByRole('heading', { name: '超时设置' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '遍历上限' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '商品页假售罄恢复' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '商品详情进入恢复' })).toBeVisible()
|
||||
await expect(page.getByText('固定能力 v1')).toBeVisible()
|
||||
await expect(page.locator('.json-editor')).toHaveCount(0)
|
||||
await page.getByText('查看只读 JSON 预览').click()
|
||||
await expect(page.locator('.json-preview-block')).toContainText('pddProductDetailV1')
|
||||
await expect(page.locator('.json-preview-block')).toContainText('商品已售罄')
|
||||
await expect(page.locator('.json-preview-block')).toContainText('reopenBrowser')
|
||||
await expect(page.locator('.json-preview-block')).not.toContainText('提交订单')
|
||||
await expect(page.getByRole('button', { name: '保存并立即生效' })).toBeEnabled()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user