Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# dedup.py : dédoublonnage inter-sources par VIN5#6# Le même véhicule apparaît souvent chez plusieurs sources : le site du7# concessionnaire ET le portail de son groupe (Le Prix du Gros ↔ HGrégoire,8# Méga Centre ↔ concessions Laplante, Toutes les marques ↔ GM Côte-Nord…),9# voire une petite annonce Kijiji. Le VIN (17 caractères, unique au monde)10# est la clé de blocage parfaite : on ne regroupe QUE sur VIN identique et11# valide — dédoublonnage volontairement conservateur, zéro faux positif.12#13# Pour chaque groupe de doublons on élit une annonce CANONIQUE :14# autorité (concessionnaire direct > portail/regroupeur > petites15# annonces) puis complétude de la fiche, puis ancienneté (first_seen).16# La canonique garde la liste des autres offres dans `dup_sources` (JSON) ;17# les doublons pointent vers elle via `dup_of` (uid canonique). Les18# endpoints de liste filtrent `dup_of IS NULL` — les fiches détail restent19# toutes accessibles par uid.20#21# `recompute()` repart de zéro à chaque appel (idempotent) : appelé à la fin22# de chaque cycle d'ingestion (ingest.run) et disponible en one-shot :23# python3 -m autoka.dedup24# -----------------------------------------------------------------------------25from __future__ import annotations2627import json28import re29import sqlite330from pathlib import Path3132# VIN valide : 17 caractères, alphanumériques sans I, O ni Q33_VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")3435_SOURCES_PATH = Path(__file__).resolve().parent.parent / "data" / "sources.json"3637# autorité par type de source (défaut : concessionnaire direct)38_AUTHORITY = {"direct": 2, "portail": 1, "marketplace": 0}3940# champs comptés pour la complétude de la fiche (départage à autorité égale)41_COMPLETENESS_FIELDS = (42 "price", "mileage_km", "transmission", "fuel", "drivetrain", "body_type",43 "exterior_color", "engine", "description", "carfax_url", "trim", "year",44)454647def _source_authority() -> dict[str, int]:48 """source_id -> niveau d'autorité, depuis data/sources.json (champ `type`)."""49 try:50 registry = json.loads(_SOURCES_PATH.read_text(encoding="utf-8"))["sources"]51 except (OSError, ValueError, KeyError):52 return {}53 return {s["id"]: _AUTHORITY.get(s.get("type", "direct"), 2) for s in registry}545556def _score(row: sqlite3.Row, authority: dict[str, int]) -> tuple:57 """Clé de tri décroissante : la meilleure annonce du groupe gagne."""58 completeness = sum(1 for f in _COMPLETENESS_FIELDS if row[f] not in (None, ""))59 try:60 n_images = len(json.loads(row["images"] or "[]"))61 except ValueError:62 n_images = 063 return (64 authority.get(row["source"], 2), # concessionnaire direct d'abord65 completeness + min(n_images, 10) / 10.0,66 -(row["first_seen"] or 0), # à égalité : la plus ancienne67 )686970def recompute(con: sqlite3.Connection, verbose: bool = False) -> dict:71 """Recalcule dup_of/dup_sources sur toutes les annonces actives."""72 authority = _source_authority()7374 # repartir de zéro : les groupes bougent à chaque cycle (ventes, retraits)75 con.execute("UPDATE vehicles SET dup_of=NULL, dup_sources=NULL"76 " WHERE dup_of IS NOT NULL OR dup_sources IS NOT NULL")7778 rows = con.execute(79 """SELECT uid, source, vin, url, price, dealer_name, city, first_seen,80 images, mileage_km, transmission, fuel, drivetrain,81 body_type, exterior_color, engine, description, carfax_url,82 trim, year83 FROM vehicles WHERE active=1 AND length(vin)=17""").fetchall()8485 groups: dict[str, list[sqlite3.Row]] = {}86 for r in rows:87 vin = (r["vin"] or "").upper()88 if _VIN_RE.match(vin):89 groups.setdefault(vin, []).append(r)9091 n_groups = n_dups = 092 for vin, members in groups.items():93 if len(members) < 2:94 continue95 members.sort(key=lambda r: _score(r, authority), reverse=True)96 canonical, dups = members[0], members[1:]97 dup_sources = [98 {"uid": d["uid"], "source": d["source"], "url": d["url"],99 "price": d["price"], "dealer_name": d["dealer_name"],100 "city": d["city"]}101 for d in dups102 ]103 con.execute("UPDATE vehicles SET dup_sources=? WHERE uid=?",104 (json.dumps(dup_sources, ensure_ascii=False),105 canonical["uid"]))106 for d in dups:107 con.execute("UPDATE vehicles SET dup_of=? WHERE uid=?",108 (canonical["uid"], d["uid"]))109 n_groups += 1110 n_dups += len(dups)111 if verbose:112 print(f" VIN {vin} : {canonical['uid']} <- "113 + ", ".join(d["uid"] for d in dups))114115 con.commit()116 out = {"vin_groups": n_groups, "duplicates_masked": n_dups,117 "vins_actifs": len(groups)}118 print(f"[auto-ka] dedup VIN : {n_groups} groupe(s), "119 f"{n_dups} doublon(s) masqué(s)")120 return out121122123if __name__ == "__main__":124 import sys125 from . import db126 con = db.connect()127 recompute(con, verbose="-v" in sys.argv)128 con.close()129