feat: recover transient PDD sold-out pages (#27)

This commit is contained in:
QiuSW
2026-08-15 17:23:20 +08:00
parent 72d614524b
commit dbeb151f1d
14 changed files with 225 additions and 12 deletions
+5
View File
@@ -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
}
+37 -2
View File
@@ -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 不支持的类型化能力")
}
@@ -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)
}
}
}
@@ -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",