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,32 @@
|
||||
package marketing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
)
|
||||
|
||||
// clientError is a validation message safe to return to API clients.
|
||||
type clientError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *clientError) Error() string { return e.msg }
|
||||
|
||||
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
|
||||
func ClientMsg(msg string) error {
|
||||
return &clientError{msg: msg}
|
||||
}
|
||||
|
||||
// ClientError reports whether err is a known client-facing marketing error.
|
||||
// Feed create/update validation from PrepareCampaign is also exposed.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
var ce *clientError
|
||||
if errors.As(err, &ce) {
|
||||
return ce.msg, true
|
||||
}
|
||||
return feeds.ClientError(err)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package marketing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListPreparedCampaignsSQL_boundsAndFilters(t *testing.T) {
|
||||
if !strings.Contains(listPreparedCampaignsSQL, "LIMIT") {
|
||||
t.Fatal("expected SQL LIMIT on prepared campaigns list")
|
||||
}
|
||||
if !strings.Contains(listPreparedCampaignsSQL, "template ?") {
|
||||
t.Fatal("expected jsonb key filter so non-campaign feeds are skipped in SQL")
|
||||
}
|
||||
if maxPreparedCampaigns <= 0 || maxPreparedCampaigns > 2000 {
|
||||
t.Fatalf("maxPreparedCampaigns out of expected range: %d", maxPreparedCampaigns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackFridayDate2026(t *testing.T) {
|
||||
bf := BlackFridayDate(2026)
|
||||
if bf.Year() != 2026 || bf.Month() != 11 || bf.Day() != 27 {
|
||||
t.Fatalf("expected 2026-11-27, got %s", bf.Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBlackFridayWindow(t *testing.T) {
|
||||
p, err := ResolvePreset(PresetBlackFriday, 2026)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.StartDate != "2026-11-20" || p.EndDate != "2026-11-30" {
|
||||
t.Fatalf("unexpected window %s → %s", p.StartDate, p.EndDate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChristmas(t *testing.T) {
|
||||
p, err := ResolvePreset(PresetChristmas, 2026)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.StartDate != "2026-12-01" || p.EndDate != "2026-12-26" {
|
||||
t.Fatalf("unexpected christmas window %s → %s", p.StartDate, p.EndDate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeProductQualityScore(t *testing.T) {
|
||||
empty := ComputeProductQualityScore(ProductInput{})
|
||||
if empty.Score != 0 || empty.Grade != "F" {
|
||||
t.Fatalf("empty expected F/0, got %s/%d", empty.Grade, empty.Score)
|
||||
}
|
||||
|
||||
full := ComputeProductQualityScore(ProductInput{
|
||||
ProcessedName: "Great Widget Pro",
|
||||
ProcessedDescription: "A detailed product description that is long enough.",
|
||||
MetaTitle: "Great Widget Pro | Shop",
|
||||
MetaDescription: "Buy Great Widget Pro with free shipping and a two-year warranty today.",
|
||||
Category: "Widgets",
|
||||
ProcessedAttributes: map[string]any{"color": "red"},
|
||||
MappedData: map[string]any{"image": "https://example.com/w.jpg"},
|
||||
})
|
||||
if full.Score != 100 || full.Grade != "A" {
|
||||
t.Fatalf("full expected A/100, got %s/%d", full.Grade, full.Score)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package marketing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// PreparedCampaign is a seasonal preset linked to an export feed.
|
||||
type PreparedCampaign struct {
|
||||
PresetID PresetID `json:"preset_id"`
|
||||
Name string `json:"name"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Year int `json:"year"`
|
||||
ExportFeedID string `json:"export_feed_id"`
|
||||
ExportFeedName string `json:"export_feed_name"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
|
||||
// Service prepares content-calendar campaigns via export feeds (no new tables).
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
Feeds *feeds.Service
|
||||
}
|
||||
|
||||
// Cap matches httpapi maxPageLimit; seasonal presets over years stay well under this.
|
||||
const maxPreparedCampaigns = 200
|
||||
|
||||
// listPreparedCampaignsSQL filters to campaign feeds in SQL (jsonb key) and caps rows.
|
||||
const listPreparedCampaignsSQL = `
|
||||
SELECT id, name, template
|
||||
FROM export_feeds
|
||||
WHERE company_id = $1
|
||||
AND template ? $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3`
|
||||
|
||||
// ListPreparedCampaigns finds export feeds whose template contains _campaign meta.
|
||||
func (s *Service) ListPreparedCampaigns(ctx context.Context, companyID uuid.UUID) ([]PreparedCampaign, error) {
|
||||
rows, err := s.Pool.Query(ctx, listPreparedCampaignsSQL, companyID, CampaignStructureKey, maxPreparedCampaigns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]PreparedCampaign, 0)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var name string
|
||||
var tpl []byte
|
||||
if err := rows.Scan(&id, &name, &tpl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, ok := readCampaignMeta(tpl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, PreparedCampaign{
|
||||
PresetID: meta.PresetID,
|
||||
Name: meta.Name,
|
||||
StartDate: meta.StartDate,
|
||||
EndDate: meta.EndDate,
|
||||
Year: meta.Year,
|
||||
ExportFeedID: id.String(),
|
||||
ExportFeedName: name,
|
||||
Created: false,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].StartDate < out[j].StartDate
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PrepareInput creates or reuses a seasonal export feed.
|
||||
type PrepareInput struct {
|
||||
PresetID PresetID
|
||||
Year int
|
||||
Format string
|
||||
ForceNew bool
|
||||
}
|
||||
|
||||
// PrepareCampaign creates (or reuses) a CSV/XML export feed for a seasonal preset.
|
||||
func (s *Service) PrepareCampaign(ctx context.Context, companyID uuid.UUID, in PrepareInput) (PreparedCampaign, error) {
|
||||
year := in.Year
|
||||
if year == 0 {
|
||||
year = time.Now().UTC().Year()
|
||||
}
|
||||
preset, err := ResolvePreset(in.PresetID, year)
|
||||
if err != nil {
|
||||
return PreparedCampaign{}, err
|
||||
}
|
||||
format := in.Format
|
||||
if format == "" {
|
||||
format = "csv"
|
||||
}
|
||||
if format != "csv" && format != "xml" {
|
||||
return PreparedCampaign{}, ClientMsg("format must be csv or xml")
|
||||
}
|
||||
|
||||
if !in.ForceNew {
|
||||
existing, err := s.ListPreparedCampaigns(ctx, companyID)
|
||||
if err != nil {
|
||||
return PreparedCampaign{}, err
|
||||
}
|
||||
for _, c := range existing {
|
||||
if c.PresetID == in.PresetID && c.Year == year {
|
||||
c.Created = false
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meta := StructureMeta{
|
||||
PresetID: preset.ID,
|
||||
Name: preset.Name,
|
||||
StartDate: preset.StartDate,
|
||||
EndDate: preset.EndDate,
|
||||
Year: preset.Year,
|
||||
PreparedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
template := map[string]any{
|
||||
CampaignStructureKey: meta,
|
||||
"mappings": DefaultCampaignMappings(),
|
||||
}
|
||||
if format == "xml" {
|
||||
template["root"] = "rss"
|
||||
template["item"] = "channel/item"
|
||||
}
|
||||
|
||||
feedName := fmt.Sprintf("%s %d", preset.Name, year)
|
||||
created, err := s.Feeds.CreateExportFeed(ctx, companyID, feeds.CreateExportInput{
|
||||
Name: feedName,
|
||||
Format: format,
|
||||
Template: template,
|
||||
Filters: map[string]any{"statuses": []string{"completed"}},
|
||||
})
|
||||
if err != nil {
|
||||
return PreparedCampaign{}, err
|
||||
}
|
||||
|
||||
id, _ := created["id"].(uuid.UUID)
|
||||
name, _ := created["name"].(string)
|
||||
if name == "" {
|
||||
name = feedName
|
||||
}
|
||||
return PreparedCampaign{
|
||||
PresetID: preset.ID,
|
||||
Name: preset.Name,
|
||||
StartDate: preset.StartDate,
|
||||
EndDate: preset.EndDate,
|
||||
Year: preset.Year,
|
||||
ExportFeedID: id.String(),
|
||||
ExportFeedName: name,
|
||||
Created: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readCampaignMeta(raw []byte) (StructureMeta, bool) {
|
||||
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
|
||||
return StructureMeta{}, false
|
||||
}
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
return StructureMeta{}, false
|
||||
}
|
||||
metaRaw, ok := root[CampaignStructureKey]
|
||||
if !ok || metaRaw == nil {
|
||||
return StructureMeta{}, false
|
||||
}
|
||||
b, err := json.Marshal(metaRaw)
|
||||
if err != nil {
|
||||
return StructureMeta{}, false
|
||||
}
|
||||
var meta StructureMeta
|
||||
if err := json.Unmarshal(b, &meta); err != nil {
|
||||
return StructureMeta{}, false
|
||||
}
|
||||
if meta.PresetID == "" || meta.StartDate == "" || meta.EndDate == "" {
|
||||
return StructureMeta{}, false
|
||||
}
|
||||
return meta, true
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package marketing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PresetID identifies a seasonal content-calendar preset.
|
||||
type PresetID string
|
||||
|
||||
const (
|
||||
PresetBlackFriday PresetID = "black_friday"
|
||||
PresetChristmas PresetID = "christmas"
|
||||
)
|
||||
|
||||
// CampaignStructureKey is stored on export_feeds.template JSON (no migration).
|
||||
const CampaignStructureKey = "_campaign"
|
||||
|
||||
// Preset is a dated seasonal window for preparing an export feed.
|
||||
type Preset struct {
|
||||
ID PresetID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Year int `json:"year"`
|
||||
}
|
||||
|
||||
// StructureMeta is persisted under template._campaign.
|
||||
type StructureMeta struct {
|
||||
PresetID PresetID `json:"presetId"`
|
||||
Name string `json:"name"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
Year int `json:"year"`
|
||||
PreparedAt string `json:"preparedAt"`
|
||||
}
|
||||
|
||||
func pad2(n int) string {
|
||||
return fmt.Sprintf("%02d", n)
|
||||
}
|
||||
|
||||
func toISODate(t time.Time) string {
|
||||
return fmt.Sprintf("%s-%s-%s", pad2(t.Year()), pad2(int(t.Month())), pad2(t.Day()))
|
||||
}
|
||||
|
||||
// BlackFridayDate returns Black Friday (day after US Thanksgiving) in UTC date parts.
|
||||
func BlackFridayDate(year int) time.Time {
|
||||
nov1 := time.Date(year, time.November, 1, 0, 0, 0, 0, time.UTC)
|
||||
dow := int(nov1.Weekday()) // Sunday=0
|
||||
firstThursday := 1 + ((4 - dow + 7) % 7)
|
||||
fourthThursday := firstThursday + 21
|
||||
return time.Date(year, time.November, fourthThursday+1, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// ResolvePreset returns date window for a preset id and year.
|
||||
func ResolvePreset(id PresetID, year int) (Preset, error) {
|
||||
if year < 2000 || year > 2100 {
|
||||
return Preset{}, ClientMsg("invalid year")
|
||||
}
|
||||
switch id {
|
||||
case PresetBlackFriday:
|
||||
bf := BlackFridayDate(year)
|
||||
start := bf.AddDate(0, 0, -7)
|
||||
end := bf.AddDate(0, 0, 3)
|
||||
return Preset{
|
||||
ID: id,
|
||||
Name: "Black Friday",
|
||||
Description: "Promo window around Black Friday — prepare a Google Shopping-style export feed.",
|
||||
StartDate: toISODate(start),
|
||||
EndDate: toISODate(end),
|
||||
Year: year,
|
||||
}, nil
|
||||
case PresetChristmas:
|
||||
return Preset{
|
||||
ID: PresetChristmas,
|
||||
Name: "Christmas",
|
||||
Description: "Holiday catalog push from Dec 1 through Boxing Day.",
|
||||
StartDate: toISODate(time.Date(year, time.December, 1, 0, 0, 0, 0, time.UTC)),
|
||||
EndDate: toISODate(time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC)),
|
||||
Year: year,
|
||||
}, nil
|
||||
default:
|
||||
return Preset{}, ClientMsg("preset_id must be black_friday or christmas")
|
||||
}
|
||||
}
|
||||
|
||||
// ListPresets returns Black Friday + Christmas for the year.
|
||||
func ListPresets(year int) []Preset {
|
||||
bf, _ := ResolvePreset(PresetBlackFriday, year)
|
||||
xmas, _ := ResolvePreset(PresetChristmas, year)
|
||||
return []Preset{bf, xmas}
|
||||
}
|
||||
|
||||
// DefaultCampaignMappings are Google Shopping-ish CSV column → product field sources.
|
||||
func DefaultCampaignMappings() map[string]string {
|
||||
return map[string]string{
|
||||
"id": "product_id",
|
||||
"title": "processed_name",
|
||||
"description": "processed_description",
|
||||
"link": "attr.url",
|
||||
"image_link": "attr.image",
|
||||
"availability": "attr.availability",
|
||||
"price": "attr.price",
|
||||
"brand": "attr.brand",
|
||||
"gtin": "gtin",
|
||||
"google_product_category": "category",
|
||||
"condition": "attr.condition",
|
||||
}
|
||||
}
|
||||
@@ -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