From dbeb151f1ddcb94068e73e3c131c81d74cbb596c Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 17:23:20 +0800 Subject: [PATCH] feat: recover transient PDD sold-out pages (#27) --- .../automation/GoAutoAccessibilityService.kt | 23 +++++++++ .../automation/PddProductDetailCollector.kt | 29 ++++++++++- .../goauto/agent/automation/RuleContract.kt | 33 +++++++++++- .../agent/PddProductDetailCollectorTest.kt | 50 ++++++++++++++++++- .../ilapage/goauto/agent/RuleExecutorTest.kt | 2 + docs/08-agent-api-contract.md | 2 + docs/09-delivery-issues.md | 1 + .../rules/pdd-product-detail-v2.proposed.json | 10 ++++ server/app/goauto/rule/service.go | 5 ++ server/app/goauto/rulecontract/contract.go | 39 ++++++++++++++- .../app/goauto/rulecontract/contract_test.go | 19 ++++++- server/app/goauto/rulecontract/template.go | 1 + .../views/goauto/collection-rules/index.vue | 20 ++++++-- web/tests/e2e/collection-rule-editor.spec.ts | 3 ++ 14 files changed, 225 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index fb46de7..1e2254a 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -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 diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index 7c3c2bb..b971f60 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -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, -) +) { + 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 diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt index fa607d9..6c1ac9d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt @@ -58,6 +58,15 @@ data class PddCollectorConfig( val limits: Map, ) +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, @@ -65,6 +74,7 @@ data class CollectionRule( val pageEvidence: PageEvidence? = null, val hooks: Map> = 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 { diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index 8b70efa..de10140 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -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() val swipes = mutableListOf>() + 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, ) } } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt index ebb8f52..7c2f655 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt @@ -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}} }""" diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 211ba31..6ebcbf1 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -73,6 +73,7 @@ v2 完整示例见 [PDD 商品详情规则](rules/pdd-product-detail-v2.proposed - `pageEvidence`:精确的 PDD 包名、Activity 和非空节点证据。 - `collector.collectorId: pddProductDetailV1`:引用 Agent 中经过测试的类型化能力,不下发可执行代码。 - `hooks.afterSpecPanelOpen`:固定阶段的安全动作;当前只允许对语义目标 `specPanel` 执行 `swipe`,方向为上下左右、单动作次数 1~5、等待 0~2000 毫秒,单阶段最多 8 个动作。 +- `pageRecovery.transientSoldOut`:商品详情页精确显示“商品已售罄”且缺少正常商品证据时,允许对商品页执行一次有界恢复;默认下拉 2 次、间隔 1000 毫秒。规格面板内的 SKU 售罄不触发。恢复后仍售罄时失败码为 `PDD_GOODS_SOLD_OUT`。 - `collector.dimensionAliases`、`timeoutsMs` 和 `limits`:分别管理规格标题别名、超时和遍历/SKU 上限。 例如规格面板打开后向上滑动两次,只修改规则: @@ -268,6 +269,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `PDD_CAPTCHA_REQUIRED` | 验证码或人机验证 | 否 | | `PDD_RISK_CONTROL` | 风控页面 | 否 | | `PDD_LINK_INVALID` | 商品链接无效、商品不存在或已下架 | 否 | +| `PDD_GOODS_SOLD_OUT` | 商品页一次性下拉恢复后仍显示商品已售罄,或恢复动作失败 | 否 | | `RULE_NOT_MATCHED` | 找不到唯一控件或页面 | 否 | | `RULE_AMBIGUOUS` | 规则同时匹配多个控件 | 否 | | `TASK_ALREADY_CLAIMED` | 未指定任务已被其他设备领取 | 否 | diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index ccf0605..3bf33c5 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -32,6 +32,7 @@ | T22 | [#24](https://git.ilapage.cn/OPC/goauto/issues/24) | 内置规则模板与管理端校验 | T20、T21 | | 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 | ## 延期 diff --git a/docs/rules/pdd-product-detail-v2.proposed.json b/docs/rules/pdd-product-detail-v2.proposed.json index 65581d1..d81b78a 100644 --- a/docs/rules/pdd-product-detail-v2.proposed.json +++ b/docs/rules/pdd-product-detail-v2.proposed.json @@ -35,6 +35,16 @@ "className": "android.widget.FrameLayout" } }, + "pageRecovery": { + "transientSoldOut": { + "enabled": true, + "exactText": "商品已售罄", + "pullDownCount": 2, + "intervalMs": 1000, + "settleMs": 2000, + "maxAttempts": 1 + } + }, "hooks": { "afterSpecPanelOpen": [] }, diff --git a/server/app/goauto/rule/service.go b/server/app/goauto/rule/service.go index 3e4ad9d..af7703f 100644 --- a/server/app/goauto/rule/service.go +++ b/server/app/goauto/rule/service.go @@ -70,6 +70,7 @@ type TemplateConstraints struct { TimeoutsMS map[string]IntRange `json:"timeoutsMs"` Limits map[string]IntRange `json:"limits"` Swipe map[string]IntRange `json:"swipe"` + PageRecovery map[string]IntRange `json:"pageRecovery"` } type TemplateResponse struct { TemplateID string `json:"templateId"` @@ -105,6 +106,10 @@ func (service *Service) Template(templateID string) (TemplateResponse, error) { "stablePriceReads": {Min: 2, Max: 5}, "maxSkuCount": {Min: 1, Max: 2000}, }, Swipe: map[string]IntRange{"count": {Min: 1, Max: 5}, "settleMs": {Min: 0, Max: 2000}}, + PageRecovery: map[string]IntRange{ + "pullDownCount": {Min: 1, Max: 3}, "intervalMs": {Min: 500, Max: 2000}, + "settleMs": {Min: 500, Max: 5000}, "maxAttempts": {Min: 1, Max: 1}, + }, }, }, nil } diff --git a/server/app/goauto/rulecontract/contract.go b/server/app/goauto/rulecontract/contract.go index 85410ae..f7fa013 100644 --- a/server/app/goauto/rulecontract/contract.go +++ b/server/app/goauto/rulecontract/contract.go @@ -71,6 +71,19 @@ type collectorConfig struct { Limits map[string]int `json:"limits"` } +type transientSoldOutRecovery struct { + Enabled bool `json:"enabled"` + ExactText string `json:"exactText"` + PullDownCount int `json:"pullDownCount"` + IntervalMS int `json:"intervalMs"` + SettleMS int `json:"settleMs"` + MaxAttempts int `json:"maxAttempts"` +} + +type pageRecovery struct { + TransientSoldOut *transientSoldOutRecovery `json:"transientSoldOut,omitempty"` +} + type v2Rule struct { SchemaVersion int `json:"schemaVersion"` RuleType string `json:"ruleType"` @@ -82,8 +95,9 @@ type v2Rule struct { ActivityName string `json:"activityName"` Selector selector `json:"selector"` } `json:"pageEvidence"` - Hooks map[string][]hookAction `json:"hooks,omitempty"` - Collector collectorConfig `json:"collector"` + Hooks map[string][]hookAction `json:"hooks,omitempty"` + PageRecovery *pageRecovery `json:"pageRecovery,omitempty"` + Collector collectorConfig `json:"collector"` } // Validate accepts the legacy v1 shape and strictly validates the v2 product @@ -133,6 +147,9 @@ func RequiredCapabilities(raw string) ([]string, error) { required[CapabilitySwipeV1] = true } } + if rule.PageRecovery != nil && rule.PageRecovery.TransientSoldOut != nil && rule.PageRecovery.TransientSoldOut.Enabled { + required[CapabilitySwipeV1] = true + } result := make([]string, 0, len(required)) for capability := range required { result = append(result, capability) @@ -219,6 +236,24 @@ func parseV2(raw []byte) (v2Rule, error) { } } } + if rule.PageRecovery != nil && rule.PageRecovery.TransientSoldOut != nil { + recovery := rule.PageRecovery.TransientSoldOut + if recovery.ExactText != "商品已售罄" { + return rule, errors.New("pageRecovery.transientSoldOut.exactText 只能是商品已售罄") + } + if recovery.PullDownCount < 1 || recovery.PullDownCount > 3 { + return rule, errors.New("pageRecovery.transientSoldOut.pullDownCount 必须为 1..3") + } + if recovery.IntervalMS < 500 || recovery.IntervalMS > 2000 { + return rule, errors.New("pageRecovery.transientSoldOut.intervalMs 必须为 500..2000") + } + if recovery.SettleMS < 500 || recovery.SettleMS > 5000 { + return rule, errors.New("pageRecovery.transientSoldOut.settleMs 必须为 500..5000") + } + if recovery.MaxAttempts != 1 { + return rule, errors.New("pageRecovery.transientSoldOut.maxAttempts 只能为 1") + } + } if rule.Collector.CollectorID != "pddProductDetailV1" || rule.Collector.SpecEntryStrategy != "safeBottomSpecEntryV1" || rule.Collector.PriceParser != "pddRmbPriceV1" || rule.Collector.PriceGranularity != "color" { return rule, errors.New("collector 使用了 Agent 不支持的类型化能力") } diff --git a/server/app/goauto/rulecontract/contract_test.go b/server/app/goauto/rulecontract/contract_test.go index 4ac35b3..931b4aa 100644 --- a/server/app/goauto/rulecontract/contract_test.go +++ b/server/app/goauto/rulecontract/contract_test.go @@ -65,7 +65,24 @@ func TestBuiltInPDDProductDetailTemplateIsValid(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.Join(required, ",") != "collector.pdd.product-detail.v1,rule.schema.v2" { + if strings.Join(required, ",") != "action.swipe.v1,collector.pdd.product-detail.v1,rule.schema.v2" { t.Fatalf("unexpected template capabilities: %v", required) } } + +func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) { + base := validV2(`[]`) + valid := strings.Replace(base, `"hooks"`, `"pageRecovery":{"transientSoldOut":{"enabled":true,"exactText":"商品已售罄","pullDownCount":2,"intervalMs":1000,"settleMs":2000,"maxAttempts":1}},"hooks"`, 1) + if err := Validate([]byte(valid)); err != nil { + t.Fatalf("valid recovery rejected: %v", err) + } + for _, invalid := range []string{ + strings.Replace(valid, `"exactText":"商品已售罄"`, `"exactText":"售罄"`, 1), + strings.Replace(valid, `"pullDownCount":2`, `"pullDownCount":4`, 1), + strings.Replace(valid, `"maxAttempts":1`, `"maxAttempts":2`, 1), + } { + if err := Validate([]byte(invalid)); err == nil { + t.Fatalf("invalid recovery accepted: %s", invalid) + } + } +} diff --git a/server/app/goauto/rulecontract/template.go b/server/app/goauto/rulecontract/template.go index d5a842e..1f15b33 100644 --- a/server/app/goauto/rulecontract/template.go +++ b/server/app/goauto/rulecontract/template.go @@ -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"}}, + "pageRecovery":{"transientSoldOut":{"enabled":true,"exactText":"商品已售罄","pullDownCount":2,"intervalMs":1000,"settleMs":2000,"maxAttempts":1}}, "hooks":{"afterSpecPanelOpen":[]}, "collector":{ "collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color", diff --git a/web/src/views/goauto/collection-rules/index.vue b/web/src/views/goauto/collection-rules/index.vue index 4949035..6633a6f 100644 --- a/web/src/views/goauto/collection-rules/index.vue +++ b/web/src/views/goauto/collection-rules/index.vue @@ -67,6 +67,15 @@ +
+

商品页假售罄恢复

仅在商品页精确显示“商品已售罄”且没有正常商品证据时触发;规格项售罄不会触发。

+
+ + + +
+
+
{{ previewText }}
@@ -88,7 +97,8 @@ const defaultConstraints = { aliasCount: { min: 1, max: 20 }, aliasLength: { min: 1, max: 30 }, 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 }} + 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 }} } 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 数量' }] @@ -130,16 +140,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 }} }, + 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 }} }, 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]; 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 }} }, - 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 }] : []; return content }, + 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 }, 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; return `颜色别名 ${aliases?.color?.length || 0} · 尺码别名 ${aliases?.size?.length || 0} · 附加滑动 ${hookCount} 次` }, + 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}` }, 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 } }, diff --git a/web/tests/e2e/collection-rule-editor.spec.ts b/web/tests/e2e/collection-rule-editor.spec.ts index 8a1d738..cf4955b 100644 --- a/web/tests/e2e/collection-rule-editor.spec.ts +++ b/web/tests/e2e/collection-rule-editor.spec.ts @@ -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' } }, + pageRecovery: { transientSoldOut: { enabled: true, exactText: '商品已售罄', pullDownCount: 2, intervalMs: 1000, settleMs: 2000, maxAttempts: 1 } }, hooks: { afterSpecPanelOpen: [] }, collector: { collectorId: 'pddProductDetailV1', specEntryStrategy: 'safeBottomSpecEntryV1', priceParser: 'pddRmbPriceV1', priceGranularity: 'color', @@ -32,10 +33,12 @@ 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')).not.toContainText('提交订单') await expect(page.getByRole('button', { name: '保存并立即生效' })).toBeEnabled() })