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,237 @@
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user