Files

238 lines
6.0 KiB
Go
Raw Permalink Normal View History

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 ""
}
}