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 (≈50–60 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 (≈120–155 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 category’s 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 }