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

238 lines
6.0 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 marketing
import (
"encoding/json"
"strings"
)
// QualityCheckKey identifies a completeness / SEO signal.
type QualityCheckKey string
const (
CheckTitle QualityCheckKey = "title"
CheckDescription QualityCheckKey = "description"
CheckMetaTitle QualityCheckKey = "meta_title"
CheckMetaDescription QualityCheckKey = "meta_description"
CheckCategory QualityCheckKey = "category"
CheckAttributes QualityCheckKey = "attributes"
CheckImage QualityCheckKey = "image"
)
// QualityCheck is one weighted gate in the score.
type QualityCheck struct {
Passed bool `json:"passed"`
Weight int `json:"weight"`
Label string `json:"label"`
}
// QualityResult is a 0100 completeness / SEO score.
type QualityResult struct {
Score int `json:"score"`
MaxScore int `json:"max_score"`
Grade string `json:"grade"`
Checks map[QualityCheckKey]QualityCheck `json:"checks"`
}
// ProductInput is the field snapshot used for scoring (no DB column required).
type ProductInput struct {
Name string
ProcessedName string
Description string
ProcessedDescription string
MetaTitle string
MetaDescription string
Category string
Attributes any
ProcessedAttributes any
MappedData map[string]any
}
var qualityWeights = map[QualityCheckKey]int{
CheckTitle: 20,
CheckDescription: 20,
CheckMetaTitle: 15,
CheckMetaDescription: 15,
CheckCategory: 10,
CheckAttributes: 10,
CheckImage: 10,
}
var qualityLabels = map[QualityCheckKey]string{
CheckTitle: "Title",
CheckDescription: "Description",
CheckMetaTitle: "Meta title",
CheckMetaDescription: "Meta description",
CheckCategory: "Category",
CheckAttributes: "Attributes",
CheckImage: "Image",
}
var qualityOrder = []QualityCheckKey{
CheckTitle, CheckDescription, CheckMetaTitle, CheckMetaDescription,
CheckCategory, CheckAttributes, CheckImage,
}
func hasText(value string, minLen int) bool {
return len(strings.TrimSpace(value)) >= minLen
}
func countAttributes(value any) int {
if value == nil {
return 0
}
switch v := value.(type) {
case []any:
return len(v)
case map[string]any:
return len(v)
case string:
s := strings.TrimSpace(v)
if s == "" || s == "{}" || s == "[]" || s == "null" {
return 0
}
var arr []any
if err := json.Unmarshal([]byte(s), &arr); err == nil {
return len(arr)
}
var obj map[string]any
if err := json.Unmarshal([]byte(s), &obj); err == nil {
return len(obj)
}
return 0
case []byte:
return countAttributes(string(v))
default:
b, err := json.Marshal(v)
if err != nil {
return 0
}
return countAttributes(string(b))
}
}
func hasImage(mapped map[string]any) bool {
if mapped == nil {
return false
}
keys := []string{"image", "image_link", "image_url", "images", "main_image", "primary_image", "picture", "photo"}
for _, key := range keys {
raw, ok := mapped[key]
if !ok || raw == nil {
continue
}
switch v := raw.(type) {
case string:
if strings.TrimSpace(v) != "" {
return true
}
case []any:
if len(v) > 0 {
return true
}
}
}
return false
}
func gradeFromScore(score int) string {
switch {
case score >= 90:
return "A"
case score >= 75:
return "B"
case score >= 60:
return "C"
case score >= 40:
return "D"
default:
return "F"
}
}
// ComputeProductQualityScore scores completeness + SEO fields (0100).
func ComputeProductQualityScore(in ProductInput) QualityResult {
mappedName := ""
mappedDesc := ""
if in.MappedData != nil {
if s, ok := in.MappedData["name"].(string); ok {
mappedName = s
} else if s, ok := in.MappedData["title"].(string); ok {
mappedName = s
}
if s, ok := in.MappedData["description"].(string); ok {
mappedDesc = s
}
}
passed := map[QualityCheckKey]bool{
CheckTitle: hasText(in.ProcessedName, 3) || hasText(in.Name, 3) || hasText(mappedName, 3),
CheckDescription: hasText(in.ProcessedDescription, 20) || hasText(in.Description, 20) ||
hasText(mappedDesc, 20),
CheckMetaTitle: hasText(in.MetaTitle, 10),
CheckMetaDescription: hasText(in.MetaDescription, 40),
CheckCategory: hasText(in.Category, 1),
CheckAttributes: countAttributes(in.ProcessedAttributes) > 0 || countAttributes(in.Attributes) > 0,
CheckImage: hasImage(in.MappedData),
}
score := 0
checks := make(map[QualityCheckKey]QualityCheck, len(qualityOrder))
for _, key := range qualityOrder {
w := qualityWeights[key]
ok := passed[key]
if ok {
score += w
}
checks[key] = QualityCheck{Passed: ok, Weight: w, Label: qualityLabels[key]}
}
return QualityResult{
Score: score,
MaxScore: 100,
Grade: gradeFromScore(score),
Checks: checks,
}
}
// ScoreFromProductMap builds ProductInput from a catalog row map and scores it.
func ScoreFromProductMap(m map[string]any) QualityResult {
in := ProductInput{
Name: asString(m["name"]),
ProcessedName: asString(m["processed_name"]),
Description: asString(m["description"]),
ProcessedDescription: asString(m["processed_description"]),
MetaTitle: asString(m["meta_title"]),
MetaDescription: asString(m["meta_description"]),
Category: asString(m["category"]),
Attributes: m["attributes"],
ProcessedAttributes: m["processed_attributes"],
}
if md, ok := m["mapped_data"].(map[string]any); ok {
in.MappedData = md
} else if raw, ok := m["mapped_data"].([]byte); ok && len(raw) > 0 {
var obj map[string]any
if json.Unmarshal(raw, &obj) == nil {
in.MappedData = obj
}
} else if s := asString(m["mapped_data"]); s != "" {
var obj map[string]any
if json.Unmarshal([]byte(s), &obj) == nil {
in.MappedData = obj
}
}
return ComputeProductQualityScore(in)
}
func asString(v any) string {
switch t := v.(type) {
case string:
return t
case []byte:
return string(t)
case nil:
return ""
default:
return ""
}
}