Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
323 lines
8.0 KiB
Go
323 lines
8.0 KiB
Go
package eprel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://eprel.ec.europa.eu/api"
|
|
defaultTimeout = 10 * time.Second
|
|
defaultFicheLanguage = "EN"
|
|
maxBodyBytes = 2 << 20
|
|
)
|
|
|
|
var allowedFicheLanguages = map[string]struct{}{
|
|
"EN": {}, "DE": {}, "FR": {}, "NL": {}, "ES": {}, "IT": {},
|
|
}
|
|
|
|
func isAllowedFicheLanguage(code string) bool {
|
|
_, ok := allowedFicheLanguages[code]
|
|
return ok
|
|
}
|
|
|
|
// Data is the public energy-label payload attached to processed products.
|
|
type Data struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
PDF string `json:"pdf,omitempty"`
|
|
EnergyClass string `json:"energy_class,omitempty"`
|
|
EnergyScale string `json:"energy_scale,omitempty"`
|
|
}
|
|
|
|
// Fetcher is the test seam for EPREL HTTP calls.
|
|
type Fetcher interface {
|
|
Enabled() bool
|
|
Fetch(ctx context.Context, eprelID string) (*Data, error)
|
|
}
|
|
|
|
// Client calls the public EPREL product API with timeouts and bounded bodies.
|
|
type Client struct {
|
|
BaseURL string
|
|
FicheLanguage string
|
|
APIKey string // optional; never logged
|
|
HTTP *http.Client
|
|
enabled bool
|
|
}
|
|
|
|
// Options configures a Client.
|
|
type Options struct {
|
|
Enabled bool
|
|
BaseURL string
|
|
Timeout time.Duration
|
|
FicheLanguage string
|
|
APIKey string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// NewClient builds an HTTP Fetcher. When Enabled is false, Fetch is a no-op.
|
|
func NewClient(opts Options) *Client {
|
|
timeout := opts.Timeout
|
|
if timeout <= 0 {
|
|
timeout = defaultTimeout
|
|
}
|
|
base := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
|
|
if base == "" {
|
|
base = defaultBaseURL
|
|
}
|
|
lang := strings.TrimSpace(opts.FicheLanguage)
|
|
if lang == "" {
|
|
lang = defaultFicheLanguage
|
|
} else {
|
|
lang = strings.ToUpper(lang)
|
|
if !isAllowedFicheLanguage(lang) {
|
|
lang = defaultFicheLanguage
|
|
}
|
|
}
|
|
httpClient := opts.HTTPClient
|
|
if httpClient == nil {
|
|
httpClient = security.SafeHTTPClient(timeout, false)
|
|
} else if httpClient.Timeout == 0 {
|
|
cloned := *httpClient
|
|
cloned.Timeout = timeout
|
|
httpClient = &cloned
|
|
}
|
|
return &Client{
|
|
BaseURL: base,
|
|
FicheLanguage: lang,
|
|
APIKey: strings.TrimSpace(opts.APIKey),
|
|
HTTP: httpClient,
|
|
enabled: opts.Enabled,
|
|
}
|
|
}
|
|
|
|
// Enabled reports whether EPREL enrichment is active.
|
|
func (c *Client) Enabled() bool {
|
|
return c != nil && c.enabled
|
|
}
|
|
|
|
// Fetch loads label URL, product fiche PDF, and energy class for a registration id.
|
|
// Partial success is returned when some sub-calls fail (label URL is always set for a valid id).
|
|
func (c *Client) Fetch(ctx context.Context, eprelID string) (*Data, error) {
|
|
if !c.Enabled() {
|
|
return nil, nil
|
|
}
|
|
id := NormalizeID(eprelID)
|
|
if id == "" {
|
|
return nil, nil
|
|
}
|
|
if err := validateID(id); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &Data{
|
|
ID: id,
|
|
Label: fmt.Sprintf("%s/product/%s/labels?format=png", c.BaseURL, url.PathEscape(id)),
|
|
}
|
|
|
|
if pdf, err := c.fetchFichePDF(ctx, id); err == nil && pdf != "" {
|
|
out.PDF = pdf
|
|
}
|
|
if class, scale, err := c.fetchProductInfo(ctx, id); err == nil {
|
|
out.EnergyClass = class
|
|
out.EnergyScale = scale
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func validateID(id string) error {
|
|
if len(id) > 64 {
|
|
return fmt.Errorf("eprel id too long")
|
|
}
|
|
for _, r := range id {
|
|
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '-' || r == '_' {
|
|
continue
|
|
}
|
|
return fmt.Errorf("eprel id has invalid characters")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) fetchFichePDF(ctx context.Context, id string) (string, error) {
|
|
path := fmt.Sprintf("/product/%s/fiches", url.PathEscape(id))
|
|
q := url.Values{}
|
|
q.Set("noRedirect", "true")
|
|
q.Set("language", c.FicheLanguage)
|
|
raw, err := c.get(ctx, path, q)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var payload struct {
|
|
Address string `json:"address"`
|
|
}
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return "", err
|
|
}
|
|
addr := strings.TrimSpace(payload.Address)
|
|
if addr == "" {
|
|
return "", nil
|
|
}
|
|
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
|
|
return addr, nil
|
|
}
|
|
if !strings.HasPrefix(addr, "/") {
|
|
addr = "/" + addr
|
|
}
|
|
origin := originFromBase(c.BaseURL)
|
|
return origin + addr, nil
|
|
}
|
|
|
|
func (c *Client) fetchProductInfo(ctx context.Context, id string) (class, scale string, err error) {
|
|
raw, err := c.get(ctx, fmt.Sprintf("/product/%s", url.PathEscape(id)), nil)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
var payload struct {
|
|
EnergyClass string `json:"energyClass"`
|
|
EnergyClassRange string `json:"energyClassRange"`
|
|
EnergyScale string `json:"energyScale"`
|
|
}
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return "", "", err
|
|
}
|
|
class = strings.ReplaceAll(strings.TrimSpace(payload.EnergyClass), "_", "-")
|
|
scale = strings.TrimSpace(payload.EnergyClassRange)
|
|
if scale == "" {
|
|
scale = strings.TrimSpace(payload.EnergyScale)
|
|
}
|
|
return class, scale, nil
|
|
}
|
|
|
|
func (c *Client) get(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
|
u, err := url.Parse(c.BaseURL + path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if query != nil {
|
|
u.RawQuery = query.Encode()
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "Descrybe-EPREL/2.0")
|
|
if c.APIKey != "" {
|
|
req.Header.Set("X-API-KEY", c.APIKey)
|
|
}
|
|
res, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
limited := io.LimitReader(res.Body, maxBodyBytes+1)
|
|
raw, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(raw) > maxBodyBytes {
|
|
return nil, fmt.Errorf("eprel response too large")
|
|
}
|
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("eprel api %s", strconv.Itoa(res.StatusCode))
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func originFromBase(base string) string {
|
|
u, err := url.Parse(base)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
return "https://eprel.ec.europa.eu"
|
|
}
|
|
return u.Scheme + "://" + u.Host
|
|
}
|
|
|
|
// AttributeKeys are the processed_attributes keys written for exports/mapping.
|
|
const (
|
|
AttrID = "eprel_id"
|
|
AttrLabel = "eprel_label"
|
|
AttrLabelURL = "eprel_label_url"
|
|
AttrPDF = "eprel_pdf"
|
|
AttrPDFURL = "eprel_pdf_url"
|
|
AttrEnergyClass = "eprel_energy_class"
|
|
AttrEnergyScale = "eprel_energy_scale"
|
|
)
|
|
|
|
// MergeInto copies EPREL fields into dest (creates map if nil). Returns dest.
|
|
// Writes both flat eprel_* keys (export mapping) and a nested "eprel" object (API shape).
|
|
func MergeInto(dest map[string]any, data *Data) map[string]any {
|
|
if data == nil || data.ID == "" {
|
|
return dest
|
|
}
|
|
if dest == nil {
|
|
dest = map[string]any{}
|
|
}
|
|
dest[AttrID] = data.ID
|
|
if data.Label != "" {
|
|
dest[AttrLabel] = data.Label
|
|
dest[AttrLabelURL] = data.Label
|
|
}
|
|
if data.PDF != "" {
|
|
dest[AttrPDF] = data.PDF
|
|
dest[AttrPDFURL] = data.PDF
|
|
}
|
|
if data.EnergyClass != "" {
|
|
dest[AttrEnergyClass] = data.EnergyClass
|
|
}
|
|
if data.EnergyScale != "" {
|
|
dest[AttrEnergyScale] = data.EnergyScale
|
|
}
|
|
nested := map[string]any{
|
|
"id": data.ID,
|
|
"label": data.Label,
|
|
}
|
|
if data.PDF != "" {
|
|
nested["pdf"] = data.PDF
|
|
}
|
|
if data.EnergyClass != "" {
|
|
nested["energy_class"] = data.EnergyClass
|
|
}
|
|
if data.EnergyScale != "" {
|
|
nested["energy_scale"] = data.EnergyScale
|
|
}
|
|
dest["eprel"] = nested
|
|
return dest
|
|
}
|
|
|
|
// FieldValue returns a single export field from Data (empty when missing).
|
|
func FieldValue(data *Data, fieldName string) string {
|
|
if data == nil {
|
|
return ""
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(fieldName)) {
|
|
case AttrID, "eprelid":
|
|
return data.ID
|
|
case AttrLabel, AttrLabelURL:
|
|
return data.Label
|
|
case AttrPDF, AttrPDFURL:
|
|
return data.PDF
|
|
case AttrEnergyClass:
|
|
return data.EnergyClass
|
|
case AttrEnergyScale:
|
|
return data.EnergyScale
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// Disabled is a no-op Fetcher used when EPREL_ENABLED is false.
|
|
type Disabled struct{}
|
|
|
|
func (Disabled) Enabled() bool { return false }
|
|
|
|
func (Disabled) Fetch(context.Context, string) (*Data, error) { return nil, nil }
|