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%
7.2 KB · 185 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# recalls.py : rappels de sécurité — Base de données des rappels de véhicules5#              de Transports Canada (données ouvertes, gratuit)6#7#   Source : dump mensuel complet CSV du jeu de données « Vehicle Recalls8#   Database » (ouvert.canada.ca, jeu 1ec92326-47ef-4110-b7ca-959fab03f96d) :9#       https://opendatatc.tc.canada.ca/vrdb_full_monthly.csv  (~200 Mo)10#   1 ligne = 1 rappel × marque × modèle × année-modèle. L'API REST officielle11#   (tc.api.canada.ca) exige une clé délivrée manuellement — le CSV ouvert est12#   la voie sans clé, rafraîchi mensuellement par TC.13#14#   Table `recalls` (make/model/year normalisés en MAJUSCULES sans accents) +15#   croisement par (make, model, year) avec les annonces : le modèle TC est16#   souvent plus court que le nôtre (« CIVIC » vs « Civic Sport Touring ») —17#   on matche si l'un préfixe l'autre au premier mot près, année exacte.18#19#   Peuplement one-shot / mensuel :  python3 -m autoka.recalls [--limit-years N]20# -----------------------------------------------------------------------------21from __future__ import annotations2223import csv24import io25import sqlite326import sys27import time28import unicodedata2930import requests3132CSV_URL = "https://opendatatc.tc.canada.ca/vrdb_full_monthly.csv"3334_SCHEMA = """35CREATE TABLE IF NOT EXISTS recalls (36    recall_number TEXT NOT NULL,37    make          TEXT NOT NULL,   -- MAJUSCULES sans accents (norme TC)38    model         TEXT NOT NULL,   -- MAJUSCULES sans accents (norme TC)39    year          INTEGER,         -- année-modèle visée40    date          TEXT,            -- date du rappel (YYYY-MM-DD)41    component     TEXT,            -- système visé (FR, repli EN)42    description   TEXT,            -- description (FR, repli EN)43    category      TEXT,            -- Car / Truck / Motorcycle... (norme TC)44    units_affected INTEGER,45    PRIMARY KEY (recall_number, make, model, year)46);47CREATE INDEX IF NOT EXISTS idx_recalls_mmy ON recalls(make, model, year);48"""4950# alias marques Auto-Ka -> marque TC quand l'orthographe diffère51_MAKE_ALIASES = {52    "MERCEDES-BENZ": ("MERCEDES-BENZ", "MERCEDES"),53    "LAND ROVER": ("LAND ROVER", "LANDROVER"),54    "VOLKSWAGEN": ("VOLKSWAGEN", "VW"),55    "CHEVROLET": ("CHEVROLET", "CHEV"),56    "MINI": ("MINI", "BMW MINI"),57}585960def _norm(text: str | None) -> str:61    """MAJUSCULES sans accents, espaces réduits — clé de croisement."""62    if not text:63        return ""64    t = unicodedata.normalize("NFKD", text)65    t = "".join(c for c in t if not unicodedata.combining(c))66    return " ".join(t.upper().split())676869def ensure_schema(con: sqlite3.Connection) -> None:70    con.executescript(_SCHEMA)71    con.commit()727374# ---------------------------------------------------------------------------75# Peuplement depuis le dump CSV mensuel de Transports Canada76# ---------------------------------------------------------------------------7778def _fr_or_en(fr: str, en: str) -> str:79    fr = (fr or "").strip()80    if fr and fr.lower() not in ("translation not available", "non saisie"):81        return fr82    return (en or "").strip()838485def populate(con: sqlite3.Connection, csv_path: str | None = None,86             min_year: int = 1980) -> dict:87    """(Re)charge la table depuis le dump complet (téléchargé si besoin).8889    `min_year` écarte les années-modèles antérieures au parc plausible90    d'Auto-Ka (schema.py borne les annonces à 1980+).91    """92    ensure_schema(con)93    t0 = time.time()94    if csv_path:95        fh = open(csv_path, encoding="utf-8", errors="replace", newline="")96    else:97        print(f"[auto-ka] rappels TC : téléchargement {CSV_URL} ...")98        resp = requests.get(CSV_URL, timeout=600)99        resp.raise_for_status()100        fh = io.StringIO(resp.content.decode("utf-8", errors="replace"))101102    con.execute("DELETE FROM recalls")103    n = kept = 0104    with fh:105        for row in csv.DictReader(fh):106            n += 1107            try:108                year = int(float(row.get("YEAR") or 0)) or None109            except ValueError:110                year = None111            if year is not None and year < min_year:112                continue113            make = _norm(row.get("MAKE_NAME_NM"))114            model = _norm(row.get("MODEL_NAME_NM"))115            if not make or not row.get("RECALL_NUMBER_NUM"):116                continue117            try:118                units = int(float((row.get("UNIT_AFFECTED_NBR") or "0")119                                  .replace(",", "")))120            except ValueError:121                units = 0122            con.execute(123                """INSERT OR REPLACE INTO recalls (recall_number, make, model,124                   year, date, component, description, category, units_affected)125                   VALUES (?,?,?,?,?,?,?,?,?)""",126                (row["RECALL_NUMBER_NUM"], make, model, year,127                 (row.get("RECALL_DATE_DTE") or "")[:10],128                 _fr_or_en(row.get("SYSTEM_TYPE_FTXT"), row.get("SYSTEM_TYPE_ETXT")),129                 _fr_or_en(row.get("COMMENT_FTXT"), row.get("COMMENT_ETXT")),130                 row.get("CATEGORY_ETXT") or "",131                 units))132            kept += 1133    con.commit()134    total = con.execute("SELECT COUNT(*) c FROM recalls").fetchone()["c"]135    out = {"csv_rows": n, "kept": kept, "recalls_rows": total,136           "seconds": round(time.time() - t0, 1)}137    print(f"[auto-ka] rappels TC : {out}")138    return out139140141# ---------------------------------------------------------------------------142# Croisement avec une annonce (make/model/year normalisés)143# ---------------------------------------------------------------------------144145def for_vehicle(con: sqlite3.Connection, make: str, model: str,146                year: int | None) -> list[dict]:147    """Rappels TC visant ce (marque, modèle, année) — année exacte requise.148149    Le modèle TC est générique (« CIVIC », « F-150 ») alors que le nôtre porte150    parfois la version ; on matche par préfixe dans les deux sens, ancré sur151    le premier mot pour éviter les faux positifs.152    """153    if not make or not model or year is None:154        return []155    ensure_schema(con)156    n_make, n_model = _norm(make), _norm(model)157    makes = _MAKE_ALIASES.get(n_make, (n_make,))158    first_word = n_model.split()[0]159160    q = f"""SELECT recall_number, make, model, year, date, component,161                   description, category, units_affected162            FROM recalls163            WHERE make IN ({','.join('?' * len(makes))}) AND year=?164              AND (model=? OR model LIKE ? OR ? LIKE model || ' %'165                   OR (length(model) >= 3 AND ? LIKE model || '%'))166            ORDER BY date DESC"""167    rows = con.execute(q, (*makes, year, n_model, f"{first_word} %",168                           n_model, first_word)).fetchall()169    seen: set[str] = set()170    out = []171    for r in rows:172        if r["recall_number"] in seen:173            continue174        seen.add(r["recall_number"])175        out.append(dict(r))176    return out177178179if __name__ == "__main__":180    from . import db181    con = db.connect()182    path = sys.argv[1] if len(sys.argv) > 1 else None183    populate(con, csv_path=path)184    con.close()185