SPB Git

spb/wp37_uqo

Public
4.3 KB · 91 lines python
1"""Unit normalisation for grocery listings (CAD; g / ml / unit).23Re-implements and extends Food-Ka's parser (data/raw/foodka-src/foodka/normalize.py) so that the research4pipeline does not depend on production code. Differences are documented in reports/phase1_data_audit.md.5"""6from __future__ import annotations7import re, unicodedata8from dataclasses import dataclass910_UNITS = {"kg": (1000.0, "g"), "g": (1.0, "g"), "mg": (0.001, "g"), "lb": (453.592, "g"), "lbs": (453.592, "g"),11          "oz": (28.3495, "g"), "l": (1000.0, "ml"), "ml": (1.0, "ml"), "cl": (10.0, "ml"),12          "un": (1.0, "un"), "unite": (1.0, "un"), "unites": (1.0, "un"), "ea": (1.0, "un"), "each": (1.0, "un"),13          "pk": (1.0, "un"), "ch": (1.0, "un"), "pc": (1.0, "un"), "pcs": (1.0, "un"), "units": (1.0, "un"), "unit": (1.0, "un")}14_SIZE_RE = re.compile(r"(?:(\d+)\s*[x×*]\s*)?(\d+(?:[.,]\d+)?)\s*(kg|g|mg|lb|lbs|oz|ml|cl|l|un|unites?|ea|each|pk|ch|pcs?|units?)\b", re.I)15_RANGE_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:-|–|a|à)\s*(\d+(?:[.,]\d+)?)\s*(kg|g|lb|lbs|oz|ml|l)\b", re.I)16_PERKG_RE = re.compile(r"/\s*(kg|lb|100\s*g|100\s*ml|l|ch|un)\b", re.I)17_MULTI_RE = re.compile(r"^\s*(\d+)\s*(?:/|pour|for)\s*(?:(\d{1,4}(?:[.,]\d{1,2})?)\s*\$)?")18_APPROX_RE = re.compile(r"environ|approx|moyenne|avg|about|~", re.I)19_SOLD_INDIV_RE = re.compile(r"vendu individuellement|sold individually|a l'unite|à l'unité|l'unite|unite$", re.I)2021def strip_accents(s: str) -> str:22    return "".join(c for c in unicodedata.normalize("NFKD", s or "") if not unicodedata.combining(c))2324@dataclass25class Size:26    qty: float          # in base unit (g, ml, un)27    base: str           # 'g' | 'ml' | 'un'28    approx: bool = False29    source: str = "size_label"   # where it was parsed from3031def parse_size(label: str | None, name: str | None = None) -> Size | None:32    """Parse '450 g', '2 L', '12 x 355 ml', '6 un', 'Vendu individuellement', '1,36 kg'; falls back to the name."""33    for text, src in ((label, "size_label"), (name, "name")):34        if not text:35            continue36        t = strip_accents(text).lower().replace("\u00a0", " ")37        m = _RANGE_RE.search(t)38        if m:  # "1,2 - 1,5 kg" -> midpoint, approximate (checked before the simple pattern)39            a, b = float(m.group(1).replace(",", ".")), float(m.group(2).replace(",", "."))40            factor, base = _UNITS[m.group(3).lower()]41            return Size((a + b) / 2 * factor, base, True, src)42        m = _SIZE_RE.search(t)43        if m:44            mult = int(m.group(1)) if m.group(1) else 145            qty = float(m.group(2).replace(",", "."))46            factor, base = _UNITS[m.group(3).lower()]47            total = mult * qty * factor48            if total > 0:49                return Size(total, base, bool(_APPROX_RE.search(t)), src)50        if src == "size_label" and _SOLD_INDIV_RE.search(t):51            return Size(1.0, "un", False, src)52    return None5354@dataclass55class PriceLabel:56    per_weight_unit: str | None = None   # 'kg','lb','100 g','100 ml','l' when the displayed price is per weight/volume57    multibuy_n: int | None = None        # 'n / x $'58    multibuy_total: float | None = None5960def parse_price_label(label: str | None) -> PriceLabel:61    pl = PriceLabel()62    if not label:63        return pl64    t = strip_accents(label).lower().replace(" ", " ")65    m = _PERKG_RE.search(t)66    if m:67        pl.per_weight_unit = re.sub(r"\s+", " ", m.group(1))68    m = _MULTI_RE.match(t)69    if m:70        pl.multibuy_n = int(m.group(1))71        if m.group(2):72            pl.multibuy_total = float(m.group(2).replace(",", "."))73    return pl7475def unit_price(price: float | None, size: Size | None, label: PriceLabel | None = None) -> tuple[float, str] | None:76    """Comparable price: $/100 g, $/100 ml or $/unit. Returns None when not computable."""77    if price is None or price <= 0:78        return None79    if label and label.per_weight_unit:80        u = label.per_weight_unit81        if u == "kg": return (round(price / 10.0, 6), "100 g")82        if u == "lb": return (round(price / 4.53592, 6), "100 g")83        if u == "100 g": return (price, "100 g")84        if u == "100 ml": return (price, "100 ml")85        if u == "l": return (round(price / 10.0, 6), "100 ml")86    if size is None:87        return None88    if size.base == "un":89        return (round(price / size.qty, 6), "un")90    return (round(price / size.qty * 100.0, 6), f"100 {size.base}")91
Files · a7a52e3d94dfc207cf7e4b31060d2bd20303175c
Menu

Theme