Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

483 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package seo
import (
"fmt"
"strings"
"unicode"
)
// ProductInput is a catalog product snapshot for analysis.
type ProductInput struct {
ID string
ProductID string
Name string
ProcessedName string
Description string
ProcessedDesc string
MetaTitle string
MetaDescription string
Category string
Attributes map[string]any
ProcessedAttrs map[string]any
MappedData map[string]any
// BrandPrompt is optional brand-kit injection for AI meta (paid path).
BrandPrompt string
// Language is companies.language; injected as {{language}} (English label).
Language string
}
// CategoryInput is a category snapshot for analysis.
type CategoryInput struct {
ID string
UniqueID string
Name string
DescriptionTemplate map[string]any
}
const (
maxPeerIDsPerDup = 20
maxDupRecsPerTitle = 50
)
type titlePeer struct {
ID string
Label string
}
// Analyze builds recommendations from product + category snapshots (pure; no I/O).
func Analyze(products []ProductInput, categories []CategoryInput) []Recommendation {
out := make([]Recommendation, 0, len(products)*2+len(categories))
titleCounts := map[string][]titlePeer{}
titleTotals := map[string]int{}
for _, p := range products {
title := displayTitle(p)
key := normalizeTitleKey(title)
label := entityLabel(p)
if key != "" {
titleTotals[key]++
keep := maxDupRecsPerTitle
if maxPeerIDsPerDup > keep {
keep = maxPeerIDsPerDup
}
if len(titleCounts[key]) < keep {
titleCounts[key] = append(titleCounts[key], titlePeer{ID: p.ID, Label: label})
}
}
if strings.TrimSpace(p.MetaTitle) == "" {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeMissingMetaTitle, p.ID),
Type: TypeMissingMetaTitle,
Severity: SeverityCritical,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Missing meta title",
Message: "Add a SERP title (≈5060 characters) so search results show a clear product name.",
Fixable: true,
FixModes: []string{ApplyModeTemplate, ApplyModeAI},
})
}
if strings.TrimSpace(p.MetaDescription) == "" {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeMissingMetaDescription, p.ID),
Type: TypeMissingMetaDescription,
Severity: SeverityCritical,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Missing meta description",
Message: "Add a meta description (≈120155 characters) summarizing benefits and specs.",
Fixable: true,
FixModes: []string{ApplyModeTemplate, ApplyModeAI},
})
}
if title != "" && runeLen(title) <= ThinTitleMaxChars {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeThinTitle, p.ID),
Type: TypeThinTitle,
Severity: SeverityWarn,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Thin product title",
Message: fmt.Sprintf("Title is only %d characters — expand with brand, model, or key attribute.", runeLen(title)),
Fixable: false,
Meta: map[string]any{"title_length": runeLen(title)},
})
}
if !hasImage(p) {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeMissingImage, p.ID),
Type: TypeMissingImage,
Severity: SeverityWarn,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Missing product image",
Message: "No primary image URL found in mapped/attribute fields — add an image for richer listings.",
Fixable: false,
})
}
if isWeakKeywords(p) {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeWeakKeywords, p.ID),
Type: TypeWeakKeywords,
Severity: SeverityInfo,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Weak keywords",
Message: "Title/description look generic — include brand, category, or distinctive product terms.",
Fixable: true,
FixModes: []string{ApplyModeAI},
})
}
}
for key, group := range titleCounts {
total := titleTotals[key]
if total < 2 {
continue
}
peerN := len(group)
if peerN > maxPeerIDsPerDup {
peerN = maxPeerIDsPerDup
}
ids := make([]string, 0, peerN)
for i := 0; i < peerN; i++ {
ids = append(ids, group[i].ID)
}
emit := len(group)
if emit > maxDupRecsPerTitle {
emit = maxDupRecsPerTitle
}
for i := 0; i < emit; i++ {
p := group[i]
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s:%s", TypeDuplicateTitle, key, p.ID),
Type: TypeDuplicateTitle,
Severity: SeverityWarn,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: p.Label,
Title: "Duplicate title",
Message: fmt.Sprintf("%d products share this title — differentiate for unique search snippets.", total),
Fixable: false,
Meta: map[string]any{
"duplicate_count": total,
"peer_ids": ids,
"title_key": key,
},
})
}
}
for _, c := range categories {
label := c.Name
if label == "" {
label = c.UniqueID
}
if !categoryHasMetaFormula(c.DescriptionTemplate) {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeCategoryMissingMeta, c.ID),
Type: TypeCategoryMissingMeta,
Severity: SeverityWarn,
EntityType: EntityCategory,
EntityID: c.ID,
EntityLabel: label,
Title: "Category missing meta formulas",
Message: "Set meta title/description formulas on this categorys description template.",
Fixable: false,
Meta: map[string]any{"unique_id": c.UniqueID},
})
}
name := strings.TrimSpace(c.Name)
if name != "" && runeLen(name) <= ThinCategoryNameMaxChars {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeThinCategoryName, c.ID),
Type: TypeThinCategoryName,
Severity: SeverityInfo,
EntityType: EntityCategory,
EntityID: c.ID,
EntityLabel: label,
Title: "Thin category name",
Message: "Category name is very short — use a clearer label for breadcrumbs and filters.",
Fixable: false,
})
}
}
return out
}
// BuildChecklist aggregates recommendations into scored checklist rows + overall score.
func BuildChecklist(recs []Recommendation, productCount, categoryCount int) (checklist []ChecklistItem, overall float64) {
type meta struct {
label, severity, desc string
fixable bool
denomKind string // products | categories
}
defs := []struct {
typ string
meta meta
}{
{TypeMissingMetaTitle, meta{"Missing meta titles", SeverityCritical, "Products without meta_title", true, "products"}},
{TypeMissingMetaDescription, meta{"Missing meta descriptions", SeverityCritical, "Products without meta_description", true, "products"}},
{TypeThinTitle, meta{"Thin titles", SeverityWarn, "Product titles that are too short", false, "products"}},
{TypeDuplicateTitle, meta{"Duplicate titles", SeverityWarn, "Products sharing the same title", false, "products"}},
{TypeMissingImage, meta{"Missing images", SeverityWarn, "Products without a primary image", false, "products"}},
{TypeWeakKeywords, meta{"Weak keywords", SeverityInfo, "Generic titles/descriptions lacking distinctive terms", true, "products"}},
{TypeCategoryMissingMeta, meta{"Category meta formulas", SeverityWarn, "Categories without meta title/description formulas", false, "categories"}},
{TypeThinCategoryName, meta{"Thin category names", SeverityInfo, "Very short category names", false, "categories"}},
}
byType := map[string][]Recommendation{}
for _, r := range recs {
byType[r.Type] = append(byType[r.Type], r)
}
checklist = make([]ChecklistItem, 0, len(defs))
var scoreSum float64
var scoreN int
for _, d := range defs {
group := byType[d.typ]
affected := uniqueEntities(group)
denom := productCount
if d.meta.denomKind == "categories" {
denom = categoryCount
}
score := 100.0
if denom > 0 {
score = 100.0 * (1.0 - float64(affected)/float64(denom))
if score < 0 {
score = 0
}
} else if len(group) == 0 {
score = 100.0
} else {
score = 0
}
checklist = append(checklist, ChecklistItem{
Type: d.typ,
Label: d.meta.label,
Severity: d.meta.severity,
Count: len(group),
Affected: affected,
Score: round1(score),
Fixable: d.meta.fixable,
Description: d.meta.desc,
})
scoreSum += score
scoreN++
}
if scoreN > 0 {
overall = round1(scoreSum / float64(scoreN))
}
return checklist, overall
}
func uniqueEntities(recs []Recommendation) int {
seen := map[string]struct{}{}
for _, r := range recs {
seen[r.EntityType+":"+r.EntityID] = struct{}{}
}
return len(seen)
}
func displayTitle(p ProductInput) string {
for _, s := range []string{p.ProcessedName, p.Name} {
if t := strings.TrimSpace(s); t != "" {
return t
}
}
return ""
}
func entityLabel(p ProductInput) string {
if t := displayTitle(p); t != "" {
return t
}
if p.ProductID != "" {
return p.ProductID
}
return p.ID
}
func normalizeTitleKey(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ""
}
var b strings.Builder
prevSpace := false
for _, r := range s {
if unicode.IsSpace(r) {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
continue
}
prevSpace = false
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
}
}
return strings.TrimSpace(b.String())
}
func hasImage(p ProductInput) bool {
keys := []string{
"image_url", "image", "main_image", "mainImage", "main_image_url",
"image_link", "imageLink", "thumbnail", "photo", "media_url",
}
bags := []map[string]any{p.ProcessedAttrs, p.Attributes, p.MappedData}
for _, bag := range bags {
if bag == nil {
continue
}
for _, k := range keys {
if v, ok := bag[k]; ok && nonEmptyStringish(v) {
return true
}
}
// Case-insensitive scan
for bk, v := range bag {
nk := strings.ToLower(strings.ReplaceAll(bk, "-", ""))
nk = strings.ReplaceAll(nk, "_", "")
if (nk == "imageurl" || nk == "mainimage" || nk == "mainimageurl" ||
nk == "imagelink" || nk == "thumbnail" || nk == "image") && nonEmptyStringish(v) {
return true
}
}
}
return false
}
func nonEmptyStringish(v any) bool {
switch t := v.(type) {
case string:
return strings.TrimSpace(t) != ""
case []any:
return len(t) > 0
case map[string]any:
return len(t) > 0
default:
return false
}
}
func categoryHasMetaFormula(tpl map[string]any) bool {
if tpl == nil {
return false
}
mt, _ := tpl["metaTitle"].(string)
md, _ := tpl["metaDescription"].(string)
// Also accept snake_case
if mt == "" {
mt, _ = tpl["meta_title"].(string)
}
if md == "" {
md, _ = tpl["meta_description"].(string)
}
return strings.TrimSpace(mt) != "" && strings.TrimSpace(md) != ""
}
// isWeakKeywords: short/generic title and description without brand/category cues.
func isWeakKeywords(p ProductInput) bool {
title := displayTitle(p)
desc := strings.TrimSpace(p.ProcessedDesc)
if desc == "" {
desc = strings.TrimSpace(p.Description)
}
metaT := strings.TrimSpace(p.MetaTitle)
metaD := strings.TrimSpace(p.MetaDescription)
corpus := strings.ToLower(strings.Join([]string{title, desc, metaT, metaD}, " "))
if strings.TrimSpace(corpus) == "" {
return true
}
// Distinctive if category or brand appears in title/meta.
cat := strings.ToLower(strings.TrimSpace(p.Category))
if cat != "" && len(cat) > 2 && strings.Contains(strings.ToLower(title+" "+metaT), cat) {
return false
}
brand := firstString(p.ProcessedAttrs, p.Attributes, p.MappedData, "brand", "Brand", "manufacturer", "Manufacturer")
if brand != "" && len(brand) > 1 && strings.Contains(strings.ToLower(title+" "+metaT), strings.ToLower(brand)) {
return false
}
// Weak if title is mostly stopwords / very short content overall.
words := tokenize(corpus)
if len(words) < 4 {
return true
}
meaningful := 0
for _, w := range words {
if !stopWord(w) && len(w) > 2 {
meaningful++
}
}
return meaningful < 3
}
func firstString(bags ...any) string {
var maps []map[string]any
var keys []string
for _, b := range bags {
switch t := b.(type) {
case map[string]any:
maps = append(maps, t)
case string:
keys = append(keys, t)
}
}
for _, m := range maps {
if m == nil {
continue
}
for _, k := range keys {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
}
}
return ""
}
func tokenize(s string) []string {
parts := strings.FieldsFunc(s, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.ToLower(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func stopWord(w string) bool {
switch w {
case "a", "an", "the", "and", "or", "for", "of", "to", "in", "on", "with",
"product", "item", "new", "best", "buy", "sale", "pack", "set":
return true
default:
return false
}
}
func runeLen(s string) int {
return len([]rune(s))
}
func round1(v float64) float64 {
return float64(int(v*10+0.5)) / 10
}