""" Parser pour les mails de confirmation de commande Picnic. Les mails Picnic arrivent en HTML sur remora@dilain.com après un forward du mail de confirmation de livraison. Le corps du mail est encodé en Quoted-Printable (QP), ce parser le décode automatiquement avant d'analyser le HTML. Structure HTML Picnic identifiée : - Date de livraison dans le texte d'intro "livraison du JJ MOIS AAAA" - Numéro de commande dans "Commande : XXX-XXX-XXXX" - Articles : lignes HTML repérées par les images produit (domaine storefront-prod.fr.picnicinternational.com) Structure standard (7 colonnes directes) : col 0 : quantité dans un badge borduré col 2 : image produit (alt = nom du produit) col 4 : nom (font-size 15px) + format (font-size 12px, vert #234314) col 6 : prix splitté euros (font-size 26px) / centimes (17px) Structure corrompue (< 7 colonnes) : fallback via balises - Total dans une ligne labelisée Total Corruption QP connue dans ce mail : - Sauts de ligne doux (=\r\n) au milieu de séquences UTF-8 multi-octets - Balises corrompues : "<= /td>", "", "78 <= /strong>", etc. - Valeur d'attribut style qui déborde en contenu texte du td Dépendances : beautifulsoup4, lxml """ import quopri import re from datetime import date from typing import Optional from bs4 import BeautifulSoup from tickettracker.models.receipt import Item, Receipt # Correspondance noms de mois français → numéro _MOIS_FR = { "janvier": 1, "février": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6, "juillet": 7, "août": 8, "septembre": 9, "octobre": 10, "novembre": 11, "décembre": 12, } def parse(html_content: str) -> Receipt: """Parse un mail HTML Picnic et retourne un ticket normalisé. Args: html_content: Contenu HTML du mail de confirmation Picnic, potentiellement encodé en Quoted-Printable (format brut d'email). Returns: Receipt: Ticket de courses normalisé avec tous les articles. Raises: ValueError: Si la date ou le total sont introuvables dans le HTML. """ soup = _decode_and_parse(html_content) full_text = soup.get_text(" ", strip=True) delivery_date = _extract_date(full_text) order_id = _extract_order_id(full_text) items = _extract_items(soup) total = _extract_total(full_text) return Receipt( store="picnic", date=delivery_date, total=total, items=items, order_id=order_id, ) # --------------------------------------------------------------------------- # Décodage # --------------------------------------------------------------------------- def _decode_and_parse(html_content: str) -> BeautifulSoup: """Décode le Quoted-Printable et retourne un objet BeautifulSoup. Problème connu avec les mails Picnic : l'encodeur QP insère des sauts de ligne doux (=\\r\\n) au milieu de séquences UTF-8 multi-octets, avec une indentation HTML sur la ligne suivante. Par exemple : f=C3= =A9vrier 2026 devient après décodage QP naïf : f\\xc3⎵⎵⎵⎵\\xa9vrier (séquence cassée). Solution : on supprime d'abord les sauts doux ET leur indentation éventuelle (=\\r?\\n\\s*) avant de passer au décodeur QP standard, ce qui reconstitue correctement =C3=A9 → é. """ raw_bytes = html_content.encode("ascii", errors="replace") # Supprime les sauts de ligne doux QP et leur indentation HTML éventuelle raw_clean = re.sub(rb"=\r?\n\s*", b"", raw_bytes) decoded_bytes = quopri.decodestring(raw_clean) decoded_html = decoded_bytes.decode("utf-8", errors="replace") # Corrige les artefacts résiduels du double-encodage QP dans les attributs HTML. # # Certains encodeurs QP encodent aussi les '=' de la syntaxe HTML (attribut="valeur"), # créant des séquences comme alt==3D"..." dans l'email brut. Notre décodeur QP # résout le dernier =3D mais laisse le premier '=' → "alt=3D\"...\"" dans le HTML. # lxml interprète alors "alt=3D" comme valeur non-quotée et perd le vrai contenu. # # De même, un saut QP tombe parfois au milieu du nom d'attribut "src" → # "sr=c=3D\"...\"" → après décodage QP → "sr=c=\"...\"". # lxml crée un attribut "sr" au lieu de "src". # # Corrections : # "\w+=3D\"" → "\w+=\"" (ex: alt=3D" → alt=", src=3D" → src=") # "sr=c=\"" → "src=\"" (reconstruction du nom d'attribut corrompu) decoded_html = re.sub(r'(\w+=)3D"', r'\1"', decoded_html) decoded_html = re.sub(r'\bsr=c="', 'src="', decoded_html) return BeautifulSoup(decoded_html, "lxml") # --------------------------------------------------------------------------- # Date # --------------------------------------------------------------------------- def _extract_date(text: str) -> date: """Extrait la date de livraison depuis le texte du mail. Le mail contient une phrase du type : "Voici le reçu de votre livraison du samedi 14 février 2026." """ m = re.search( r"livraison du\s+\w+\s+(\d{1,2})\s+(\w+)\s+(\d{4})", text, re.IGNORECASE, ) if not m: raise ValueError( "Date de livraison introuvable dans le mail Picnic. " "Attendu : 'livraison du '" ) day = int(m.group(1)) month_str = m.group(2).lower().strip() year = int(m.group(3)) month = _MOIS_FR.get(month_str) if month is None: raise ValueError( f"Mois '{month_str}' non reconnu. " f"Mois attendus : {', '.join(_MOIS_FR)}" ) return date(year, month, day) # --------------------------------------------------------------------------- # Numéro de commande # --------------------------------------------------------------------------- def _extract_order_id(text: str) -> Optional[str]: """Extrait le numéro de commande Picnic (optionnel).""" m = re.search(r"Commande\s*:\s*([\d\-]+)", text) return m.group(1) if m else None # --------------------------------------------------------------------------- # Articles # --------------------------------------------------------------------------- def _extract_items(soup: BeautifulSoup) -> list[Item]: """Extrait la liste des articles depuis le HTML du mail. Pour chaque image produit, tente d'abord la lecture en ligne 7 colonnes (structure standard), puis utilise un fallback basé sur les balises pour les lignes dont le HTML est corrompu par l'encodage QP. """ items = [] seen_rows: set[int] = set() # Évite de traiter le même conteneur deux fois # La corruption QP insère des '=' dans les URLs de src (ex: "picnici=nternational.com"). # On les neutralise avant le test pour trouver toutes les images produit. product_imgs = soup.find_all( "img", src=lambda s: s and "picnicinternational.com" in s.replace("=", ""), ) for img in product_imgs: item = _try_7col(img, seen_rows) if item is None: item = _try_fallback(img, seen_rows) if item is not None: items.append(item) return items def _try_7col(img, seen_rows: set[int]) -> Optional[Item]: """Tente d'extraire un article depuis une ligne à 7 colonnes directes. Structure attendue : td[0] : badge quantité | td[2] : image | td[4] : nom+unité | td[6] : prix """ # Remonte jusqu'à trouver un avec exactement 7 directs tds = [] node = img.parent while node: if node.name == "tr": candidate = node.find_all("td", recursive=False) if len(candidate) == 7: tds = candidate break node = node.parent if len(tds) != 7: return None row_id = id(tds[0]) if row_id in seen_rows: return None seen_rows.add(row_id) # --- Quantité (colonne 0) --- qty_text = tds[0].get_text(strip=True) try: quantity = float(qty_text) except ValueError: return None # --- Nom et format/unité (colonne 4) --- # Approche positionnelle : 1er td feuille non vide = nom, 2e = unité. # On utilise uniquement les tds feuilles (sans td enfant) pour éviter que # le td parent récapitulatif ne double le texte. inner_texts = [ " ".join(inner_td.get_text().split()) for inner_td in tds[4].find_all("td") if not inner_td.find("td") and inner_td.get_text(strip=True) ] name = inner_texts[0] if inner_texts else img.get("alt", "Inconnu").strip() unit = inner_texts[1] if len(inner_texts) > 1 else "pièce" name = _clean_artifact(name) unit = _clean_unit(unit) # --- Prix total de la ligne (colonne 6) --- # Les balises sont plus robustes que get_text() pour éviter la # contamination par les valeurs CSS corrompues (ex: "#234314" dans du texte). total_price = _parse_price_from_cell(tds[6]) if total_price is None: return None unit_price = round(total_price / quantity, 4) if quantity > 0 else total_price return Item( name=name, quantity=quantity, unit=unit, unit_price=unit_price, total_price=total_price, ) def _try_fallback(img, seen_rows: set[int]) -> Optional[Item]: """Fallback pour les articles dont la ligne HTML est corrompue (< 7 td directs). Deux tentatives successives : 1. Tr avec >= 3 td directs ET >= 3 strongs chiffres (quantité + prix en tête) → utilisé pour les items dont le badge qty est dans un 2. Tr avec >= 1 td ET exactement 2 strongs chiffres (badge qty absent/corrompu) → utilisé quand seul le prix est dans des (ex: Jardin Bio, Alfapac) """ # --- Tentative 1 : badge qty dans un (cas standard) --- item_tr = None node = img.parent while node and node.name != "body": if node.name == "tr": tds = node.find_all("td", recursive=False) if len(tds) >= 3 and len(_get_digit_strongs(node)) >= 3: item_tr = node break node = node.parent if item_tr is not None: row_id = id(item_tr) if row_id not in seen_rows: seen_rows.add(row_id) digit_strongs = _get_digit_strongs(item_tr) try: quantity = float(digit_strongs[0]) except (ValueError, IndexError): return None # L'article est le PREMIER dans le tr → ses euros/centimes sont à [1] et [2]. # (Contrairement à _parse_price_from_cell qui utilise [-2][-1] car dans une # cellule dédiée, le dernier prix affiché est le prix réel après réduction.) try: total_price = float( f"{digit_strongs[1]}.{digit_strongs[2].zfill(2)}" ) except (ValueError, IndexError): return None name, unit = _extract_name_unit_fallback(img, item_tr) unit_price = round(total_price / quantity, 4) if quantity > 0 else total_price return Item( name=name, quantity=quantity, unit=unit, unit_price=unit_price, total_price=total_price, ) # --- Tentative 2 : badge qty corrompu, seul le prix est dans des --- # Ex: Jardin Bio, Alfapac dont le badge qty n'est pas dans un . item_tr = None node = img.parent while node and node.name != "body": if node.name == "tr": ds = _get_digit_strongs(node) if len(ds) == 2: item_tr = node break node = node.parent if item_tr is None: return None row_id = id(item_tr) if row_id in seen_rows: return None seen_rows.add(row_id) digit_strongs = _get_digit_strongs(item_tr) # Seulement 2 strongs → ce sont euros et centimes du prix (pas de badge qty strong) try: total_price = float(f"{digit_strongs[0]}.{digit_strongs[1].zfill(2)}") except (ValueError, IndexError): return None # Quantité : extraire le premier chiffre du texte brut du tr text = item_tr.get_text(strip=True) qty_match = re.match(r"(\d+)", text) quantity = float(qty_match.group(1)) if qty_match else 1.0 name, unit = _extract_name_unit_fallback(img, item_tr) unit_price = round(total_price / quantity, 4) if quantity > 0 else total_price return Item( name=name, quantity=quantity, unit=unit, unit_price=unit_price, total_price=total_price, ) def _extract_name_unit_fallback(img, item_tr) -> tuple[str, str]: """Extrait nom et unité pour un article en structure corrompue. Cherche d'abord dans le td ancêtre de l'image au sein de item_tr (cas 3-col : tout est collapsé dans td[0] à cause du QP), puis dans les td suivants (cas 5-col ou 6-col : nom+unité dans un td frère). """ name = img.get("alt", "Inconnu").strip() unit = "pièce" # Trouve le td direct de item_tr qui contient l'image img_container_td = None node = img.parent while node and node is not item_tr: if node.name == "td" and node.parent is item_tr: img_container_td = node break node = node.parent if img_container_td is None: return _clean_artifact(name), unit def _significant_texts(td): """Textes des tds feuilles significatifs (longueur > 3, non numérique). N'utilise que les tds feuilles (sans td enfant) pour éviter que les tds parents récapitulatifs ne doublent le nom/unité. Exclut les artefacts QP du type '= td>' qui commencent par '=' ou '<'. """ return [ " ".join(t.get_text().split()) for t in td.find_all("td") if not t.find("td") # td feuille uniquement and len(t.get_text(strip=True)) > 3 and not t.get_text(strip=True).isdigit() and not t.get_text(strip=True).startswith(("=", "<")) # exclut artefacts ] # Cas A : tout est dans le td de l'image (structure 3-col collapsed) inner = _significant_texts(img_container_td) if inner: name = inner[0] unit = inner[1] if len(inner) > 1 else "pièce" else: # Cas B : nom+unité dans un td frère après l'image (5-col ou 6-col) sibling = img_container_td.next_sibling while sibling: if hasattr(sibling, "find_all"): inner = _significant_texts(sibling) if inner: name = inner[0] unit = inner[1] if len(inner) > 1 else "pièce" break sibling = sibling.next_sibling return _clean_artifact(name), _clean_unit(unit) # --------------------------------------------------------------------------- # Helpers prix et nettoyage # --------------------------------------------------------------------------- def _get_digit_strongs(node) -> list[str]: """Retourne les valeurs des ne contenant que des chiffres. Prend le premier token whitespace de chaque pour ignorer les artefacts QP du type "78 <= /strong>" → premier token "78" (digit). """ result = [] for s in node.find_all("strong"): text = s.get_text(strip=True) first_token = text.split()[0] if text.split() else "" if first_token.isdigit(): result.append(first_token) return result def _parse_price_from_cell(td) -> Optional[float]: """Extrait le prix depuis une cellule de prix Picnic. Picnic split les euros (grande police) et les centimes (petite police) dans des éléments séparés. L'approche via est préférée à get_text() pour éviter la contamination par des valeurs CSS corrompues. On utilise les DEUX DERNIERS chiffres strongs car un article soldé affiche deux prix : barré (original) puis réel. Ex: '3 05 . 2 74 .' → prix=€2.74. """ digit_strongs = _get_digit_strongs(td) if len(digit_strongs) >= 2: euros = digit_strongs[-2] # avant-dernier = euros du prix réel centimes = digit_strongs[-1].zfill(2) # dernier = centimes return float(f"{euros}.{centimes}") # Fallback : extraction textuelle brute return _parse_price_from_text(td.get_text()) def _parse_price_from_text(raw_text: str) -> Optional[float]: """Parse un montant Picnic depuis le texte brut d'une cellule de prix (fallback). Stratégie : on extrait tous les chiffres consécutifs, et on interprète les 2 derniers comme les centimes, le reste comme les euros. Exemples : '358.' → '358' → 3€58 = 3.58 '065.' → '065' → 0€65 = 0.65 """ digits = re.sub(r"[^0-9]", "", raw_text) if len(digits) < 3: return None return float(f"{digits[:-2]}.{digits[-2:]}") def _clean_artifact(text: str) -> str: """Supprime les artefacts HTML/QP du texte. Trois types d'artefacts observés dans ce mail Picnic : - '<= /td>' : scission sur '<=' → partie avant - ' = tr>' : scission sur ' = ' avec espace avant - 'Soda zéro= td>' : scission sur '= ' sans espace avant """ text = text.split("<=")[0] text = re.split(r" = [a-z/]", text)[0] text = re.split(r"= [a-z]", text)[0] # ex: "zéro= td>" → "zéro" return text.strip() def _clean_unit(unit: str) -> str: """Nettoie l'unité ; retourne 'pièce' si le contenu ressemble à du CSS. La corruption QP peut faire déborder des valeurs d'attribut style dans le contenu texte d'un td (ex: '; color: #234314; padding: ...'). """ if unit.startswith(";") or "font-" in unit or "color:" in unit: return "pièce" return _clean_artifact(unit) # --------------------------------------------------------------------------- # Total # --------------------------------------------------------------------------- def _extract_total(full_text: str) -> float: """Extrait le montant total payé depuis le texte décodé du mail. Picnic affiche le prix splitté : les euros (grand) et les centimes (petit) sont dans des éléments HTML séparés, ce qui donne dans le texte brut : "Total Payé avec Paypal 95 10 ." On cherche "Total" (majuscule, pour ne pas capturer "Sous-total") puis les deux premiers groupes de chiffres qui suivent. Note : on utilise le texte plutôt que le DOM car lxml redécoupe les tables imbriquées de prix Picnic (euros/centimes dans des sœurs), rendant la navigation par arbre peu fiable. """ # "Total" majuscule pour exclure "Sous-total" (lowercase 't' en français) m = re.search(r"Total[^0-9]{0,60}?(\d+)\s+(\d+)\s*\.", full_text) if not m: raise ValueError( "Montant 'Total' introuvable dans le texte du mail Picnic. " "La structure HTML a peut-être changé." ) euros = m.group(1) centimes = m.group(2).zfill(2) return float(f"{euros}.{centimes}")