Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
483 lines
13 KiB
Go
483 lines
13 KiB
Go
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
|
||
}
|