# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # dedup.py : dédoublonnage inter-sources par VIN # # Le même véhicule apparaît souvent chez plusieurs sources : le site du # concessionnaire ET le portail de son groupe (Le Prix du Gros ↔ HGrégoire, # Méga Centre ↔ concessions Laplante, Toutes les marques ↔ GM Côte-Nord…), # voire une petite annonce Kijiji. Le VIN (17 caractères, unique au monde) # est la clé de blocage parfaite : on ne regroupe QUE sur VIN identique et # valide — dédoublonnage volontairement conservateur, zéro faux positif. # # Pour chaque groupe de doublons on élit une annonce CANONIQUE : # autorité (concessionnaire direct > portail/regroupeur > petites # annonces) puis complétude de la fiche, puis ancienneté (first_seen). # La canonique garde la liste des autres offres dans `dup_sources` (JSON) ; # les doublons pointent vers elle via `dup_of` (uid canonique). Les # endpoints de liste filtrent `dup_of IS NULL` — les fiches détail restent # toutes accessibles par uid. # # `recompute()` repart de zéro à chaque appel (idempotent) : appelé à la fin # de chaque cycle d'ingestion (ingest.run) et disponible en one-shot : # python3 -m autoka.dedup # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import sqlite3 from pathlib import Path # VIN valide : 17 caractères, alphanumériques sans I, O ni Q _VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") _SOURCES_PATH = Path(__file__).resolve().parent.parent / "data" / "sources.json" # autorité par type de source (défaut : concessionnaire direct) _AUTHORITY = {"direct": 2, "portail": 1, "marketplace": 0} # champs comptés pour la complétude de la fiche (départage à autorité égale) _COMPLETENESS_FIELDS = ( "price", "mileage_km", "transmission", "fuel", "drivetrain", "body_type", "exterior_color", "engine", "description", "carfax_url", "trim", "year", ) def _source_authority() -> dict[str, int]: """source_id -> niveau d'autorité, depuis data/sources.json (champ `type`).""" try: registry = json.loads(_SOURCES_PATH.read_text(encoding="utf-8"))["sources"] except (OSError, ValueError, KeyError): return {} return {s["id"]: _AUTHORITY.get(s.get("type", "direct"), 2) for s in registry} def _score(row: sqlite3.Row, authority: dict[str, int]) -> tuple: """Clé de tri décroissante : la meilleure annonce du groupe gagne.""" completeness = sum(1 for f in _COMPLETENESS_FIELDS if row[f] not in (None, "")) try: n_images = len(json.loads(row["images"] or "[]")) except ValueError: n_images = 0 return ( authority.get(row["source"], 2), # concessionnaire direct d'abord completeness + min(n_images, 10) / 10.0, -(row["first_seen"] or 0), # à égalité : la plus ancienne ) def recompute(con: sqlite3.Connection, verbose: bool = False) -> dict: """Recalcule dup_of/dup_sources sur toutes les annonces actives.""" authority = _source_authority() # repartir de zéro : les groupes bougent à chaque cycle (ventes, retraits) con.execute("UPDATE vehicles SET dup_of=NULL, dup_sources=NULL" " WHERE dup_of IS NOT NULL OR dup_sources IS NOT NULL") rows = con.execute( """SELECT uid, source, vin, url, price, dealer_name, city, first_seen, images, mileage_km, transmission, fuel, drivetrain, body_type, exterior_color, engine, description, carfax_url, trim, year FROM vehicles WHERE active=1 AND length(vin)=17""").fetchall() groups: dict[str, list[sqlite3.Row]] = {} for r in rows: vin = (r["vin"] or "").upper() if _VIN_RE.match(vin): groups.setdefault(vin, []).append(r) n_groups = n_dups = 0 for vin, members in groups.items(): if len(members) < 2: continue members.sort(key=lambda r: _score(r, authority), reverse=True) canonical, dups = members[0], members[1:] dup_sources = [ {"uid": d["uid"], "source": d["source"], "url": d["url"], "price": d["price"], "dealer_name": d["dealer_name"], "city": d["city"]} for d in dups ] con.execute("UPDATE vehicles SET dup_sources=? WHERE uid=?", (json.dumps(dup_sources, ensure_ascii=False), canonical["uid"])) for d in dups: con.execute("UPDATE vehicles SET dup_of=? WHERE uid=?", (canonical["uid"], d["uid"])) n_groups += 1 n_dups += len(dups) if verbose: print(f" VIN {vin} : {canonical['uid']} <- " + ", ".join(d["uid"] for d in dups)) con.commit() out = {"vin_groups": n_groups, "duplicates_masked": n_dups, "vins_actifs": len(groups)} print(f"[auto-ka] dedup VIN : {n_groups} groupe(s), " f"{n_dups} doublon(s) masqué(s)") return out if __name__ == "__main__": import sys from . import db con = db.connect() recompute(con, verbose="-v" in sys.argv) con.close()