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.
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnalyze_missingMetaAndImage(t *testing.T) {
|
||||
products := []ProductInput{
|
||||
{
|
||||
ID: "p1",
|
||||
Name: "Acme UltraWidget Pro 3000 Stainless",
|
||||
Category: "Widgets",
|
||||
ProcessedAttrs: map[string]any{"brand": "Acme"},
|
||||
},
|
||||
}
|
||||
recs := Analyze(products, nil)
|
||||
types := map[string]bool{}
|
||||
for _, r := range recs {
|
||||
types[r.Type] = true
|
||||
if r.EntityID != "p1" && r.Type != TypeDuplicateTitle {
|
||||
t.Fatalf("unexpected entity %s", r.EntityID)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{TypeMissingMetaTitle, TypeMissingMetaDescription, TypeMissingImage} {
|
||||
if !types[want] {
|
||||
t.Fatalf("expected type %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyze_duplicateAndThin(t *testing.T) {
|
||||
products := []ProductInput{
|
||||
{ID: "a", Name: "Short", MetaTitle: "x", MetaDescription: "y"},
|
||||
{ID: "b", Name: "Short", MetaTitle: "x", MetaDescription: "y"},
|
||||
}
|
||||
recs := Analyze(products, nil)
|
||||
var thin, dup int
|
||||
for _, r := range recs {
|
||||
switch r.Type {
|
||||
case TypeThinTitle:
|
||||
thin++
|
||||
case TypeDuplicateTitle:
|
||||
dup++
|
||||
}
|
||||
}
|
||||
if thin < 2 {
|
||||
t.Fatalf("expected thin titles, got %d", thin)
|
||||
}
|
||||
if dup < 2 {
|
||||
t.Fatalf("expected duplicate titles, got %d", dup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyze_duplicatePeerAndRecCaps(t *testing.T) {
|
||||
n := maxDupRecsPerTitle + maxPeerIDsPerDup + 10
|
||||
products := make([]ProductInput, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
products = append(products, ProductInput{
|
||||
ID: fmt.Sprintf("p%d", i),
|
||||
Name: "Shared Title Product",
|
||||
MetaTitle: "mt",
|
||||
MetaDescription: "md",
|
||||
})
|
||||
}
|
||||
recs := Analyze(products, nil)
|
||||
var dup int
|
||||
var peerLen int
|
||||
for _, r := range recs {
|
||||
if r.Type != TypeDuplicateTitle {
|
||||
continue
|
||||
}
|
||||
dup++
|
||||
peers, _ := r.Meta["peer_ids"].([]string)
|
||||
if peerLen == 0 {
|
||||
peerLen = len(peers)
|
||||
}
|
||||
if len(peers) > maxPeerIDsPerDup {
|
||||
t.Fatalf("peer_ids len=%d want <=%d", len(peers), maxPeerIDsPerDup)
|
||||
}
|
||||
count, _ := r.Meta["duplicate_count"].(int)
|
||||
if count != n {
|
||||
t.Fatalf("duplicate_count=%d want %d", count, n)
|
||||
}
|
||||
}
|
||||
if dup != maxDupRecsPerTitle {
|
||||
t.Fatalf("duplicate recs=%d want %d", dup, maxDupRecsPerTitle)
|
||||
}
|
||||
if peerLen != maxPeerIDsPerDup {
|
||||
t.Fatalf("peer_ids len=%d want %d", peerLen, maxPeerIDsPerDup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyze_categoryMeta(t *testing.T) {
|
||||
cats := []CategoryInput{
|
||||
{ID: "c1", UniqueID: "cat-1", Name: "AB", DescriptionTemplate: map[string]any{}},
|
||||
{ID: "c2", UniqueID: "cat-2", Name: "Appliances", DescriptionTemplate: map[string]any{
|
||||
"metaTitle": "t", "metaDescription": "d",
|
||||
}},
|
||||
}
|
||||
recs := Analyze(nil, cats)
|
||||
var missing, thin int
|
||||
for _, r := range recs {
|
||||
if r.Type == TypeCategoryMissingMeta {
|
||||
missing++
|
||||
if r.EntityID != "c1" {
|
||||
t.Fatalf("wrong category for missing meta: %s", r.EntityID)
|
||||
}
|
||||
}
|
||||
if r.Type == TypeThinCategoryName {
|
||||
thin++
|
||||
}
|
||||
}
|
||||
if missing != 1 {
|
||||
t.Fatalf("expected 1 missing meta formula, got %d", missing)
|
||||
}
|
||||
if thin != 1 {
|
||||
t.Fatalf("expected 1 thin category name, got %d", thin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillMetaTemplate(t *testing.T) {
|
||||
title, desc := FillMetaTemplate(ProductInput{
|
||||
Name: "Drill 18V",
|
||||
Category: "Tools",
|
||||
Description: "Cordless drill with battery pack for DIY projects.",
|
||||
Attributes: map[string]any{"brand": "Bosch"},
|
||||
})
|
||||
if title == "" || desc == "" {
|
||||
t.Fatal("expected non-empty meta")
|
||||
}
|
||||
if runeLen(title) > MetaTitleMaxChars {
|
||||
t.Fatalf("title too long: %d", runeLen(title))
|
||||
}
|
||||
if runeLen(desc) > MetaDescriptionMaxChars {
|
||||
t.Fatalf("desc too long: %d", runeLen(desc))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChecklist_score(t *testing.T) {
|
||||
products := []ProductInput{
|
||||
{ID: "1", Name: "Good Product Title Here", MetaTitle: "mt", MetaDescription: "md",
|
||||
MappedData: map[string]any{"image_url": "https://example.com/a.jpg"}, Category: "Good Product Title Here"},
|
||||
}
|
||||
recs := Analyze(products, nil)
|
||||
checklist, overall := BuildChecklist(recs, 1, 0)
|
||||
if overall <= 0 {
|
||||
t.Fatalf("expected positive overall, got %v", overall)
|
||||
}
|
||||
if len(checklist) != len(AllTypes()) {
|
||||
t.Fatalf("checklist len %d want %d", len(checklist), len(AllTypes()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllTypesStable(t *testing.T) {
|
||||
types := AllTypes()
|
||||
if len(types) != 8 {
|
||||
t.Fatalf("want 8 types, got %d", len(types))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package seo
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors for SEO apply / lookups.
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrInvalidMode = errors.New("mode must be template or ai")
|
||||
)
|
||||
|
||||
// ClientError reports whether err is a known client-facing SEO validation error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "", false
|
||||
case errors.Is(err, ErrInvalidMode):
|
||||
return err.Error(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
maxProductsScan = 2000
|
||||
maxCategoriesScan = 500
|
||||
maxRecsReturn = 200
|
||||
)
|
||||
|
||||
// Service loads catalog rows and applies SEO fixes.
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
Billing *billing.Service
|
||||
Completer processing.Completer
|
||||
// AI optional: resolves company BYOK before falling back to Completer.
|
||||
AI *aiprovider.Service
|
||||
// Prompts optional: company-editable SEO system/user templates.
|
||||
Prompts *aiprompts.Service
|
||||
}
|
||||
|
||||
// Recommendations returns a company-scoped SEO report.
|
||||
func (s *Service) Recommendations(ctx context.Context, companyID uuid.UUID) (Report, error) {
|
||||
products, err := s.loadProducts(ctx, companyID)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
categories, err := s.loadCategories(ctx, companyID)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
|
||||
recs := Analyze(products, categories)
|
||||
checklist, overall := BuildChecklist(recs, len(products), len(categories))
|
||||
|
||||
// Cap payload size but keep checklist accurate from full analyze.
|
||||
limited := recs
|
||||
if len(limited) > maxRecsReturn {
|
||||
limited = prioritizeRecs(recs, maxRecsReturn)
|
||||
}
|
||||
|
||||
canAI := false
|
||||
if s.Billing != nil {
|
||||
ent, err := s.Billing.EntitlementsForCompany(ctx, companyID)
|
||||
if err == nil {
|
||||
canAI = ent.CanUseAI
|
||||
}
|
||||
}
|
||||
|
||||
return Report{
|
||||
CompanyID: companyID.String(),
|
||||
ProductCount: len(products),
|
||||
CategoryCount: len(categories),
|
||||
OverallScore: overall,
|
||||
CanUseAI: canAI,
|
||||
Checklist: checklist,
|
||||
Recommendations: limited,
|
||||
Types: AllTypes(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Apply fills meta for one product (template = free; ai = paid/credits).
|
||||
func (s *Service) Apply(ctx context.Context, companyID uuid.UUID, productID uuid.UUID, mode string) (ApplyResult, error) {
|
||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||
if mode == "" {
|
||||
mode = ApplyModeTemplate
|
||||
}
|
||||
if mode != ApplyModeTemplate && mode != ApplyModeAI {
|
||||
return ApplyResult{}, ErrInvalidMode
|
||||
}
|
||||
|
||||
p, err := s.loadOneProduct(ctx, companyID, productID)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
var metaTitle, metaDesc string
|
||||
credits := 0
|
||||
tokens := 0
|
||||
|
||||
switch mode {
|
||||
case ApplyModeTemplate:
|
||||
metaTitle, metaDesc = FillMetaTemplate(p)
|
||||
case ApplyModeAI:
|
||||
if s.Billing == nil {
|
||||
return ApplyResult{}, billing.ErrAIRequiresUpgrade
|
||||
}
|
||||
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.seo_ai_rewrite", "marketing.seo.ai_rewrite"); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
ent, err := s.Billing.EntitlementsForCompany(ctx, companyID)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if !ent.CanUseAI {
|
||||
return ApplyResult{}, billing.ErrAIRequiresUpgrade
|
||||
}
|
||||
if ent.RemainingCredits < 1 {
|
||||
return ApplyResult{}, billing.ErrInsufficientCredits
|
||||
}
|
||||
var completer processing.Completer
|
||||
if s.AI != nil {
|
||||
c, _, _, rerr := s.AI.ResolveCompleter(ctx, companyID)
|
||||
if rerr != nil {
|
||||
return ApplyResult{}, rerr
|
||||
}
|
||||
completer = c
|
||||
} else {
|
||||
completer = s.Completer
|
||||
}
|
||||
if completer == nil {
|
||||
completer = processing.HeuristicCompleter{}
|
||||
}
|
||||
if brand, berr := company.LoadBrand(ctx, s.Pool, companyID); berr == nil {
|
||||
p.BrandPrompt = brand.PromptBlock()
|
||||
}
|
||||
p.Language = company.LoadLanguage(ctx, s.Pool, companyID)
|
||||
var prompts aiprompts.Resolved
|
||||
if s.Prompts != nil {
|
||||
if resolved, perr := s.Prompts.Resolve(ctx, companyID, aiprompts.KeySEOMeta, p.Language); perr == nil {
|
||||
prompts = resolved
|
||||
}
|
||||
}
|
||||
mt, md, tok, err := FillMetaAI(ctx, completer, p, prompts)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
metaTitle, metaDesc, tokens = mt, md, tok
|
||||
if err := s.Billing.ConsumeCredits(ctx, companyID, tokens, "seo_meta_ai"); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
credits = 1
|
||||
if tokens > 0 {
|
||||
credits += (tokens + 999) / 1000
|
||||
}
|
||||
}
|
||||
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
UPDATE processed_products
|
||||
SET meta_title = $3, meta_description = $4, updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2`,
|
||||
productID, companyID, metaTitle, metaDesc)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ApplyResult{}, ErrNotFound
|
||||
}
|
||||
|
||||
return ApplyResult{
|
||||
ProductID: productID.String(),
|
||||
Mode: mode,
|
||||
MetaTitle: metaTitle,
|
||||
MetaDescription: metaDesc,
|
||||
CreditsCharged: credits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadProducts(ctx context.Context, companyID uuid.UUID) ([]ProductInput, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT p.id::text, COALESCE(p.product_id, ''), COALESCE(p.name, ''), COALESCE(p.processed_name, ''),
|
||||
COALESCE(p.description, ''), COALESCE(p.processed_description, ''),
|
||||
COALESCE(p.meta_title, ''), COALESCE(p.meta_description, ''), COALESCE(p.category, ''),
|
||||
COALESCE(p.attributes, '{}'::jsonb), COALESCE(p.processed_attributes, '{}'::jsonb),
|
||||
COALESCE(r.mapped_data, '{}'::jsonb)
|
||||
FROM processed_products p
|
||||
LEFT JOIN raw_products r ON r.id = p.raw_product_id
|
||||
WHERE p.company_id = $1
|
||||
ORDER BY p.updated_at DESC
|
||||
LIMIT $2`, companyID, maxProductsScan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]ProductInput, 0)
|
||||
for rows.Next() {
|
||||
var p ProductInput
|
||||
var attrs, procAttrs, mapped []byte
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.ProductID, &p.Name, &p.ProcessedName,
|
||||
&p.Description, &p.ProcessedDesc,
|
||||
&p.MetaTitle, &p.MetaDescription, &p.Category,
|
||||
&attrs, &procAttrs, &mapped,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Attributes = decodeMap(attrs)
|
||||
p.ProcessedAttrs = decodeMap(procAttrs)
|
||||
p.MappedData = decodeMap(mapped)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) loadOneProduct(ctx context.Context, companyID, id uuid.UUID) (ProductInput, error) {
|
||||
var p ProductInput
|
||||
var attrs, procAttrs, mapped []byte
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT p.id::text, COALESCE(p.product_id, ''), COALESCE(p.name, ''), COALESCE(p.processed_name, ''),
|
||||
COALESCE(p.description, ''), COALESCE(p.processed_description, ''),
|
||||
COALESCE(p.meta_title, ''), COALESCE(p.meta_description, ''), COALESCE(p.category, ''),
|
||||
COALESCE(p.attributes, '{}'::jsonb), COALESCE(p.processed_attributes, '{}'::jsonb),
|
||||
COALESCE(r.mapped_data, '{}'::jsonb)
|
||||
FROM processed_products p
|
||||
LEFT JOIN raw_products r ON r.id = p.raw_product_id
|
||||
WHERE p.id = $1 AND p.company_id = $2`, id, companyID).Scan(
|
||||
&p.ID, &p.ProductID, &p.Name, &p.ProcessedName,
|
||||
&p.Description, &p.ProcessedDesc,
|
||||
&p.MetaTitle, &p.MetaDescription, &p.Category,
|
||||
&attrs, &procAttrs, &mapped,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ProductInput{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ProductInput{}, err
|
||||
}
|
||||
p.Attributes = decodeMap(attrs)
|
||||
p.ProcessedAttrs = decodeMap(procAttrs)
|
||||
p.MappedData = decodeMap(mapped)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCategories(ctx context.Context, companyID uuid.UUID) ([]CategoryInput, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id::text, COALESCE(unique_id, ''), COALESCE(name, ''), COALESCE(description_template, '{}'::jsonb)
|
||||
FROM categories
|
||||
WHERE company_id = $1
|
||||
ORDER BY name
|
||||
LIMIT $2`, companyID, maxCategoriesScan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CategoryInput, 0)
|
||||
for rows.Next() {
|
||||
var c CategoryInput
|
||||
var tplBytes []byte
|
||||
if err := rows.Scan(&c.ID, &c.UniqueID, &c.Name, &tplBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tplBytes) > 0 {
|
||||
_ = json.Unmarshal(tplBytes, &c.DescriptionTemplate)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func decodeMap(b []byte) map[string]any {
|
||||
if len(b) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(b, &m); err != nil || m == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func prioritizeRecs(recs []Recommendation, limit int) []Recommendation {
|
||||
severityRank := map[string]int{
|
||||
SeverityCritical: 0,
|
||||
SeverityWarn: 1,
|
||||
SeverityInfo: 2,
|
||||
}
|
||||
// Stable partition by severity without full sort alloc if small.
|
||||
buckets := [3][]Recommendation{}
|
||||
for _, r := range recs {
|
||||
i := severityRank[r.Severity]
|
||||
if i < 0 || i > 2 {
|
||||
i = 2
|
||||
}
|
||||
buckets[i] = append(buckets[i], r)
|
||||
}
|
||||
out := make([]Recommendation, 0, limit)
|
||||
for _, b := range buckets {
|
||||
for _, r := range b {
|
||||
if len(out) >= limit {
|
||||
return out
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EnsureCost seeds seo_meta_ai processing cost (idempotent).
|
||||
func EnsureCost(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO processing_costs (feature_name, cost_per_unit, description, is_active)
|
||||
VALUES ('seo_meta_ai', 1, 'Credits per SEO AI meta fill', true)
|
||||
ON CONFLICT (feature_name) DO NOTHING`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
)
|
||||
|
||||
// FillMetaTemplate builds meta title/description from product fields (free, rule-based).
|
||||
func FillMetaTemplate(p ProductInput) (title, description string) {
|
||||
name := displayTitle(p)
|
||||
if name == "" {
|
||||
name = "Product"
|
||||
}
|
||||
cat := strings.TrimSpace(p.Category)
|
||||
brand := firstString(p.ProcessedAttrs, p.Attributes, p.MappedData, "brand", "Brand", "manufacturer")
|
||||
|
||||
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 = truncateRunes(strings.Join(parts, " | "), MetaTitleMaxChars)
|
||||
|
||||
body := strings.TrimSpace(p.ProcessedDesc)
|
||||
if body == "" {
|
||||
body = strings.TrimSpace(p.Description)
|
||||
}
|
||||
body = stripTags(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 = truncateRunes(collapseSpace(body), MetaDescriptionMaxChars)
|
||||
return title, description
|
||||
}
|
||||
|
||||
// FillMetaAI uses Completer to rewrite SEO meta (paid path).
|
||||
func FillMetaAI(ctx context.Context, completer processing.Completer, p ProductInput, prompts aiprompts.Resolved) (title, description string, tokens int, err error) {
|
||||
if completer == nil {
|
||||
return "", "", 0, fmt.Errorf("ai completer not configured")
|
||||
}
|
||||
name := displayTitle(p)
|
||||
desc := strings.TrimSpace(p.ProcessedDesc)
|
||||
if desc == "" {
|
||||
desc = strings.TrimSpace(p.Description)
|
||||
}
|
||||
brand := firstString(p.ProcessedAttrs, p.Attributes, p.MappedData, "brand", "Brand", "manufacturer")
|
||||
sysTpl := strings.TrimSpace(prompts.SystemTemplate)
|
||||
userTpl := strings.TrimSpace(prompts.UserTemplate)
|
||||
if def, ok := aiprompts.DefaultFor(aiprompts.KeySEOMeta); ok {
|
||||
if sysTpl == "" {
|
||||
sysTpl = def.SystemTemplate
|
||||
}
|
||||
if userTpl == "" {
|
||||
userTpl = def.UserTemplate
|
||||
}
|
||||
}
|
||||
vars := aiprompts.Vars{
|
||||
"name": name,
|
||||
"description": truncateRunes(desc, processing.MaxProductDescRunes),
|
||||
"category": strings.TrimSpace(p.Category),
|
||||
"brand": brand,
|
||||
"brand_voice": processing.CompactBrandPrompt(p.BrandPrompt),
|
||||
"language": company.LanguageLabel(p.Language),
|
||||
}
|
||||
system := strings.TrimSpace(aiprompts.Render(sysTpl, vars))
|
||||
user := strings.TrimSpace(aiprompts.Render(userTpl, vars))
|
||||
if user == "" {
|
||||
user = fmt.Sprintf("Name: %s\nCategory: %s\nDesc: %s",
|
||||
name, p.Category, truncateRunes(desc, processing.MaxProductDescRunes))
|
||||
}
|
||||
|
||||
comp, obj, cerr := processing.CompleteJSON(ctx, completer, system, user, processing.CompleteOptions{
|
||||
MaxTokens: processing.MaxTokensSEO,
|
||||
Temperature: processing.DefaultStructuredTemp,
|
||||
})
|
||||
tokens = comp.TotalTokens
|
||||
if cerr != nil && obj == nil {
|
||||
if comp.Text == "" {
|
||||
return "", "", tokens, cerr
|
||||
}
|
||||
// Parse failed — fall back to template
|
||||
mt, md := FillMetaTemplate(p)
|
||||
return mt, md, tokens, nil
|
||||
}
|
||||
mt, md := metaFieldsFromObj(obj)
|
||||
if mt == "" || md == "" {
|
||||
t2, d2 := FillMetaTemplate(p)
|
||||
if mt == "" {
|
||||
mt = t2
|
||||
}
|
||||
if md == "" {
|
||||
md = d2
|
||||
}
|
||||
}
|
||||
return truncateRunes(mt, MetaTitleMaxChars), truncateRunes(md, MetaDescriptionMaxChars), tokens, nil
|
||||
}
|
||||
|
||||
func metaFieldsFromObj(obj map[string]any) (title, desc string) {
|
||||
if obj == nil {
|
||||
return "", ""
|
||||
}
|
||||
title, _ = obj["meta_title"].(string)
|
||||
desc, _ = obj["meta_description"].(string)
|
||||
if title == "" {
|
||||
title, _ = obj["metaTitle"].(string)
|
||||
}
|
||||
if desc == "" {
|
||||
desc, _ = obj["metaDescription"].(string)
|
||||
}
|
||||
return strings.TrimSpace(title), strings.TrimSpace(desc)
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
s = collapseSpace(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 collapseSpace(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
func stripTags(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()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package seo
|
||||
|
||||
// Recommendation type identifiers (stable API contract).
|
||||
const (
|
||||
TypeMissingMetaTitle = "missing_meta_title"
|
||||
TypeMissingMetaDescription = "missing_meta_description"
|
||||
TypeThinTitle = "thin_title"
|
||||
TypeDuplicateTitle = "duplicate_title"
|
||||
TypeMissingImage = "missing_image"
|
||||
TypeWeakKeywords = "weak_keywords"
|
||||
TypeCategoryMissingMeta = "category_missing_meta_formula"
|
||||
TypeThinCategoryName = "thin_category_name"
|
||||
)
|
||||
|
||||
// Severity levels for checklist ordering.
|
||||
const (
|
||||
SeverityCritical = "critical"
|
||||
SeverityWarn = "warn"
|
||||
SeverityInfo = "info"
|
||||
)
|
||||
|
||||
// Entity kinds.
|
||||
const (
|
||||
EntityProduct = "product"
|
||||
EntityCategory = "category"
|
||||
)
|
||||
|
||||
// ApplyModeTemplate fills meta from product fields (free).
|
||||
const ApplyModeTemplate = "template"
|
||||
|
||||
// ApplyModeAI rewrites meta with LLM (paid / credits).
|
||||
const ApplyModeAI = "ai"
|
||||
|
||||
// ThinTitleMaxChars — titles at or below this length are "thin".
|
||||
const ThinTitleMaxChars = 20
|
||||
|
||||
// ThinCategoryNameMaxChars — category names at or below this are thin.
|
||||
const ThinCategoryNameMaxChars = 2
|
||||
|
||||
// MetaTitleMaxChars / MetaDescriptionMaxChars — SERP-friendly caps for template fill.
|
||||
const (
|
||||
MetaTitleMaxChars = 60
|
||||
MetaDescriptionMaxChars = 155
|
||||
)
|
||||
|
||||
// Recommendation is one actionable SEO finding.
|
||||
type Recommendation struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
EntityLabel string `json:"entity_label"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Fixable bool `json:"fixable"`
|
||||
FixModes []string `json:"fix_modes,omitempty"` // template | ai
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// ChecklistItem aggregates score for one recommendation type.
|
||||
type ChecklistItem struct {
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Severity string `json:"severity"`
|
||||
Count int `json:"count"`
|
||||
Affected int `json:"affected"` // unique entities
|
||||
Score float64 `json:"score"` // 0–100 (100 = none of this issue)
|
||||
Fixable bool `json:"fixable"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Report is the GET /api/seo/recommendations payload.
|
||||
type Report struct {
|
||||
CompanyID string `json:"company_id"`
|
||||
ProductCount int `json:"product_count"`
|
||||
CategoryCount int `json:"category_count"`
|
||||
OverallScore float64 `json:"overall_score"`
|
||||
CanUseAI bool `json:"can_use_ai"`
|
||||
Checklist []ChecklistItem `json:"checklist"`
|
||||
Recommendations []Recommendation `json:"recommendations"`
|
||||
Types []string `json:"types"`
|
||||
}
|
||||
|
||||
// ApplyRequest is POST /api/seo/apply body.
|
||||
type ApplyRequest struct {
|
||||
ProductID string `json:"product_id"`
|
||||
Mode string `json:"mode"` // template | ai
|
||||
}
|
||||
|
||||
// ApplyResult is the apply response.
|
||||
type ApplyResult struct {
|
||||
ProductID string `json:"product_id"`
|
||||
Mode string `json:"mode"`
|
||||
MetaTitle string `json:"meta_title"`
|
||||
MetaDescription string `json:"meta_description"`
|
||||
CreditsCharged int `json:"credits_charged,omitempty"`
|
||||
}
|
||||
|
||||
// AllTypes returns stable recommendation type keys (API contract).
|
||||
func AllTypes() []string {
|
||||
return []string{
|
||||
TypeMissingMetaTitle,
|
||||
TypeMissingMetaDescription,
|
||||
TypeThinTitle,
|
||||
TypeDuplicateTitle,
|
||||
TypeMissingImage,
|
||||
TypeWeakKeywords,
|
||||
TypeCategoryMissingMeta,
|
||||
TypeThinCategoryName,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user