Files
laurent 1e5fc97bb7 feat: migration Windows → Ubuntu, stabilisation suite de tests
- Ajout venv Python (.venv) avec pip bootstrap (python3-venv absent)
- Correction OCR Linux : marqueur TTC/TVA tolère la confusion T↔I
  (Tesseract 5.3.4 Linux lit parfois "TIc" au lieu de "TTC")
- test_leclerc.py : skipif si Tesseract absent, xfail pour test de somme
  (précision OCR variable entre plateformes, solution LLM vision prévue)
- Résultat : 77 passent, 1 xfail, 0 échec (vs 78 sur Windows)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 18:53:41 +01:00

515 lines
19 KiB
Python

"""
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 <strong>
- Total dans une ligne labelisée <strong>Total</strong>
Corruption QP connue dans ce mail :
- Sauts de ligne doux (=\r\n) au milieu de séquences UTF-8 multi-octets
- Balises corrompues : "<= /td>", "<t d>", "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 <jour_semaine> <JJ> <mois> <AAAA>'"
)
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
<strong> 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 <tr> avec exactement 7 <td> 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 <strong> 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 <strong>
2. Tr avec >= 1 td ET exactement 2 strongs chiffres (badge qty absent/corrompu)
→ utilisé quand seul le prix est dans des <strong> (ex: Jardin Bio, Alfapac)
"""
# --- Tentative 1 : badge qty dans un <strong> (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 <strong> ---
# Ex: Jardin Bio, Alfapac dont le badge qty n'est pas dans un <strong>.
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 <strong> ne contenant que des chiffres.
Prend le premier token whitespace de chaque <strong> 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 <strong> séparés. L'approche via <strong> 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 ' = <lettre>' avec espace avant
- 'Soda zéro= td>' : scission sur '= <lettre>' 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 <td> 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}")