207 lines
5.4 KiB
Go
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
|
|
}
|