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%
1# -----------------------------------------------------------------------------2# Ora-Ka — Plateforme unifiée des agrégateurs Ka3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# web.py : app maîtresse — landing + montage des 5 apps sous leurs préfixes5# -----------------------------------------------------------------------------6from __future__ import annotations78import sqlite39import sys10import time11from pathlib import Path1213from fastapi import FastAPI, Request14from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse15from fastapi.middleware.gzip import GZipMiddleware1617ROOT = Path(__file__).resolve().parent.parent18APPS_DIR = ROOT / "apps"1920# Chaque app garde son arborescence d'origine (<pkg>/, data/, frontend/dist/)21# sous apps/<alias>/ — on rend les 5 packages importables tels quels.22for _d in ("immo", "lou", "fabri", "auto", "food"):23 p = str(APPS_DIR / _d)24 if p not in sys.path:25 sys.path.insert(0, p)2627from immoka.web import app as immo_app # noqa: E40228from louka.web import app as lou_app # noqa: E40229from fabrika.web import app as fabri_app # noqa: E40230from autoka.web import app as auto_app # noqa: E40231from foodka.web import app as food_app # noqa: E4023233from oraka.auth import current_user, router as auth_router # noqa: E40234from oraka.hubfav import hub_list_all # noqa: E40235from oraka.landing import LANDING # noqa: E40236from oraka.legal import PRIVACY, TERMS # noqa: E40237from oraka.search import search_hits # noqa: E4023839app = FastAPI(title="Ora-Ka", version="1.0", docs_url=None, redoc_url=None)40app.add_middleware(GZipMiddleware, minimum_size=1024)4142# Connexion « KA ID » (SSO du Groupe KA) — inclus AVANT les montages /immo etc.43# pour que /api/auth/* soit toujours servi par l'app racine.44app.include_router(auth_router)4546# ---------------------------------------------------------------------------47# Statistiques agrégées (lecture directe des 5 bases, cache mémoire 5 min)48# ---------------------------------------------------------------------------49_DBS = {50 "immo": APPS_DIR / "immo" / "data" / "immoka.db",51 "lou": APPS_DIR / "lou" / "data" / "louka.db",52 "fabri": APPS_DIR / "fabri" / "data" / "fabrika.db",53 "auto": APPS_DIR / "auto" / "data" / "autoka.db",54 "food": APPS_DIR / "food" / "data" / "foodka.db",55}5657_COUNT_SQL = {58 "immo": "SELECT COUNT(*) FROM listings WHERE active=1 AND dup_hidden=0 AND price IS NOT NULL",59 "lou": "SELECT COUNT(*) FROM listings WHERE active=1",60 "fabri": "SELECT COUNT(*) FROM products WHERE active=1",61 "auto": "SELECT COUNT(*) FROM vehicles WHERE active=1",62 "food": "SELECT COUNT(*) FROM products WHERE active=1",63}6465_overview_cache: dict = {"ts": 0.0, "data": None}666768def _count(db_path: Path, sql: str) -> int:69 if not db_path.exists():70 return 071 try:72 con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5)73 try:74 return int(con.execute(sql).fetchone()[0])75 finally:76 con.close()77 except Exception:78 return 0798081@app.get("/api/overview")82def overview() -> JSONResponse:83 now = time.time()84 if _overview_cache["data"] is not None and now - _overview_cache["ts"] < 300:85 return JSONResponse(_overview_cache["data"])86 counts = {k: _count(_DBS[k], _COUNT_SQL[k]) for k in _DBS}87 data = {88 "apps": {89 "immo": {"label": "propriétés à vendre", "n": counts["immo"]},90 "lou": {"label": "logements à louer", "n": counts["lou"]},91 "fabri": {"label": "produits québécois", "n": counts["fabri"]},92 "auto": {"label": "véhicules d'occasion", "n": counts["auto"]},93 "food": {"label": "produits d'épicerie", "n": counts["food"]},94 },95 "total": sum(counts.values()),96 "generated_at": now,97 }98 _overview_cache.update(ts=now, data=data)99 return JSONResponse(data)100101102@app.get("/api/search")103def api_search(q: str = "", scope: str | None = None, limit: int = 60) -> JSONResponse:104 """Moteur hybride : exact + sémantique dans les 5 univers en une requête."""105 limit = max(1, min(int(limit), 120))106 return JSONResponse(search_hits(q, scope, limit))107108109@app.get("/api/favorites")110def api_favorites(request: Request) -> JSONResponse:111 """« Mon univers Ka » : les favoris du membre connecté, TOUTES plateformes112 du groupe confondues, lus depuis le magasin central du hub Groupe KA."""113 user = current_user(request)114 if not user:115 return JSONResponse({"error": "non connecté"}, status_code=401)116 return JSONResponse({"favorites": hub_list_all(user.get("ka_id"))})117118119@app.get("/healthz")120def healthz() -> dict:121 return {"status": "ok", "apps": {k: _DBS[k].exists() for k in _DBS}}122123124# Redirections sans barre oblique finale vers la racine de chaque app125for _alias in ("immo", "lou", "fabri", "auto", "food"):126 def _mk(alias: str):127 def _redir() -> RedirectResponse:128 return RedirectResponse(url=f"/{alias}/", status_code=308)129 return _redir130 app.get(f"/{_alias}", include_in_schema=False)(_mk(_alias))131132133@app.get("/", response_class=HTMLResponse, include_in_schema=False)134def landing() -> HTMLResponse:135 return HTMLResponse(LANDING)136137138@app.get("/conditions", response_class=HTMLResponse, include_in_schema=False)139def terms() -> HTMLResponse:140 return HTMLResponse(TERMS)141142143@app.get("/confidentialite", response_class=HTMLResponse, include_in_schema=False)144def privacy() -> HTMLResponse:145 return HTMLResponse(PRIVACY)146147148# ---------------------------------------------------------------------------149# Montage des 5 apps — chacune reproduite fidèlement sous son préfixe.150# Leurs catch-all SPA restent confinés à leur préfixe grâce au mount.151# ---------------------------------------------------------------------------152app.mount("/immo", immo_app)153app.mount("/lou", lou_app)154app.mount("/fabri", fabri_app)155app.mount("/auto", auto_app)156app.mount("/food", food_app)157