238 lines
6.0 KiB
Go
238 lines
6.0 KiB
Go
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 0–100 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 (0–100).
|
|||
|
|
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 ""
|
|||
|
|
}
|
|||
|
|
}
|