Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
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 ""
|
||
}
|
||
}
|