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>
This commit is contained in:
2026-02-24 18:53:41 +01:00
parent bb62bd6eb6
commit 1e5fc97bb7
24 changed files with 3181 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Parsers de tickets de courses

View File

@@ -0,0 +1,403 @@
"""
Parser pour les tickets de caisse PDF Leclerc.
Les PDFs Leclerc sont des scans d'images (pas de couche texte sélectionnable).
Le parser extrait l'image JPEG embarquée et applique Tesseract OCR pour
récupérer le texte, puis l'analyse ligne par ligne.
Prérequis système :
- Tesseract OCR 5.x installé :
Windows : https://github.com/UB-Mannheim/tesseract/wiki
Linux : apt install tesseract-ocr tesseract-ocr-fra
- Modèle français (fra.traineddata) dans le dossier tessdata.
Si les droits manquent pour écrire dans le dossier système,
placer les fichiers eng.traineddata et fra.traineddata dans
un dossier local et définir TESSDATA_PREFIX=<chemin_du_dossier>.
- pip install pytesseract pillow pdfplumber
Structure du ticket Leclerc :
>> CATEGORIE → titre de catégorie (gras sur ticket)
NOM PRODUIT PRIX TVA → article standard (1 ligne)
* NOM PRODUIT PRIX TVA → idem, avec marque promotion
NOM PRODUIT → article multi-unités (pas de prix ici)
QTY X PRIX_UNIT€ TOTAL TVA → ligne de prix (suite du précédent)
Total NN articles TOTAL_TTC → total avant remises
Bon reduction MONTANT → bon de réduction (peut se répéter)
CB MONTANT_FINAL → montant payé par carte
Dépendances Python : pdfplumber, pytesseract, Pillow
"""
import io
import os
import re
from datetime import date
from typing import Optional
import pdfplumber
import pytesseract
from PIL import Image
from tickettracker.models.receipt import Item, Receipt
# ---------------------------------------------------------------------------
# Configuration Tesseract
# ---------------------------------------------------------------------------
# Chemins Tesseract standards selon l'OS
_TESSERACT_PATHS = [
r"C:/Program Files/Tesseract-OCR/tesseract.exe",
r"C:/Program Files (x86)/Tesseract-OCR/tesseract.exe",
"/usr/bin/tesseract",
"/usr/local/bin/tesseract",
]
# 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 _configure_tesseract() -> None:
"""Détecte et configure le binaire Tesseract et le dossier tessdata.
Priorité :
1. Variable d'environnement TESSERACT_CMD (chemin vers le binaire)
2. Chemins standards Windows/Linux
Pour tessdata :
1. Variable TESSDATA_PREFIX déjà définie dans l'environnement
2. Dossier tessdata/ à côté de ce fichier (usage dev avec droits limités)
"""
# Binaire
cmd = os.environ.get("TESSERACT_CMD")
if not cmd:
for p in _TESSERACT_PATHS:
if os.path.isfile(p):
cmd = p
break
if cmd:
pytesseract.pytesseract.tesseract_cmd = cmd
# Tessdata (uniquement si pas déjà configuré)
if not os.environ.get("TESSDATA_PREFIX"):
# Cherche tessdata/ dans le répertoire du projet (2 niveaux au-dessus)
here = os.path.dirname(os.path.abspath(__file__))
local_tessdata = os.path.join(here, "..", "..", "tessdata")
if os.path.isdir(local_tessdata) and os.path.isfile(
os.path.join(local_tessdata, "fra.traineddata")
):
os.environ["TESSDATA_PREFIX"] = os.path.abspath(local_tessdata)
# ---------------------------------------------------------------------------
# Point d'entrée public
# ---------------------------------------------------------------------------
def parse(pdf_path: str) -> Receipt:
"""Parse un PDF de ticket Leclerc et retourne un ticket normalisé.
Args:
pdf_path: Chemin vers le fichier PDF du ticket Leclerc.
Le PDF doit être un scan image (type Leclerc classique).
Returns:
Receipt: Ticket de courses normalisé avec tous les articles et
leurs catégories.
Raises:
ValueError: Si la date ou le total sont introuvables.
RuntimeError: Si Tesseract n'est pas installé ou pas configuré.
"""
_configure_tesseract()
text = _extract_text_from_pdf(pdf_path)
return _parse_text(text)
# ---------------------------------------------------------------------------
# Extraction image + OCR
# ---------------------------------------------------------------------------
def _extract_text_from_pdf(pdf_path: str) -> str:
"""Extrait l'image JPEG du PDF et retourne le texte OCR.
Les tickets Leclerc sont une unique image haute résolution (1650×10386)
découpée sur plusieurs pages PDF. L'image est identique dans le flux de
chaque page ; on l'extrait une seule fois depuis la page 1.
"""
with pdfplumber.open(pdf_path) as pdf:
if not pdf.pages or not pdf.pages[0].images:
raise ValueError(
f"Le PDF '{pdf_path}' ne contient pas d'image en page 1. "
"Le format Leclerc attendu est un scan image (JPEG embarqué)."
)
img_obj = pdf.pages[0].images[0]
raw_jpeg = img_obj["stream"].get_rawdata()
img = Image.open(io.BytesIO(raw_jpeg))
# Amélioration légère de la lisibilité avant OCR :
# - Conversion en niveaux de gris (le ticket est noir sur blanc)
# - Resize au 2/3 : accélère le traitement sans perte significative
# (1650px → 1100px, le texte reste lisible à ~40px de hauteur)
img_gray = img.convert("L").resize(
(img.width * 2 // 3, img.height * 2 // 3),
Image.LANCZOS,
)
try:
text = pytesseract.image_to_string(
img_gray,
lang="fra+eng",
config="--psm 6 --oem 3", # bloc de texte uniforme, LSTM
)
except pytesseract.TesseractError as e:
raise RuntimeError(
f"Erreur Tesseract lors de l'OCR : {e}\n"
"Vérifiez que Tesseract est installé et que le modèle 'fra' est disponible.\n"
"Voir README ou commentaires dans tickettracker/parsers/leclerc.py."
) from e
return text
# ---------------------------------------------------------------------------
# Parsing du texte OCR
# ---------------------------------------------------------------------------
# Regex pour une ligne article standard :
# NOM PRODUIT PRIX CODE_TVA
#
# Problèmes OCR observés sur le ticket Leclerc Clichy :
# - Un seul espace entre nom et prix (pas d'alignement de colonnes garanti)
# - Artefacts de séparateur de colonne dans le nom : ' | ' ou ' — '
# - Prix avec 3 décimales (ex: "10.460" au lieu de "10.40")
# - Code TVA avec 0 devant : "01" au lieu de "1"
# - Préfixe "* " pour les articles en promotion
#
# Stratégie : le code TVA (1 ou 2 chiffres max) est TOUJOURS le dernier token ;
# le prix (N.NN ou N,NN) est toujours l'avant-dernier. On greedy-matche .+ pour
# le nom et le moteur de regex backtrackera jusqu'à trouver le bon découpage.
_ITEM_RE = re.compile(
r"^(?:\* )?" # préfixe optionnel * (non capturé)
r"(?P<name>.+)" # nom du produit (greedy → backtrack)
r"\s+"
r"(?P<price>\d{1,3}[.,]\d{2,3})" # prix : 2 ou 3 décimales (OCR)
r"\s+"
r"(?P<tva>0?\d{1,2})" # code TVA : 1 ou 4, parfois "01"
r"\s*$"
)
# Regex pour la ligne de prix des articles en multi-unités :
# 2 X 3.48€ 6.96 1
_MULTI_RE = re.compile(
r"^\s*(?P<qty>\d+)\s*[Xx]\s*(?P<unit_price>\d+[.,]\d+)€?"
r"\s+(?P<total>\d+[.,]\d{2})\s+(?P<tva>\d{1,2})\s*$"
)
# Regex pour la ligne de total général
_TOTAL_RE = re.compile(r"Total\s+\d+\s+articles\s+(?P<total>\d+[.,]\d{2})")
# Regex pour les remises
_REDUCTION_RE = re.compile(r"Bon\s+r[ée]duction\s+(?P<amount>\d+[.,]\d{2})", re.IGNORECASE)
# Regex pour le paiement CB (montant final payé)
_CB_RE = re.compile(r"^[Cc][Bb]\s+(?P<amount>\d+[.,]\d{2})\s*$")
# Regex pour la date dans la ligne "Caisse XXX DD mois YYYY HH:MM"
_DATE_RE = re.compile(
r"Caisse\s+\S+\s+(\d{1,2})\s+(\w+)\s+(\d{4})",
re.IGNORECASE,
)
# Regex pour l'identifiant de caisse (ordre "Caisse 018-0003 ...")
_CAISSE_RE = re.compile(r"Caisse\s+(\S+)", re.IGNORECASE)
def _parse_price(s: str) -> float:
"""Convertit une chaîne de prix (virgule ou point) en float."""
return float(s.replace(",", "."))
def _parse_text(text: str) -> Receipt:
"""Analyse le texte OCR d'un ticket Leclerc.
Retourne un Receipt avec tous les articles, catégories, date et total.
Note OCR : Tesseract peut commettre des erreurs sur des caractères
ambigus (0 ↔ 6, | ↔ 1, etc.). Le total du ticket est retourné tel qu'OCR
l'a lu ; il peut différer légèrement si des caractères sont mal reconnus.
"""
lines = text.splitlines()
delivery_date = _extract_date(lines)
order_id = _extract_caisse_id(lines)
items = _extract_items(lines)
total = _extract_total(lines)
return Receipt(
store="leclerc",
date=delivery_date,
total=total,
items=items,
order_id=order_id,
)
def _extract_date(lines: list[str]) -> date:
"""Extrait la date depuis la ligne 'Caisse XXX DD mois AAAA HH:MM'."""
for line in lines:
m = _DATE_RE.search(line)
if m:
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:
return date(year, month, day)
raise ValueError(
"Date introuvable dans le ticket Leclerc. "
"Attendu : 'Caisse XXX DD mois YYYY' dans l'OCR."
)
def _extract_caisse_id(lines: list[str]) -> Optional[str]:
"""Extrait le numéro de caisse (ex: '018-0003')."""
for line in lines:
m = _CAISSE_RE.search(line)
if m:
return m.group(1)
return None
def _extract_total(lines: list[str]) -> float:
"""Extrait le montant final payé.
Préférence au montant CB (après remises). Sinon, le 'Total N articles'.
"""
cb_total: Optional[float] = None
subtotal: Optional[float] = None
for line in lines:
m_cb = _CB_RE.match(line.strip())
if m_cb:
cb_total = _parse_price(m_cb.group("amount"))
m_tot = _TOTAL_RE.search(line)
if m_tot:
subtotal = _parse_price(m_tot.group("total"))
total = cb_total if cb_total is not None else subtotal
if total is None:
raise ValueError(
"Montant total introuvable dans le ticket Leclerc. "
"La structure du ticket a peut-être changé."
)
return total
def _extract_items(lines: list[str]) -> list[Item]:
"""Extrait tous les articles du texte OCR ligne par ligne.
Gère :
- Articles standard (1 ligne : nom + prix + code TVA)
- Articles en multi-unités (nom sur une ligne, QTY × PRIX sur la suivante)
- Changements de catégorie (lignes débutant par >>)
- Arrêt à la ligne 'Total N articles'
"""
items: list[Item] = []
current_category: Optional[str] = None
pending_name: Optional[str] = None # nom en attente de la ligne de prix
# On cherche d'abord la ligne "TTC TVA" qui marque le début des articles
in_items = False
for line in lines:
raw = line.strip()
# Début de la section articles (insensible à la casse : "TTc TvA" possible)
# Note OCR : Tesseract Linux lit parfois "TIc" au lieu de "TTC"
# (confusion T↔I en 2e position, fréquente avec Tesseract 5.x)
if not in_items:
if re.search(r"T[TI]C\s+TVA", raw, re.IGNORECASE):
in_items = True
continue
# Fin de la section articles
if _TOTAL_RE.search(raw):
break
# Ligne vide → ignore
if not raw:
continue
# Changement de catégorie (>> CATEGORIE)
if raw.startswith(">>"):
category_raw = raw.lstrip(">").strip()
current_category = re.sub(r"[|_—]+", " ", category_raw).strip()
pending_name = None
continue
# Pré-nettoyage des artefacts OCR dans la ligne avant les regex :
# '|' et '—' sont des séparateurs de colonnes que Tesseract voit parfois
# comme des caractères dans le texte.
clean = re.sub(r"\s*[|—]\s*", " ", raw).strip()
# Ligne de prix pour un article multi-unités (2 X 3.48€ 6.96 1)
m_multi = _MULTI_RE.match(clean)
if m_multi and pending_name is not None:
qty = float(m_multi.group("qty"))
unit_price = _parse_price(m_multi.group("unit_price"))
total_price = _parse_price(m_multi.group("total"))
items.append(Item(
name=_clean_name(pending_name),
quantity=qty,
unit="pièce",
unit_price=unit_price,
total_price=total_price,
category=current_category,
))
pending_name = None
continue
# Ligne article standard (nom prix tva) — regex greedy depuis la droite
m_item = _ITEM_RE.match(clean)
if m_item:
if pending_name is not None:
pending_name = None
name = _clean_name(m_item.group("name"))
# Tronque à 2 décimales pour corriger les prix OCR comme "10.460"
price = round(_parse_price(m_item.group("price")), 2)
items.append(Item(
name=name,
quantity=1.0,
unit="pièce",
unit_price=price,
total_price=price,
category=current_category,
))
continue
# Ligne sans prix reconnaissable → début d'un article multi-unités
if len(clean) > 4 and not re.match(r"^[\d\s.,|*_-]+$", clean):
pending_name = clean.lstrip("* ").strip()
return items
def _clean_name(name: str) -> str:
"""Nettoie le nom d'un article des artefacts OCR courants.
Artefacts observés sur le ticket Leclerc Clichy :
- ' |' ou '| ' en fin de nom (artefact de séparateur de colonne)
- ' _' ou '_ ' (bruit d'image)
- espaces multiples
"""
# Retire les artefacts de colonnes en fin de chaîne
name = re.sub(r"[\s|_—]+$", "", name)
# Normalise les espaces internes
name = re.sub(r"\s{2,}", " ", name)
return name.strip()

View File

@@ -0,0 +1,514 @@
"""
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}")