package processing import ( "regexp" "strings" "unicode" ) // Meta title/description caps mirror seo.MetaTitleMaxChars / MetaDescriptionMaxChars. // Duplicated here so processing can fill meta without importing seo (seo imports processing). const ( metaTitleMaxChars = 60 metaDescriptionMaxChars = 155 ) // Bare "| " suffix = legacy unique_id leak (e.g. "VOX EBR700 | 50"). // Matches upsertProcessedProductSQL refresh predicate. var poisonedMetaTitleUniqueIDRe = regexp.MustCompile(`\|\s+[0-9]+$`) // isPoisonedMetaTitle reports meta_title that must be refreshed: bare unique_id // suffix ("… | 50") or enhance-prompt instruction leakage ("short retail title", // "Title formula", …). Matches upsert / BackfillMissingMeta SQL predicates. func isPoisonedMetaTitle(s string) bool { s = strings.TrimSpace(s) if s == "" { return false } if poisonedMetaTitleUniqueIDRe.MatchString(s) { return true } return isPromptLeakageTitle(s) } // fillMetaFromResult builds free template meta from a StepResult (FillMetaTemplate-equivalent). // Does not call AI / does not spend credits. func fillMetaFromResult(result StepResult) (title, description string) { name := strings.TrimSpace(result.ProcessedName) if name == "" { name = strings.TrimSpace(result.Name) } if name == "" { name = "Product" } cat := categoryDisplayLabel(result) brand := metaBrandFromAttrs(result.ProcessedAttributes, result.Attributes) parts := make([]string, 0, 3) if brand != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(brand)) { parts = append(parts, brand) } parts = append(parts, name) if cat != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(cat)) { parts = append(parts, cat) } title = truncateMetaRunes(strings.Join(parts, " | "), metaTitleMaxChars) body := strings.TrimSpace(result.ProcessedDescription) if body == "" { body = strings.TrimSpace(result.Description) } body = stripMetaTags(body) if body == "" { var bits []string if brand != "" { bits = append(bits, brand) } bits = append(bits, name) if cat != "" { bits = append(bits, "in "+cat) } body = strings.Join(bits, " ") + ". Shop quality products with clear specs and fast delivery." } description = truncateMetaDescription(body, metaDescriptionMaxChars) return title, description } // truncateMetaDescription collapses whitespace and truncates at a word boundary when possible. func truncateMetaDescription(s string, max int) string { s = collapseMetaSpace(s) if max <= 0 { return "" } r := []rune(s) if len(r) <= max { return s } cut := r[:max] lastSpace := -1 for i := len(cut) - 1; i >= 0; i-- { if unicode.IsSpace(cut[i]) { lastSpace = i break } } if lastSpace > max/2 { return strings.TrimSpace(string(cut[:lastSpace])) } return string(cut) } func truncateMetaRunes(s string, max int) string { s = collapseMetaSpace(s) r := []rune(s) if max <= 0 || len(r) <= max { return s } if max <= 3 { return string(r[:max]) } return string(r[:max-1]) + "…" } func collapseMetaSpace(s string) string { return strings.Join(strings.Fields(s), " ") } func stripMetaTags(s string) string { var b strings.Builder inTag := false for _, r := range s { switch { case r == '<': inTag = true case r == '>': inTag = false case !inTag: b.WriteRune(r) } } return b.String() } func metaBrandFromAttrs(bags ...map[string]any) string { keys := []string{"brand", "Brand", "manufacturer"} for _, m := range bags { if m == nil { continue } for _, k := range keys { if v, ok := m[k]; ok { if s, ok := v.(string); ok { if t := strings.TrimSpace(s); t != "" { return t } } } } } return "" }