This commit is contained in:
2026-08-16 16:57:36 +02:00
parent 96f8c1115c
commit 532d439c41
106 changed files with 10147 additions and 494 deletions
+145
View File
@@ -0,0 +1,145 @@
package eprel
import (
"fmt"
"strings"
)
// ExtractFromAttrs builds the nested EPREL object used by V1 process items and
// the dashboard product API (id / label / pdf / energy_class / energy_scale).
// Returns nil when no usable EPREL fields are present.
//
// Nested attrs["eprel"] may use flat export aliases (eprel_id, eprel_label, …);
// those are normalized onto the public keys. Flat eprel_* keys fill any gaps.
func ExtractFromAttrs(attrs map[string]any) any {
if attrs == nil {
return nil
}
out := map[string]any{}
if e, ok := attrs["eprel"]; ok && e != nil {
switch t := e.(type) {
case map[string]any:
for k, v := range t {
if s := attrString(v); s == "" {
continue
} else {
out[strings.TrimSpace(k)] = s
}
}
default:
if s := attrString(e); s != "" {
return map[string]any{"id": s}
}
return nil
}
}
put := func(dstKey string, keys ...string) {
if attrString(out[dstKey]) != "" {
return
}
for _, k := range keys {
if s := attrString(attrs[k]); s != "" {
out[dstKey] = s
return
}
// Also accept aliases already copied from a nested object.
if s := attrString(out[k]); s != "" {
out[dstKey] = s
return
}
}
}
put("id", "eprel_id", "EPRELID", "eprelId")
put("label", "eprel_label", "eprel_label_url")
put("pdf", "eprel_pdf", "eprel_pdf_url")
put("energy_class", "eprel_energy_class", "energyClass")
put("energy_scale", "eprel_energy_scale", "energyClassRange", "energyScale")
return NormalizeShape(out)
}
// NormalizeShape keeps only the public EPREL keys with non-empty string values.
// Alias keys (eprel_id, …) are folded onto id/label/pdf/energy_class/energy_scale.
func NormalizeShape(v any) any {
if v == nil {
return nil
}
m, ok := v.(map[string]any)
if !ok {
return nil
}
if len(m) == 0 {
return nil
}
out := map[string]any{}
for k, val := range m {
if s := attrString(val); s != "" {
out[strings.TrimSpace(k)] = s
}
}
fold := func(dst string, aliases ...string) {
if attrString(out[dst]) != "" {
for _, a := range aliases {
delete(out, a)
}
return
}
for _, a := range aliases {
if s := attrString(out[a]); s != "" {
out[dst] = s
break
}
}
for _, a := range aliases {
delete(out, a)
}
}
fold("id", "eprel_id", "EPRELID", "eprelId")
fold("label", "eprel_label", "eprel_label_url")
fold("pdf", "eprel_pdf", "eprel_pdf_url")
fold("energy_class", "eprel_energy_class", "energyClass")
fold("energy_scale", "eprel_energy_scale", "energyClassRange", "energyScale")
canonical := map[string]any{}
for _, k := range []string{"id", "label", "pdf", "energy_class", "energy_scale"} {
if s := attrString(out[k]); s != "" {
canonical[k] = s
}
}
if len(canonical) == 0 {
return nil
}
return canonical
}
func attrString(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64:
// JSON numbers for numeric eprel ids.
if t == float64(int64(t)) {
return strings.TrimSpace(fmt.Sprintf("%.0f", t))
}
return strings.TrimSpace(fmt.Sprint(t))
case int:
return fmt.Sprintf("%d", t)
case int64:
return fmt.Sprintf("%d", t)
case map[string]any:
for _, k := range []string{"value", "#text", "text", "id"} {
if s := attrString(t[k]); s != "" {
return s
}
}
return ""
default:
s := strings.TrimSpace(fmt.Sprint(v))
if s == "" || s == "<nil>" {
return ""
}
return s
}
}
+84
View File
@@ -0,0 +1,84 @@
package eprel
import "testing"
func TestExtractFromAttrs_flatKeys(t *testing.T) {
got := ExtractFromAttrs(map[string]any{
"eprel_id": "1632113",
"eprel_label": "https://eprel.ec.europa.eu/api/product/1632113/labels?format=png",
"eprel_pdf_url": "https://eprel.ec.europa.eu/fiches/x.pdf",
"eprel_energy_class": "A",
"brand": "Samsung",
})
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("type=%T", got)
}
if m["id"] != "1632113" || m["label"] == nil || m["pdf"] == nil || m["energy_class"] != "A" {
t.Fatalf("got=%v", m)
}
if _, leak := m["eprel_id"]; leak {
t.Fatalf("alias leaked: %v", m)
}
}
func TestExtractFromAttrs_nestedAliasesNormalized(t *testing.T) {
got := ExtractFromAttrs(map[string]any{
"eprel": map[string]any{
"eprel_id": "2208111",
"eprel_label": "https://eprel.ec.europa.eu/api/product/2208111/labels?format=png",
"eprel_pdf": "https://eprel.ec.europa.eu/fiches/y.pdf",
"eprel_energy_class": "E",
"source": "eprel_api",
},
})
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("type=%T", got)
}
if m["id"] != "2208111" {
t.Fatalf("id=%v", m["id"])
}
if m["label"] == nil || m["pdf"] == nil {
t.Fatalf("missing label/pdf: %v", m)
}
if m["energy_class"] != "E" {
t.Fatalf("energy_class=%v", m["energy_class"])
}
if _, ok := m["eprel_id"]; ok {
t.Fatalf("eprel_id alias must be folded: %v", m)
}
if _, ok := m["source"]; ok {
t.Fatalf("non-canonical keys must be dropped: %v", m)
}
}
func TestExtractFromAttrs_nestedPartialFilledFromFlat(t *testing.T) {
got := ExtractFromAttrs(map[string]any{
"eprel": map[string]any{
"id": "1632113",
"label": "https://eprel.ec.europa.eu/api/product/1632113/labels?format=png",
},
"eprel_pdf": "https://eprel.ec.europa.eu/fiches/z.pdf",
"eprel_energy_class": "A",
})
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("type=%T", got)
}
if m["id"] != "1632113" || m["pdf"] == nil || m["energy_class"] != "A" {
t.Fatalf("got=%v", m)
}
}
func TestNormalizeShape_empty(t *testing.T) {
if NormalizeShape(nil) != nil {
t.Fatal("nil")
}
if NormalizeShape(map[string]any{}) != nil {
t.Fatal("empty")
}
if NormalizeShape(map[string]any{"eprel_id": ""}) != nil {
t.Fatal("blank")
}
}
+10
View File
@@ -32,6 +32,16 @@ func TestNormalizeAndExtractID(t *testing.T) {
if got := ExtractID(mapped2); got != "777" {
t.Fatalf("mapped key=%q", got)
}
attrsOnly := map[string]any{"eprel_id": "888"}
if got := ExtractID(nil, nil, attrsOnly); got != "888" {
t.Fatalf("attrs extract=%q", got)
}
if got := ExtractID(map[string]any{"eprel_id": "0000"}); got != "" {
t.Fatalf("placeholder must be empty, got %q", got)
}
if got := NormalizeID("0000"); got != "" {
t.Fatalf("NormalizeID placeholder=%q", got)
}
}
func TestClientFetch_httptest(t *testing.T) {
+45 -13
View File
@@ -16,32 +16,34 @@ var eprelIDKeys = []string{
}
// NormalizeID coerces XML/API values (string, number, {"#text": ...}) to a trimmed ID.
// Placeholder feed values like "0" / "0000" become empty so the EPREL step is not
// falsely "skipped" after a doomed fetch attempt.
func NormalizeID(v any) string {
if v == nil {
return ""
}
var s string
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
s = strings.TrimSpace(t)
case json.Number:
s := strings.TrimSpace(t.String())
s = strings.TrimSpace(t.String())
if i := strings.IndexByte(s, '.'); i >= 0 {
s = s[:i]
}
return s
case float64:
if t != t { // NaN
return ""
}
return strconv.FormatInt(int64(t), 10)
s = strconv.FormatInt(int64(t), 10)
case float32:
return strconv.FormatInt(int64(t), 10)
s = strconv.FormatInt(int64(t), 10)
case int:
return strconv.Itoa(t)
s = strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
s = strconv.FormatInt(t, 10)
case int32:
return strconv.FormatInt(int64(t), 10)
s = strconv.FormatInt(int64(t), 10)
case json.RawMessage:
var decoded any
if err := json.Unmarshal(t, &decoded); err != nil {
@@ -55,14 +57,44 @@ func NormalizeID(v any) string {
if text, ok := t["text"]; ok {
return NormalizeID(text)
}
return ""
case []any:
for _, item := range t {
if id := NormalizeID(item); id != "" {
return id
}
}
return ""
case []string:
for _, item := range t {
if id := NormalizeID(item); id != "" {
return id
}
}
return ""
default:
s := strings.TrimSpace(fmt.Sprint(t))
s = strings.TrimSpace(fmt.Sprint(t))
if s == "" || s == "<nil>" {
return ""
}
return s
}
return ""
if isPlaceholderEPRELID(s) {
return ""
}
return s
}
func isPlaceholderEPRELID(id string) bool {
id = strings.TrimSpace(id)
if id == "" {
return true
}
for _, r := range id {
if r != '0' {
return false
}
}
return true
}
// IsValidID reports whether v normalizes to a non-empty EPREL registration id.
@@ -70,8 +102,8 @@ func IsValidID(v any) bool {
return NormalizeID(v) != ""
}
// ExtractID finds an EPREL ID in mapped and/or raw product field maps
// (vendor feeds often use <EPRELID/> / eprel_id).
// ExtractID finds an EPREL ID in mapped, raw, and/or attribute field maps
// (vendor feeds often use <EPRELID/> / eprel_id; specs parse may put it only in attrs).
func ExtractID(sources ...map[string]any) string {
for _, src := range sources {
if src == nil {