feat(#115): move PDD recognition aliases into rules

This commit is contained in:
QiuSW
2026-08-27 18:19:33 +08:00
parent 3619435a97
commit e117fd222d
11 changed files with 389 additions and 96 deletions
+100 -23
View File
@@ -8,6 +8,8 @@ import (
"io"
"sort"
"strings"
"go-admin/app/goauto/purchasecontract"
)
const (
@@ -63,23 +65,69 @@ type hookAction struct {
}
type collectorConfig struct {
CollectorID string `json:"collectorId"`
SpecEntryStrategy string `json:"specEntryStrategy"`
PriceParser string `json:"priceParser"`
PriceGranularity string `json:"priceGranularity"`
DimensionAliases map[string][]string `json:"dimensionAliases"`
TimeoutsMS map[string]int `json:"timeoutsMs"`
Limits map[string]int `json:"limits"`
CollectorID string `json:"collectorId"`
SpecEntryStrategy string `json:"specEntryStrategy"`
PriceParser string `json:"priceParser"`
PriceGranularity string `json:"priceGranularity"`
DimensionAliases map[string][]string `json:"dimensionAliases"`
TimeoutsMS map[string]int `json:"timeoutsMs"`
Limits map[string]int `json:"limits"`
TextAliases *collectorTextAliases `json:"textAliases,omitempty"`
}
type collectorTextAliases struct {
SpecPanel specPanelTextAliases `json:"specPanel"`
Dimension dimensionTextAliases `json:"dimension"`
Selection selectionTextAliases `json:"selection"`
Review reviewTextAliases `json:"review"`
SoldOut soldOutTextAliases `json:"soldOut"`
Purchase purchaseTextAliases `json:"purchase"`
}
type specPanelTextAliases struct {
TitleAliases []string `json:"titleAliases"`
CloseAliases []string `json:"closeAliases"`
ConfirmAliases []string `json:"confirmAliases"`
QuantityDecreaseAliases []string `json:"quantityDecreaseAliases"`
QuantityIncreaseAliases []string `json:"quantityIncreaseAliases"`
PaymentAreaAliases []string `json:"paymentAreaAliases"`
OrderSubmitAliases []string `json:"orderSubmitAliases"`
QuickBuyAliases []string `json:"quickBuyAliases"`
}
type dimensionTextAliases struct {
ColorSuffixes []string `json:"colorSuffixes"`
SizeSuffixes []string `json:"sizeSuffixes"`
ExactNames []string `json:"exactNames"`
AdaptiveAliases []string `json:"adaptiveAliases"`
}
type selectionTextAliases struct {
SummaryPrefixes []string `json:"summaryPrefixes"`
SelectedPrefixes []string `json:"selectedPrefixes"`
SpecEntryPrefixes []string `json:"specEntryPrefixes"`
SubmitHintAliases []string `json:"submitHintAliases"`
}
type reviewTextAliases struct {
EntryAliases []string `json:"entryAliases"`
}
type soldOutTextAliases struct {
MainProductExactAliases []string `json:"mainProductExactAliases"`
MainProductPrefixAliases []string `json:"mainProductPrefixAliases"`
}
type purchaseTextAliases struct {
BuyWords []string `json:"buyWords"`
}
type transientSoldOutRecovery struct {
Enabled bool `json:"enabled"`
ExactText string `json:"exactText"`
FallbackTopText string `json:"fallbackTopText,omitempty"`
PullDownCount int `json:"pullDownCount"`
IntervalMS int `json:"intervalMs"`
SettleMS int `json:"settleMs"`
MaxAttempts int `json:"maxAttempts"`
Enabled bool `json:"enabled"`
ExactText string `json:"exactText"`
FallbackTopText *string `json:"fallbackTopText,omitempty"`
PullDownCount int `json:"pullDownCount"`
IntervalMS int `json:"intervalMs"`
SettleMS int `json:"settleMs"`
MaxAttempts int `json:"maxAttempts"`
}
type pageRecovery struct {
@@ -260,11 +308,13 @@ 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 err := purchasecontract.ValidateReadOnlyTextAliases("pageRecovery.transientSoldOut.exactText", []string{recovery.ExactText}, 1, 30); err != nil {
return rule, err
}
if recovery.FallbackTopText != "" && recovery.FallbackTopText != "相似商品" {
return rule, errors.New("pageRecovery.transientSoldOut.fallbackTopText 只能是相似商品")
if recovery.FallbackTopText != nil {
if err := purchasecontract.ValidateReadOnlyTextAliases("pageRecovery.transientSoldOut.fallbackTopText", []string{*recovery.FallbackTopText}, 1, 30); err != nil {
return rule, err
}
}
if recovery.PullDownCount < 1 || recovery.PullDownCount > 3 {
return rule, errors.New("pageRecovery.transientSoldOut.pullDownCount 必须为 1..3")
@@ -306,12 +356,39 @@ func parseV2(raw []byte) (v2Rule, error) {
}
for _, key := range []string{"color", "size"} {
aliases := rule.Collector.DimensionAliases[key]
if len(aliases) == 0 || len(aliases) > 20 {
return rule, fmt.Errorf("dimensionAliases.%s 必须包含 1..20 项", key)
if err := validateAliases(aliases, "dimensionAliases."+key, 1, 20, 30); err != nil {
return rule, err
}
for _, alias := range aliases {
if strings.TrimSpace(alias) == "" || len([]rune(alias)) > 30 {
return rule, fmt.Errorf("dimensionAliases.%s 含无效别名", key)
}
if aliases := rule.Collector.TextAliases; aliases != nil {
groups := []struct {
name string
values []string
}{
{"specPanel.titleAliases", aliases.SpecPanel.TitleAliases},
{"specPanel.closeAliases", aliases.SpecPanel.CloseAliases},
{"specPanel.confirmAliases", aliases.SpecPanel.ConfirmAliases},
{"specPanel.quantityDecreaseAliases", aliases.SpecPanel.QuantityDecreaseAliases},
{"specPanel.quantityIncreaseAliases", aliases.SpecPanel.QuantityIncreaseAliases},
{"specPanel.paymentAreaAliases", aliases.SpecPanel.PaymentAreaAliases},
{"specPanel.orderSubmitAliases", aliases.SpecPanel.OrderSubmitAliases},
{"specPanel.quickBuyAliases", aliases.SpecPanel.QuickBuyAliases},
{"dimension.colorSuffixes", aliases.Dimension.ColorSuffixes},
{"dimension.sizeSuffixes", aliases.Dimension.SizeSuffixes},
{"dimension.exactNames", aliases.Dimension.ExactNames},
{"dimension.adaptiveAliases", aliases.Dimension.AdaptiveAliases},
{"selection.summaryPrefixes", aliases.Selection.SummaryPrefixes},
{"selection.selectedPrefixes", aliases.Selection.SelectedPrefixes},
{"selection.specEntryPrefixes", aliases.Selection.SpecEntryPrefixes},
{"selection.submitHintAliases", aliases.Selection.SubmitHintAliases},
{"review.entryAliases", aliases.Review.EntryAliases},
{"soldOut.mainProductExactAliases", aliases.SoldOut.MainProductExactAliases},
{"soldOut.mainProductPrefixAliases", aliases.SoldOut.MainProductPrefixAliases},
{"purchase.buyWords", aliases.Purchase.BuyWords},
}
for _, group := range groups {
if err := purchasecontract.ValidateReadOnlyTextAliases("collector.textAliases."+group.name, group.values, 20, 30); err != nil {
return rule, err
}
}
}
@@ -72,6 +72,9 @@ func TestBuiltInPDDProductDetailTemplateIsValid(t *testing.T) {
if !bytes.Contains(raw, []byte(`"size":["尺码","尺寸","套餐","参考分类"]`)) {
t.Fatal("built-in template must classify 参考分类 as size")
}
if !bytes.Contains(raw, []byte(`"orderSubmitAliases":["提交订单","提交訂單"]`)) {
t.Fatal("built-in template must carry read-only collector text aliases")
}
}
func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
@@ -80,9 +83,16 @@ func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
if err := Validate([]byte(valid)); err != nil {
t.Fatalf("valid recovery rejected: %v", err)
}
for _, invalid := range []string{
for _, customized := range []string{
strings.Replace(valid, `"exactText":"商品已售罄"`, `"exactText":"售罄"`, 1),
strings.Replace(valid, `"fallbackTopText":"相似商品"`, `"fallbackTopText":"猜你喜欢"`, 1),
} {
if err := Validate([]byte(customized)); err != nil {
t.Fatalf("custom recovery evidence rejected: %v", err)
}
}
for _, invalid := range []string{
strings.Replace(valid, `"fallbackTopText":"相似商品"`, `"fallbackTopText":"收货地址"`, 1),
strings.Replace(valid, `"pullDownCount":2`, `"pullDownCount":4`, 1),
strings.Replace(valid, `"maxAttempts":1`, `"maxAttempts":2`, 1),
} {
@@ -96,6 +106,22 @@ func TestV2ValidatesTransientSoldOutRecovery(t *testing.T) {
}
}
func TestV2CollectorTextAliasesAreReadOnlyAndStrict(t *testing.T) {
raw := string(PDDProductDetailTemplate())
if err := Validate([]byte(raw)); err != nil {
t.Fatalf("payment/order evidence must be accepted: %v", err)
}
for _, invalid := range []string{
strings.Replace(raw, `"buyWords":["单独购买"`, `"buyWords":["收货地址"`, 1),
strings.Replace(raw, `"titleAliases":["确认款式","確認款式"]`, `"titleAliases":["确认款式","确认款式"]`, 1),
strings.Replace(raw, `"entryAliases":["商品评价"`, `"entryAliases":[] ,"ignored":["商品评价"`, 1),
} {
if err := Validate([]byte(invalid)); err == nil {
t.Fatalf("invalid collector text aliases accepted: %s", invalid)
}
}
}
func TestV2ValidatesReopenBrowserRecovery(t *testing.T) {
base := validV2(`[]`)
valid := strings.Replace(base, `"hooks"`, `"navigationRecovery":{"reopenBrowser":{"enabled":true,"maxAttempts":1,"settleMs":1000}},"hooks"`, 1)
@@ -18,6 +18,14 @@ var pddProductDetailTemplate = json.RawMessage(`{
"collector":{
"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color",
"dimensionAliases":{"color":["颜色分类","颜色","款式","颜色款式","花色"],"size":["尺码","尺寸","套餐","参考分类"]},
"textAliases":{
"specPanel":{"titleAliases":["确认款式","確認款式"],"closeAliases":["关闭","關閉"],"confirmAliases":["确定","確定","确认","確認"],"quantityDecreaseAliases":["减少数量","減少數量"],"quantityIncreaseAliases":["增加数量","增加數量"],"paymentAreaAliases":["微信支付","先用后付","先用後付","支付方式"],"orderSubmitAliases":["提交订单","提交訂單"],"quickBuyAliases":["现在买","現在買"]},
"dimension":{"colorSuffixes":["颜色","款式"],"sizeSuffixes":["尺码","尺寸"],"exactNames":["规格","型号","容量","类型","版本","口味"],"adaptiveAliases":["套餐"]},
"selection":{"summaryPrefixes":["已选","请选择","已選","請選擇"],"selectedPrefixes":["已选","已選"],"specEntryPrefixes":["请选择","选择","已选"],"submitHintAliases":["选择","選擇","颜色","顏色","尺码","尺碼","规格","規格"]},
"review":{"entryAliases":["商品评价","全部评价","评价详情","评论","晒单","问答"]},
"soldOut":{"mainProductExactAliases":["进店","单独购买","发起拼单","立即购买"],"mainProductPrefixAliases":["商品评价"]},
"purchase":{"buyWords":["单独购买","立即购买","发起拼单","去拼单","免拼购买","购买"]}
},
"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},
"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}
}