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:
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