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:
1
tickettracker/parsers/__init__.py
Normal file
1
tickettracker/parsers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Parsers de tickets de courses
|
||||
403
tickettracker/parsers/leclerc.py
Normal file
403
tickettracker/parsers/leclerc.py
Normal 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()
|
||||
514
tickettracker/parsers/picnic.py
Normal file
514
tickettracker/parsers/picnic.py
Normal 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}")
|
||||
Reference in New Issue
Block a user