Files
descrybe/apps/api/internal/seo/analyze.go
T

483 lines
13 KiB
Go
Raw Normal View History

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
}