SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
12.0 KB · 296 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# web.py : API FastAPI (JSON) + service du frontend React (frontend/dist)5# -----------------------------------------------------------------------------6from __future__ import annotations78import json9import threading10from pathlib import Path1112from fastapi import BackgroundTasks, FastAPI, HTTPException, Query13from fastapi.middleware.cors import CORSMiddleware14from fastapi.responses import FileResponse15from fastapi.staticfiles import StaticFiles1617from . import db, ingest1819ROOT = Path(__file__).resolve().parent.parent20SOURCES_PATH = ROOT / "data" / "sources.json"21FRONTEND_DIST = ROOT / "frontend" / "dist"2223app = FastAPI(title="Auto-Ka API", version="1.0",24              description="Agrégateur de voitures usagées — province de Québec")25app.add_middleware(CORSMiddleware, allow_origins=["*"],26                   allow_methods=["*"], allow_headers=["*"])2728_sync_lock = threading.Lock()293031def _row_to_dict(row) -> dict:32    d = dict(row)33    d["features"] = json.loads(d.get("features") or "[]")34    d["images"] = json.loads(d.get("images") or "[]")35    d["details"] = json.loads(d.get("details") or "{}")36    return d373839def _apply_filters(sql: str, args: list, *, make=None, model=None, body_type=None,40                   fuel=None, transmission=None, drivetrain=None, region=None,41                   city=None, source=None, year_min=None, year_max=None,42                   price_min=None, price_max=None, km_max=None, q=None) -> str:43    if make:44        sql += " AND make=?"; args.append(make)45    if model:46        sql += " AND model LIKE ?"; args.append(f"{model}%")47    if body_type:48        sql += " AND body_type=?"; args.append(body_type)49    if fuel:50        if fuel == "Hybride":     # inclut l'hybride rechargeable51            sql += " AND fuel IN ('Hybride','Hybride rechargeable')"52        else:53            sql += " AND fuel=?"; args.append(fuel)54    if transmission:55        sql += " AND transmission=?"; args.append(transmission)56    if drivetrain:57        sql += " AND drivetrain=?"; args.append(drivetrain)58    if region:59        sql += " AND region=?"; args.append(region)60    if city:61        sql += " AND city LIKE ?"; args.append(f"%{city}%")62    if source:63        sql += " AND source=?"; args.append(source)64    if year_min is not None:65        sql += " AND year IS NOT NULL AND year>=?"; args.append(year_min)66    if year_max is not None:67        sql += " AND year IS NOT NULL AND year<=?"; args.append(year_max)68    if price_min is not None:69        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)70    if price_max is not None:71        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)72    if km_max is not None:73        sql += " AND mileage_km IS NOT NULL AND mileage_km<=?"; args.append(km_max)74    if q:75        sql += (" AND (title LIKE ? OR make LIKE ? OR model LIKE ?"76                " OR dealer_name LIKE ? OR city LIKE ?)")77        args += [f"%{q}%"] * 578    return sql798081_SORTS = {82    "price_asc": "price IS NULL, price ASC",83    "price_desc": "price IS NULL, price DESC",84    "km_asc": "mileage_km IS NULL, mileage_km ASC",85    "year_desc": "year IS NULL, year DESC",86    "recent": "first_seen DESC",87}888990@app.get("/api/vehicles")91def list_vehicles(92    kind: str = "auto",93    make: str | None = None,94    model: str | None = None,95    body_type: str | None = None,96    fuel: str | None = None,97    transmission: str | None = None,98    drivetrain: str | None = None,99    region: str | None = None,100    city: str | None = None,101    source: str | None = None,102    year_min: int | None = None,103    year_max: int | None = None,104    price_min: float | None = None,105    price_max: float | None = None,106    km_max: float | None = None,107    q: str | None = None,108    sort: str = "price_asc",109    active: int = 1,110    limit: int = Query(60, le=500),111    offset: int = 0,112):113    con = db.connect()114    sql = "SELECT * FROM vehicles WHERE 1=1"115    args: list = []116    if active in (0, 1):117        sql += " AND active=?"; args.append(active)118    if kind and kind != "tous":119        sql += " AND kind=?"; args.append(kind)120    sql = _apply_filters(sql, args, make=make, model=model, body_type=body_type,121                         fuel=fuel, transmission=transmission,122                         drivetrain=drivetrain, region=region, city=city,123                         source=source, year_min=year_min, year_max=year_max,124                         price_min=price_min, price_max=price_max,125                         km_max=km_max, q=q)126    total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]127    sql += f" ORDER BY {_SORTS.get(sort, _SORTS['price_asc'])} LIMIT ? OFFSET ?"128    args += [limit, offset]129    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]130    con.close()131    return {"total": total, "count": len(rows), "vehicles": rows}132133134@app.get("/api/vehicles/{uid}")135def get_vehicle(uid: str):136    con = db.connect()137    row = con.execute("SELECT * FROM vehicles WHERE uid=?", (uid,)).fetchone()138    d = None139    if row is not None:140        d = _row_to_dict(row)141        d["price_history"] = [dict(r) for r in con.execute(142            "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",143            (uid,)).fetchall()]144        # véhicules similaires : même marque+modèle, autres sources incluses145        if d.get("make") and d.get("model"):146            base_model = d["model"].split()[0]147            d["similar"] = [_row_to_dict(r) for r in con.execute(148                "SELECT * FROM vehicles WHERE active=1 AND make=? AND model LIKE ?"149                " AND uid<>? ORDER BY price IS NULL, price ASC LIMIT 6",150                (d["make"], f"{base_model}%", uid)).fetchall()]151        else:152            d["similar"] = []153    con.close()154    if d is None:155        raise HTTPException(404, "Véhicule introuvable")156    return d157158159@app.get("/api/facets")160def facets(make: str | None = None, kind: str = "auto"):161    """Valeurs distinctes pour construire les filtres du frontend.162163    `make` (optionnel) restreint la liste des modèles à cette marque —164    utilisé par le sélecteur « Modèle » dépendant de « Marque ».165    """166    con = db.connect()167    kf = "" if kind in ("", "tous") else f" AND kind='{'moto' if kind=='moto' else 'scooter' if kind=='scooter' else 'auto'}'"168    model_sql = ("SELECT model, COUNT(*) n FROM vehicles"169                 " WHERE active=1 AND model<>''" + kf)170    model_args: list = []171    if make:172        model_sql += " AND make=?"173        model_args.append(make)174    out = {175        "makes": [dict(r) for r in con.execute(176            "SELECT make, COUNT(*) n FROM vehicles WHERE active=1" + kf + " AND make<>''"177            " GROUP BY make ORDER BY n DESC")],178        "models": [dict(r) for r in con.execute(179            model_sql + " GROUP BY model ORDER BY n DESC LIMIT 80", model_args)],180        "body_types": [r["body_type"] for r in con.execute(181            "SELECT DISTINCT body_type FROM vehicles WHERE active=1" + kf + ""182            " AND body_type<>'' ORDER BY body_type")],183        "fuels": [r["fuel"] for r in con.execute(184            "SELECT DISTINCT fuel FROM vehicles WHERE active=1" + kf + " AND fuel<>''"185            " ORDER BY fuel")],186        "regions": [dict(r) for r in con.execute(187            "SELECT region, COUNT(*) n FROM vehicles WHERE active=1" + kf + " AND region<>''"188            " GROUP BY region ORDER BY n DESC")],189        "sources": [dict(r) for r in con.execute(190            "SELECT source, dealer_name, COUNT(*) n FROM vehicles WHERE active=1" + kf + ""191            " GROUP BY source ORDER BY n DESC")],192        "years": [dict(r) for r in con.execute(193            "SELECT MIN(year) y_min, MAX(year) y_max FROM vehicles"194            " WHERE active=1" + kf + " AND year IS NOT NULL")],195    }196    con.close()197    return out198199200@app.get("/api/sources")201def sources():202    registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]203    con = db.connect()204    counts = {r["source"]: r["n"] for r in con.execute(205        "SELECT source, COUNT(*) n FROM vehicles WHERE active=1 GROUP BY source")}206    last = {r["source"]: r["ts"] for r in con.execute(207        "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}208    con.close()209    for s in registry:210        s["active_listings"] = counts.get(s["id"], 0)211        s["last_sync"] = last.get(s["id"])212    return {"sources": registry}213214215@app.get("/api/stats")216def stats():217    con = db.connect()218    row = con.execute(219        """SELECT COUNT(*) total,220                  COUNT(DISTINCT source) sources,221                  COUNT(DISTINCT region) regions,222                  AVG(price) avg_price,223                  AVG(mileage_km) avg_km,224                  AVG(year) avg_year225           FROM vehicles WHERE active=1 AND kind='auto'""").fetchone()226    by_region = [dict(r) for r in con.execute(227        "SELECT region, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles"228        " WHERE active=1 AND kind='auto' AND region<>'' GROUP BY region ORDER BY n DESC")]229    by_make = [dict(r) for r in con.execute(230        "SELECT make, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles"231        " WHERE active=1 AND kind='auto' AND make<>'' GROUP BY make ORDER BY n DESC LIMIT 20")]232    by_body = [dict(r) for r in con.execute(233        "SELECT body_type, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'"234        " AND body_type<>'' GROUP BY body_type ORDER BY n DESC")]235    # baisses de prix récentes (signal d'aubaine)236    drops = [dict(r) for r in con.execute(237        """SELECT v.uid, v.title, v.year, v.price, v.images, v.dealer_name, v.city,238                  p.prev_price239           FROM vehicles v JOIN (240             SELECT uid, price prev_price,241                    ROW_NUMBER() OVER (PARTITION BY uid ORDER BY ts DESC) rn242             FROM price_log) p ON p.uid=v.uid AND p.rn=2243           WHERE v.active=1 AND kind='auto' AND v.price IS NOT NULL AND p.prev_price > v.price244           ORDER BY (p.prev_price - v.price) DESC LIMIT 12""")]245    for d in drops:246        d["images"] = json.loads(d.get("images") or "[]")[:1]247    log = [dict(r) for r in con.execute(248        "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]249    con.close()250    return {**dict(row), "by_region": by_region, "by_make": by_make,251            "by_body": by_body, "price_drops": drops, "recent_syncs": log}252253254@app.get("/api/stats/detailed")255def stats_detailed():256    """Agrégats complets du marché (source unique : autoka/marketstats.py)."""257    from . import marketstats258    return marketstats.compute()259260261@app.get("/api/stats/rapport.pdf")262def rapport_pdf():263    """Rapport PDF « Le marché de l'occasion » — vue d'ensemble."""264    from fastapi.responses import Response265    from . import pdfgen266    return Response(267        content=pdfgen.rapport_pdf(), media_type="application/pdf",268        headers={"Content-Disposition":269                 'attachment; filename="auto-ka-rapport-marche.pdf"'})270271272@app.post("/api/sync")273def trigger_sync(background: BackgroundTasks, source: str | None = None):274    """Déclenche une synchronisation (équivalent d'un webhook entrant)."""275    def _job():276        with _sync_lock:277            ingest.run([source] if source else None)278    background.add_task(_job)279    return {"status": "démarré", "source": source or "toutes"}280281282# --- Frontend React (build Vite) --------------------------------------------283if FRONTEND_DIST.exists():284    app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")285286    @app.get("/{full_path:path}")287    def spa(full_path: str):288        target = FRONTEND_DIST / full_path289        if full_path and target.is_file():290            return FileResponse(target)291        # index.html jamais mis en cache : les téléphones reçoivent toujours292        # la dernière version (les bundles /assets sont hachés, eux se cachent)293        return FileResponse(294            FRONTEND_DIST / "index.html",295            headers={"Cache-Control": "no-cache, must-revalidate"})296