package processing import ( "fmt" "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 "" } // metaFieldsFromEnhanceObj extracts plain SEO meta from an enhance JSON object. // Accepts snake_case and camelCase keys (legacy A1 / cats.json). // Strips HTML and rejects prompt-leakage titles; empty when missing. func metaFieldsFromEnhanceObj(obj map[string]any) (title, description string) { if obj == nil { return "", "" } title = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["meta_title"]))) if title == "" || title == "" { title = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["metaTitle"]))) } description = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["meta_description"]))) if description == "" || description == "" { description = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["metaDescription"]))) } if title == "" { title = "" } if description == "" { description = "" } title = stripMetaTags(title) description = stripMetaTags(description) if isPromptLabelTitle(title) || isPromptLeakageTitle(title) { title = "" } if isPromptLabelTitle(description) || isPromptLeakageTitle(description) { description = "" } if title != "" { title = truncateMetaRunes(title, metaTitleMaxChars) } if description != "" { description = truncateMetaDescription(description, metaDescriptionMaxChars) } return title, description } // enhanceMetaFromRaw reads meta_title / meta_description stashed on enhance raw meta. func enhanceMetaFromRaw(raw any) (title, description string) { m, ok := raw.(map[string]any) if !ok || m == nil { return "", "" } title = strings.TrimSpace(fmt.Sprint(m["meta_title"])) description = strings.TrimSpace(fmt.Sprint(m["meta_description"])) if title == "" { title = "" } if description == "" { description = "" } return title, description }