91 lines
3.0 KiB
Python
91 lines
3.0 KiB
Python
|
|
"""
|
|||
|
|
Fuzzy matching entre produits Picnic et Leclerc.
|
|||
|
|
|
|||
|
|
Utilise RapidFuzz (token_sort_ratio) pour rapprocher des produits dont le nom
|
|||
|
|
n'est pas identique mais désigne la même chose
|
|||
|
|
(ex : "Lait demi-écremé" ↔ "LAIT DEMI ECREME").
|
|||
|
|
|
|||
|
|
Workflow :
|
|||
|
|
1. find_fuzzy_matches() — calcule les paires candidates
|
|||
|
|
2. save_fuzzy_matches() — les insère dans product_matches (ignoring duplicates)
|
|||
|
|
3. L'utilisateur valide/rejette via le dashboard /matches
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import sqlite3
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
|
|||
|
|
from rapidfuzz import fuzz
|
|||
|
|
|
|||
|
|
|
|||
|
|
def find_fuzzy_matches(
|
|||
|
|
conn: sqlite3.Connection,
|
|||
|
|
threshold: float = 85.0,
|
|||
|
|
) -> list[dict]:
|
|||
|
|
"""Calcule les paires de produits similaires entre Picnic et Leclerc.
|
|||
|
|
|
|||
|
|
Utilise rapidfuzz.fuzz.token_sort_ratio (insensible à l'ordre des mots).
|
|||
|
|
Ne retourne que les paires avec score >= threshold.
|
|||
|
|
Les noms identiques sont exclus (ils sont déjà traités par get_compare_prices).
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conn: Connexion SQLite ouverte.
|
|||
|
|
threshold: Score minimum 0–100 (défaut 85).
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Liste de dicts {name_picnic, name_leclerc, score}, triée par score décroissant.
|
|||
|
|
"""
|
|||
|
|
# Noms normalisés distincts par enseigne
|
|||
|
|
picnic_names = [
|
|||
|
|
r[0]
|
|||
|
|
for r in conn.execute(
|
|||
|
|
"SELECT DISTINCT name_normalized FROM price_history "
|
|||
|
|
"WHERE store='picnic' AND name_normalized IS NOT NULL"
|
|||
|
|
)
|
|||
|
|
]
|
|||
|
|
leclerc_names = [
|
|||
|
|
r[0]
|
|||
|
|
for r in conn.execute(
|
|||
|
|
"SELECT DISTINCT name_normalized FROM price_history "
|
|||
|
|
"WHERE store='leclerc' AND name_normalized IS NOT NULL"
|
|||
|
|
)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Produit cartésien filtré par seuil
|
|||
|
|
matches = []
|
|||
|
|
for p in picnic_names:
|
|||
|
|
for lec in leclerc_names:
|
|||
|
|
if p == lec:
|
|||
|
|
continue # exact match déjà géré par get_compare_prices
|
|||
|
|
score = fuzz.token_sort_ratio(p, lec)
|
|||
|
|
if score >= threshold:
|
|||
|
|
matches.append({"name_picnic": p, "name_leclerc": lec, "score": score})
|
|||
|
|
|
|||
|
|
return sorted(matches, key=lambda x: -x["score"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def save_fuzzy_matches(conn: sqlite3.Connection, matches: list[dict]) -> int:
|
|||
|
|
"""Insère les nouvelles paires dans product_matches (ignore les doublons).
|
|||
|
|
|
|||
|
|
Utilise INSERT OR IGNORE pour ne pas écraser les paires déjà en base
|
|||
|
|
(statut 'validated' ou 'rejected' conservé).
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conn: Connexion SQLite ouverte.
|
|||
|
|
matches: Résultat de find_fuzzy_matches().
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Nombre de nouvelles paires réellement insérées.
|
|||
|
|
"""
|
|||
|
|
created_at = datetime.now(timezone.utc).isoformat()
|
|||
|
|
inserted = 0
|
|||
|
|
with conn:
|
|||
|
|
for m in matches:
|
|||
|
|
cur = conn.execute(
|
|||
|
|
"INSERT OR IGNORE INTO product_matches "
|
|||
|
|
"(name_picnic, name_leclerc, score, status, created_at) "
|
|||
|
|
"VALUES (?, ?, ?, 'pending', ?)",
|
|||
|
|
(m["name_picnic"], m["name_leclerc"], m["score"], created_at),
|
|||
|
|
)
|
|||
|
|
inserted += cur.rowcount
|
|||
|
|
return inserted
|