SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
12 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
5.3 KB · 129 lines python
Raw Blame History
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