Files
2026-08-16 23:42:00 +02:00

207 lines
5.4 KiB
Go

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 "| <digits>" 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 <metaDescription> / 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 == "<nil>" {
title = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["metaTitle"])))
}
description = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["meta_description"])))
if description == "" || description == "<nil>" {
description = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["metaDescription"])))
}
if title == "<nil>" {
title = ""
}
if description == "<nil>" {
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 == "<nil>" {
title = ""
}
if description == "<nil>" {
description = ""
}
return title, description
}