2026-08-09 22:47:43 +02:00
|
|
|
|
package processing
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"regexp"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
brandPrefixRe = regexp.MustCompile(`(?i)^([A-Za-z][A-Za-z0-9&.\-]{1,40})\b`)
|
|
|
|
|
|
dimTripleRe = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)\s*[x×]\s*(\d+(?:[.,]\d+)?)\s*[x×]\s*(\d+(?:[.,]\d+)?)`)
|
|
|
|
|
|
dimPairRe = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)\s*[x×]\s*(\d+(?:[.,]\d+)?)`)
|
|
|
|
|
|
weightRe = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)\s*(kg|g|lb|oz)\b`)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// FillMissingFields derives sensible standard fields from name/attrs when absent.
|
|
|
|
|
|
func FillMissingFields(mapped map[string]any, attrs map[string]any) map[string]any {
|
|
|
|
|
|
out := make(map[string]any, len(mapped)+8)
|
|
|
|
|
|
for k, v := range mapped {
|
|
|
|
|
|
out[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
name := stringFromAny(out["name"])
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
name = stringFromAny(out["title"])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if stringFromAny(out["brand"]) == "" {
|
|
|
|
|
|
if b := stringFromAny(attrs["brand"]); b != "" {
|
|
|
|
|
|
out["brand"] = b
|
|
|
|
|
|
} else if b := inferBrand(name); b != "" {
|
|
|
|
|
|
out["brand"] = b
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if stringFromAny(out["gtin"]) == "" {
|
|
|
|
|
|
if g := stringFromAny(out["ean"]); g != "" {
|
|
|
|
|
|
out["gtin"] = g
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
blob := name + " " + stringFromAny(out["description"])
|
|
|
|
|
|
for _, m := range []map[string]any{attrs, out} {
|
|
|
|
|
|
for _, k := range []string{"dimensions", "size", "dimension"} {
|
|
|
|
|
|
blob += " " + stringFromAny(m[k])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if stringFromAny(out["width"]) == "" || stringFromAny(out["height"]) == "" || stringFromAny(out["depth"]) == "" {
|
|
|
|
|
|
if w, h, d, ok := parseDimensions(blob); ok {
|
|
|
|
|
|
if stringFromAny(out["width"]) == "" {
|
|
|
|
|
|
out["width"] = w
|
|
|
|
|
|
}
|
|
|
|
|
|
if stringFromAny(out["height"]) == "" {
|
|
|
|
|
|
out["height"] = h
|
|
|
|
|
|
}
|
|
|
|
|
|
if stringFromAny(out["depth"]) == "" && d != "" {
|
|
|
|
|
|
out["depth"] = d
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if stringFromAny(out["weight"]) == "" {
|
|
|
|
|
|
if w := stringFromAny(attrs["weight"]); w != "" {
|
|
|
|
|
|
out["weight"] = w
|
|
|
|
|
|
} else if w, ok := parseWeight(blob); ok {
|
|
|
|
|
|
out["weight"] = w
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 16:57:36 +02:00
|
|
|
|
if c := categoryUniqueIDFromAny(out["category"]); c != "" {
|
|
|
|
|
|
out["category"] = c
|
|
|
|
|
|
} else if c := categoryUniqueIDFromAny(attrs["category"]); c != "" {
|
|
|
|
|
|
out["category"] = c
|
|
|
|
|
|
} else if c := categoryUniqueIDFromAny(out["category_unique_id"]); c != "" {
|
|
|
|
|
|
out["category"] = c
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if stringFromAny(out["stock_status"]) == "" {
|
|
|
|
|
|
if s := stringFromAny(out["stock"]); s != "" {
|
|
|
|
|
|
out["stock_status"] = normalizeStockStatusLabel(s)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
out["stock_status"] = normalizeStockStatusLabel(stringFromAny(out["stock_status"]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func inferBrand(name string) string {
|
|
|
|
|
|
name = strings.TrimSpace(name)
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
m := brandPrefixRe.FindStringSubmatch(name)
|
|
|
|
|
|
if len(m) < 2 {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
b := strings.TrimSpace(m[1])
|
|
|
|
|
|
// Skip generic leading words
|
|
|
|
|
|
switch strings.ToLower(b) {
|
|
|
|
|
|
case "the", "new", "set", "pack", "pair", "product", "item":
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return SanitizeOutput(b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func parseDimensions(blob string) (w, h, d string, ok bool) {
|
|
|
|
|
|
if m := dimTripleRe.FindStringSubmatch(blob); len(m) == 4 {
|
|
|
|
|
|
return normalizeNum(m[1]), normalizeNum(m[2]), normalizeNum(m[3]), true
|
|
|
|
|
|
}
|
|
|
|
|
|
if m := dimPairRe.FindStringSubmatch(blob); len(m) == 3 {
|
|
|
|
|
|
return normalizeNum(m[1]), normalizeNum(m[2]), "", true
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", "", "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func parseWeight(blob string) (string, bool) {
|
|
|
|
|
|
m := weightRe.FindStringSubmatch(blob)
|
|
|
|
|
|
if len(m) < 3 {
|
|
|
|
|
|
return "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalizeNum(m[1]) + " " + strings.ToLower(m[2]), true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeNum(s string) string {
|
|
|
|
|
|
return strings.ReplaceAll(strings.TrimSpace(s), ",", ".")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeStockStatusLabel(s string) string {
|
|
|
|
|
|
if mapped := MapStockStatus(s); mapped != "" {
|
|
|
|
|
|
return mapped
|
|
|
|
|
|
}
|
|
|
|
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case s == "" || s == "0" || strings.Contains(s, "out"):
|
|
|
|
|
|
return "out_of_stock"
|
|
|
|
|
|
case strings.Contains(s, "pre"):
|
|
|
|
|
|
return "preorder"
|
|
|
|
|
|
case strings.Contains(s, "back"):
|
|
|
|
|
|
return "backorder"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "in_stock"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func stringFromAny(v any) string {
|
2026-08-16 16:57:36 +02:00
|
|
|
|
// Flatten arrays / #text wrappers; callers that need HTML-stripped descriptions
|
|
|
|
|
|
// should use PlainDescriptionFromAny (normalize + V1 enforce do that).
|
|
|
|
|
|
return coerceScalarText(v)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|