SPB Git

Repository Public

gen_connector_docs.py 25.3 KB 562 lines python

1#!/usr/bin/env python32# -----------------------------------------------------------------------------3# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)4# Auteur : Simon-Pierre Boucher — contact@spboucher.ai5# scripts/gen_connector_docs.py : générateur REJOUABLE de la documentation6#   standardisée des connecteurs → docs/connecteurs/INDEX.md + une fiche par7#   source. Croise trois vérités :8#     1. le registre data/sources.json (description, licence, cadence, statut)9#     2. l'introspection statique des connecteurs sortika/connectors/*.py10#        (endpoints, pagination, backend, plafonds env, en-tête documentaire)11#     3. la base LIVE data/sortika.db (volumétrie, complétude par champ,12#        exemples réels, dernier sync, erreurs sync_log)13#   + l'historique git de chaque connecteur.14#15#   Usage :  python3 scripts/gen_connector_docs.py16#   (aucune dépendance hors stdlib ; la BD est ouverte en lecture seule)17# -----------------------------------------------------------------------------18from __future__ import annotations1920import ast21import json22import re23import sqlite324import subprocess25import sys26from datetime import datetime, timezone27from pathlib import Path2829ROOT = Path(__file__).resolve().parents[1]30SOURCES_JSON = ROOT / "data" / "sources.json"31DB_PATH = ROOT / "data" / "sortika.db"32CONNECTORS_DIR = ROOT / "sortika" / "connectors"33OUT_DIR = ROOT / "docs" / "connecteurs"3435NOW = datetime.now().astimezone()36STAMP = NOW.strftime("%Y-%m-%d %H:%M %Z")3738# Champs documentés dans le tableau « Champs récupérés → schéma » de chaque39# fiche : (colonne SQL, libellé, description courte).40SCHEMA_FIELDS = [41    ("title",       "Titre",        "titre de l'événement"),42    ("description", "Description",  "texte descriptif (source ou fiche détail)"),43    ("url",         "URL",          "lien vers la fiche originale / billetterie"),44    ("categories",  "Catégories",   "taxonomie canonique Sorti-Ka (JSON)"),45    ("venue",       "Salle (venue)","nom du lieu"),46    ("address",     "Adresse",      "adresse civique"),47    ("city",        "Ville",        "municipalité"),48    ("region",      "Région",       "région administrative"),49    ("lat",         "GPS (lat/lng)","coordonnées géographiques"),50    ("start_date",  "Date début",   "date locale AAAA-MM-JJ"),51    ("end_date",    "Date fin",     "date locale AAAA-MM-JJ"),52    ("start_time",  "Heure (start_time)", "heure locale HH:MM (Québec)"),53    ("is_free",     "Gratuité (is_free)", "1 gratuit / 0 payant / NULL inconnu"),54    ("price_min",   "Prix min",     "tarif minimal réel ($)"),55    ("price_label", "Libellé prix", "étiquette tarifaire affichable"),56    ("artists",     "Artistes",     "têtes d'affiche / lineup (JSON)"),57    ("organizer",   "Organisateur", "promoteur / organisme"),58    ("image",       "Image",        "visuel (og:image ou média source)"),59    ("website",     "Site web",     "site officiel de l'événement"),60]6162# Complétude « clé » suivie dans l'INDEX et la section Volumétrie (SQL, libellé).63KEY_METRICS = [64    ("lat IS NOT NULL AND lng IS NOT NULL",                         "GPS"),65    ("(is_free IS NOT NULL OR price_min IS NOT NULL OR (price_label IS NOT NULL AND price_label != ''))", "Prix"),66    ("start_time IS NOT NULL AND start_time != ''",                 "Heure (start_time)"),67    ("artists IS NOT NULL AND artists NOT IN ('', '[]')",           "Artistes"),68    ("image IS NOT NULL AND image != ''",                           "Image"),69    ("venue IS NOT NULL AND venue != ''",                           "Salle (venue)"),70]7172# Attributions REQUISES par licence (rappelées mot à mot dans les fiches).73ATTRIBUTIONS = {74    "montreal": ("**Mention requise (CC BY 4.0)** : « Contient des données de "75                 "la **Ville de Montréal**, sous licence CC BY 4.0 "76                 "(donnees.montreal.ca). » L'attribution doit accompagner "77                 "toute réutilisation publique des événements de cette source."),78    "sitq":     ("**Mention requise (CC 4.0 — Données Québec)** : « Données du "79                 "**SIT Québec — Ministère du Tourisme**, diffusées sous "80                 "licence Creative Commons 4.0 via Données Québec. » "81                 "L'attribution au Ministère du Tourisme est obligatoire."),82    "laval":    ("**Mention requise (CC 4.0 — Données Québec)** : « Données de "83                 "la **Ville de Laval** (Calendrier des activités), diffusées "84                 "sous licence Creative Commons 4.0 via Données Québec. » "85                 "L'attribution à la Ville de Laval est obligatoire."),86}8788# Notes de dépannage spécifiques (complètent les erreurs sync_log live).89DEPANNAGE = {90    "montreal": ["Le WAF de donnees.montreal.ca renvoie **403** à tout "91                 "User-Agent contenant « bot » — le UA `SortiKa/1.0 (+URL; "92                 "courriel)` est volontairement sans ce mot. Un 403 ponctuel "93                 "est en général transitoire (WAF) ; vérifier le UA avant tout."],94    "atuvu":    ["Source derrière anti-bot/JS lourd → backend **Scrapfly** "95                 "requis : vérifier `SCRAPFLY_API_KEY` dans `.env` et le solde "96                 "de crédits Scrapfly si le connecteur ne rapporte plus rien."],97    "ticketmaster": ["Sans `TICKETMASTER_API_KEY` dans `.env`, le connecteur "98                     "s'annonce « clé manquante » et ne retourne rien (statut "99                     "« prêt — clé requise »). Quota API : 5 req/s (throttle "100                     "0,25 s côté connecteur)."],101    "bandsintown": ["L'API n'accepte que certains formats d'`app_id` : le "102                    "format widget `js_www.sorti-ka.com` est testé accepté "103                    "(2026-08) ; un app_id arbitraire simple est refusé."],104    "lepointdevente": ["L'enrichissement par fiches détail est incrémental "105                       "(cache `data/lepointdevente_cache.json`, plafond "106                       "`LPDV_DETAIL_MAX`/sync, re-visite 7 j) : la complétude "107                       "description/heure/prix monte au fil des cycles."],108    "lavitrine": ["Parcours par sitemap incrémental (`LV_MAX_FETCH` fiches/"109                  "sync) : après un reset de cache, plusieurs cycles sont "110                  "nécessaires pour recouvrir tout le catalogue."],111}112113WAVE_HISTORY = (114    "- **2026-08-18 — Vague 1 d'enrichissement** (commit `7d9d1b0`) : "115    "géocodage Nominatim des salles (`sortika/geocode.py`, cache disque, "116    "plafond `SORTIKA_GEOCODE_MAX`), fiches détail lepointdevente "117    "(description, heure, tarifs réels `/plugins/rates/<id>`), contacts "118    "Tourinsoft (SITQ), heure locale Montréal via JSON-LD.\n"119    "- **2026-08-18 — Vague 2 d'enrichissement** (commit `65d2d33`) : "120    "colonnes additives `events.start_time` (HH:MM locale QC) et "121    "`events.artists` (JSON) + migration auto ; heure captée partout où la "122    "source la publie (lepointdevente, evenko, Laval, Sherbrooke, Montréal, "123    "atuvu) ; artistes (headliners evenko, lineup Bandsintown, attractions "124    "Ticketmaster, patrons sûrs sur les titres) ; 3 nouveaux connecteurs "125    "(brossard, ticketmaster, bandsintown)."126)127128129# ---------------------------------------------------------------------------130# Introspection statique d'un connecteur131# ---------------------------------------------------------------------------132133def introspect(path: Path) -> dict:134    text = path.read_text(encoding="utf-8")135    info: dict = {"file": path.name, "endpoints": [], "env": [], "header": [],136                  "pagination": "", "backend": "", "request_delay": None}137138    # En-tête documentaire (bloc de commentaires en tête de fichier).139    for line in text.splitlines():140        s = line.rstrip()141        if s.startswith("#"):142            body = s.lstrip("#").strip()143            if set(body) <= {"-"}:          # séparateurs -----144                continue145            info["header"].append(body)146        elif s.startswith(("from ", "import ")) or not s:147            if info["header"]:148                break149        else:150            break151    # Retire le tri-boilerplate commun (nom du projet, auteur, nom du fichier).152    info["header"] = [h for h in info["header"]153                      if not h.startswith(("Sorti-Ka —", "Auteur :"))]154155    # Constantes URL au niveau module (ast gère la concaténation implicite).156    try:157        tree = ast.parse(text)158        for node in tree.body:159            if isinstance(node, ast.Assign) and len(node.targets) == 1 \160                    and isinstance(node.targets[0], ast.Name):161                name = node.targets[0].id162                val = node.value163                if isinstance(val, ast.Constant) and isinstance(val.value, str) \164                        and val.value.startswith("http"):165                    info["endpoints"].append((name, val.value))166    except SyntaxError:167        pass168169    # Plafonds / clés env : VAR = int(os.environ.get("X", "def"))  # commentaire170    env_re = re.compile(171        r'(?:os\.environ|__import__\("os"\)\.environ)\.get\(\s*"([A-Z_]+)"\s*'172        r'(?:,\s*"([^"]*)")?\s*\)[^\n#]*(?:#\s*(.*))?')173    for m in env_re.finditer(text):174        var, default, comment = m.group(1), m.group(2), (m.group(3) or "").strip()175        info["env"].append((var, default if default is not None else "—", comment))176177    # Backend réseau.178    low = text.lower()179    if "scrapfly" in low:180        info["backend"] = "Scrapfly (anti-bot / rendu JS) via `base.get_scrapfly()`"181    else:182        info["backend"] = "GET direct `requests` (throttlé, UA `SortiKa/1.0` identifié)"183184    # Pagination (heuristique sur le texte + l'en-tête).185    hints = []186    if "offset" in low:187        hints.append("offset/limit")188    if re.search(r'page["\']?\s*[:=]|page=\{page\}|\?page=', text):189        hints.append("par numéro de page")190    if "sitemap" in low:191        hints.append("parcours de sitemap XML")192    if "per_page" in low:193        hints.append("per_page")194    info["pagination"] = ", ".join(dict.fromkeys(hints)) or "aucune (flux unique)"195196    m = re.search(r"request_delay\s*[:=]\s*(?:float\s*=\s*)?([\d.]+)", text)197    if m:198        info["request_delay"] = m.group(1)199200    return info201202203# ---------------------------------------------------------------------------204# Base live205# ---------------------------------------------------------------------------206207def db_stats(cur: sqlite3.Cursor, sid: str) -> dict:208    row = cur.execute(209        "SELECT COUNT(*), COALESCE(SUM(active),0) FROM events WHERE source=?",210        (sid,)).fetchone()211    total, active = row212    metrics = []213    for cond, label in KEY_METRICS:214        n = cur.execute(215            f"SELECT COUNT(*) FROM events WHERE source=? AND active=1 AND ({cond})",216            (sid,)).fetchone()[0]217        metrics.append((label, n))218    # Complétude par champ du schéma (sur les événements actifs).219    per_field = {}220    for col, _label, _desc in SCHEMA_FIELDS:221        if col == "lat":222            cond = "lat IS NOT NULL AND lng IS NOT NULL"223        elif col in ("categories", "artists"):224            cond = f"{col} IS NOT NULL AND {col} NOT IN ('', '[]')"225        elif col == "is_free":226            cond = "is_free IS NOT NULL"227        elif col == "price_min":228            cond = "price_min IS NOT NULL"229        else:230            cond = f"{col} IS NOT NULL AND CAST({col} AS TEXT) != ''"231        per_field[col] = cur.execute(232            f"SELECT COUNT(*) FROM events WHERE source=? AND active=1 AND ({cond})",233            (sid,)).fetchone()[0]234235    # Exemple réel : l'événement actif le plus « complet », le plus récent.236    score = " + ".join(f"(CASE WHEN {c} THEN 1 ELSE 0 END)" for c, _ in KEY_METRICS)237    ex = cur.execute(238        f"SELECT * FROM events WHERE source=? AND active=1 "239        f"ORDER BY ({score}) DESC, updated_at DESC LIMIT 1", (sid,)).fetchone()240    example = dict(ex) if ex else {}241242    # sync_log : dernier sync, compteurs, erreurs.243    last = cur.execute(244        "SELECT ts, fetched, added, updated, removed, error FROM sync_log "245        "WHERE source=? ORDER BY ts DESC LIMIT 1", (sid,)).fetchone()246    nsync = cur.execute("SELECT COUNT(*) FROM sync_log WHERE source=?",247                        (sid,)).fetchone()[0]248    errors = cur.execute(249        "SELECT error, COUNT(*), MAX(ts) FROM sync_log WHERE source=? AND "250        "error IS NOT NULL GROUP BY error ORDER BY MAX(ts) DESC LIMIT 5",251        (sid,)).fetchall()252    return {"total": total, "active": active, "metrics": metrics,253            "per_field": per_field, "example": example,254            "last_sync": dict(last) if last else None, "nsync": nsync,255            "errors": [dict(e) for e in errors] if errors else256                      []}257258259def fmt_ts(ts: float | None) -> str:260    if not ts:261        return "n/d"262    return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() \263                   .strftime("%Y-%m-%d %H:%M")264265266def pct(n: int, d: int) -> str:267    return f"{100.0 * n / d:.0f} %" if d else "—"268269270def md_escape(v) -> str:271    if v is None:272        return "∅"273    s = str(v).replace("\n", " ").replace("|", "\\|").strip()274    return (s[:96] + "…") if len(s) > 96 else (s or "∅")275276277def git_history(path: Path) -> list[str]:278    try:279        out = subprocess.run(280            ["git", "log", "--follow", "--date=short",281             "--format=%h %ad %s", "--", str(path.relative_to(ROOT))],282            cwd=ROOT, capture_output=True, text=True, timeout=30).stdout283        return out.strip().splitlines()[:12]284    except Exception:285        return []286287288# ---------------------------------------------------------------------------289# Rendu des fiches290# ---------------------------------------------------------------------------291292def render_fiche(src: dict, intro: dict | None, stats: dict | None) -> str:293    sid = src["id"]294    L: list[str] = []295    L.append(f"# {src['name']} (`{sid}`)")296    L.append("")297    L.append(f"> Fiche générée le {STAMP} par `scripts/gen_connector_docs.py` "298             f"— ne pas éditer à la main, relancer le script.")299    L.append("")300    L.append(f"**Statut : {src.get('statut', 'n/d')}** · Type : "301             f"`{src.get('type', 'n/d')}`" +302             (f" · Connecteur : `sortika/connectors/{intro['file']}`" if intro303              else " · Connecteur : *aucun (source sans connecteur runtime)*"))304    L.append("")305306    # 1. Description --------------------------------------------------------307    L.append("## Description")308    L.append("")309    L.append(f"- **Source** : [{src['name']}]({src['url']})")310    L.append(f"- **Couverture** : {src.get('couverture', 'n/d')}")311    L.append(f"- **Extraction** : {src.get('extraction', 'n/d')}")312    if src.get("note"):313        L.append(f"- **Note** : {src['note']}")314    L.append("")315316    # 2. Accès ---------------------------------------------------------------317    L.append("## Accès")318    L.append("")319    L.append(f"- **Type** : {src.get('type', 'n/d')}")320    if intro and intro["endpoints"]:321        for name, url in intro["endpoints"]:322            L.append(f"- **Endpoint** (`{name}`) : `{url}`")323    else:324        L.append(f"- **Endpoint** : voir URL source ({src['url']})")325    auth = "Aucune (accès public, identification honnête par User-Agent)"326    if sid == "ticketmaster":327        auth = "Clé API gratuite `TICKETMASTER_API_KEY` (developer.ticketmaster.com)"328    elif sid == "bandsintown":329        auth = "`app_id` d'identification (`BANDSINTOWN_APP_ID`, format widget accepté)"330    elif intro and "Scrapfly" in intro["backend"]:331        auth = "Aucune côté source ; `SCRAPFLY_API_KEY` requis pour le backend Scrapfly"332    L.append(f"- **Auth** : {auth}")333    if intro:334        L.append(f"- **Pagination** : {intro['pagination']}")335        L.append(f"- **Backend** : {intro['backend']}")336        if intro["request_delay"]:337            L.append(f"- **Throttle** : {intro['request_delay']} s entre requêtes")338        L.append(f"- **Accès légal** : {src.get('acces_legal', 'n/d')}")339        if intro["header"]:340            L.append("")341            L.append("<details><summary>En-tête documentaire du connecteur</summary>")342            L.append("")343            for h in intro["header"]:344                L.append(f"> {h}")345            L.append("")346            L.append("</details>")347    else:348        L.append(f"- **Accès légal** : {src.get('acces_legal', 'n/d')}")349    L.append("")350351    # 3. Champs récupérés → schéma -------------------------------------------352    L.append("## Champs récupérés → schéma")353    L.append("")354    if stats and stats["active"]:355        ex = stats["example"]356        L.append(f"Complétude mesurée sur les **{stats['active']} événements "357                 f"actifs** de la source ; exemples tirés d'un événement réel "358                 f"(`{md_escape(ex.get('uid'))}`).")359        L.append("")360        L.append("| Champ (`events.*`) | Description | Complétude | Exemple réel |")361        L.append("|---|---|---|---|")362        for col, label, desc in SCHEMA_FIELDS:363            n = stats["per_field"][col]364            if col == "lat":365                val = (f"{ex.get('lat')}, {ex.get('lng')}"366                       if ex.get("lat") is not None else None)367            else:368                val = ex.get(col)369            L.append(f"| `{col}` — {label} | {desc} | "370                     f"{pct(n, stats['active'])} | {md_escape(val)} |")371    else:372        L.append("*Aucun événement actif en base pour cette source — pas de "373                 "schéma live à montrer.*")374    L.append("")375376    # 4. Fréquence & budgets ---------------------------------------------------377    L.append("## Fréquence & budgets")378    L.append("")379    L.append(f"- **Cadence** : {src.get('cadence', 'n/d')}")380    if intro and intro["env"]:381        L.append("- **Budgets / plafonds (variables d'environnement)** :")382        L.append("")383        L.append("| Variable | Défaut | Rôle |")384        L.append("|---|---|---|")385        for var, default, comment in intro["env"]:386            L.append(f"| `{var}` | `{default}` | {comment or '—'} |")387    L.append("- **Budget global** : géocodage Nominatim plafonné à "388             "`SORTIKA_GEOCODE_MAX` (défaut 120) requêtes réseau/cycle, "389             "toutes sources confondues (`sortika/geocode.py`, cache disque).")390    L.append("")391392    # 5. Volumétrie & complétude live ------------------------------------------393    L.append("## Volumétrie & complétude live")394    L.append("")395    if stats:396        L.append(f"*(mesuré le {STAMP} dans `data/sortika.db`)*")397        L.append("")398        L.append(f"- **Événements en base** : {stats['total']} "399                 f"(dont **{stats['active']} actifs**)")400        for label, n in stats["metrics"]:401            L.append(f"- **{label}** : {n}/{stats['active']} actifs "402                     f"({pct(n, stats['active'])})")403        ls = stats["last_sync"]404        if ls:405            L.append(f"- **Dernier sync** : {fmt_ts(ls['ts'])} — "406                     f"fetched {ls['fetched']}, +{ls['added']} ajoutés, "407                     f"~{ls['updated']} mis à jour, -{ls['removed']} retirés"408                     + (f", **erreur : {md_escape(ls['error'])}**"409                        if ls["error"] else ""))410        L.append(f"- **Syncs enregistrés** (sync_log) : {stats['nsync']}")411    else:412        L.append("*Source sans connecteur runtime : aucune donnée en base.*")413    L.append("")414415    # 6. Erreurs & dépannage -----------------------------------------------------416    L.append("## Erreurs & dépannage")417    L.append("")418    if stats and stats["errors"]:419        L.append("Erreurs relevées dans `sync_log` :")420        L.append("")421        L.append("| Erreur | Occurrences | Dernière fois |")422        L.append("|---|---|---|")423        for e in stats["errors"]:424            err, cnt, ts = list(e.values())425            L.append(f"| {md_escape(err)} | {cnt} | {fmt_ts(ts)} |")426        L.append("")427    elif stats:428        L.append("Aucune erreur dans `sync_log` pour cette source. ✅")429        L.append("")430    for tip in DEPANNAGE.get(sid, []):431        L.append(f"- {tip}")432    if intro:433        L.append("- Rejouer un sync isolé : `python3 run.py sync --source "434                 f"{sid}` (ou via le watcher PM2) ; inspecter ensuite "435                 "`sync_log` : `sqlite3 data/sortika.db \"SELECT * FROM "436                 f"sync_log WHERE source='{sid}' ORDER BY ts DESC LIMIT 5;\"`")437    L.append("")438439    # 7. Licence / attribution ------------------------------------------------------440    L.append("## Licence / attribution")441    L.append("")442    L.append(f"- {src.get('acces_legal', 'n/d')}")443    if sid in ATTRIBUTIONS:444        L.append("")445        L.append(ATTRIBUTIONS[sid])446    L.append("")447448    # 8. Historique -------------------------------------------------------------------449    L.append("## Historique")450    L.append("")451    if src.get("statut") == "abandonnee":452        L.append(f"- **Source abandonnée** — {src.get('note', 'sans note')}")453    L.append(WAVE_HISTORY)454    if intro:455        hist = git_history(CONNECTORS_DIR / intro["file"])456        if hist:457            L.append("")458            L.append(f"Commits touchant `sortika/connectors/{intro['file']}` :")459            L.append("")460            for h in hist:461                L.append(f"- `{h.split(' ', 1)[0]}` {h.split(' ', 1)[1]}")462    L.append("")463    return "\n".join(L)464465466# ---------------------------------------------------------------------------467# INDEX468# ---------------------------------------------------------------------------469470def render_index(rows: list[dict]) -> str:471    L: list[str] = []472    L.append("# Sorti-Ka — Connecteurs de sources d'événements")473    L.append("")474    L.append(f"> Index généré le {STAMP} par `scripts/gen_connector_docs.py` "475             f"(rejouable : `python3 scripts/gen_connector_docs.py`). "476             f"Croise `data/sources.json`, l'introspection de "477             f"`sortika/connectors/*.py` et la base live `data/sortika.db`.")478    L.append("")479    actives = [r for r in rows if r["src"].get("statut") != "abandonnee"]480    abandon = [r for r in rows if r["src"].get("statut") == "abandonnee"]481    tot = sum(r["stats"]["total"] for r in rows if r["stats"])482    act = sum(r["stats"]["active"] for r in rows if r["stats"])483    L.append(f"**{len(rows)} sources au registre** — {len(actives)} "484             f"actives/prêtes, {len(abandon)} abandonnées · "485             f"**{tot} événements en base** (dont {act} actifs).")486    L.append("")487    L.append("⚠️ **Attributions requises** : Ville de Montréal (CC BY 4.0) ; "488             "SIT Québec / Ministère du Tourisme et Ville de Laval "489             "(CC 4.0 — Données Québec). Détails dans chaque fiche, section "490             "« Licence / attribution ».")491    L.append("")492    L.append("## Sources actives / prêtes")493    L.append("")494    L.append("| Source | Type | Statut | Actifs | GPS | Prix | Heure | "495             "Artistes | Image | Salle | Dernier sync |")496    L.append("|---|---|---|---|---|---|---|---|---|---|---|")497    for r in actives:498        s, st = r["src"], r["stats"]499        if st and st["active"]:500            cells = [pct(n, st["active"]) for _, n in st["metrics"]]501            last = fmt_ts(st["last_sync"]["ts"]) if st["last_sync"] else "n/d"502            L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | "503                     f"{s['statut']} | {st['active']} | " + " | ".join(cells) +504                     f" | {last} |")505        else:506            last = (fmt_ts(st["last_sync"]["ts"])507                    if st and st["last_sync"] else "n/d")508            L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | "509                     f"{s['statut']} | 0 | — | — | — | — | — | — | {last} |")510    L.append("")511    L.append("## Sources abandonnées")512    L.append("")513    L.append("| Source | Type | Raison |")514    L.append("|---|---|---|")515    for r in abandon:516        s = r["src"]517        L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | "518                 f"{md_escape(s.get('note', 'n/d'))} |")519    L.append("")520    L.append("## Régénérer cette documentation")521    L.append("")522    L.append("```bash")523    L.append("cd ~/apps/sorti-ka && python3 scripts/gen_connector_docs.py")524    L.append("```")525    L.append("")526    return "\n".join(L)527528529# ---------------------------------------------------------------------------530531def main() -> int:532    sources = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"]533    con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)534    con.row_factory = sqlite3.Row535    cur = con.cursor()536    OUT_DIR.mkdir(parents=True, exist_ok=True)537538    rows = []539    for src in sources:540        sid = src["id"]541        cpath = CONNECTORS_DIR / f"{sid.replace('-', '_')}.py"542        intro = introspect(cpath) if cpath.exists() else None543        has_data = cur.execute(544            "SELECT COUNT(*) FROM events WHERE source=?", (sid,)).fetchone()[0]545        has_log = cur.execute(546            "SELECT COUNT(*) FROM sync_log WHERE source=?", (sid,)).fetchone()[0]547        stats = db_stats(cur, sid) if (intro or has_data or has_log) else None548        fiche = render_fiche(src, intro, stats)549        (OUT_DIR / f"{sid}.md").write_text(fiche, encoding="utf-8")550        rows.append({"src": src, "intro": intro, "stats": stats})551        print(f"  fiche : docs/connecteurs/{sid}.md"552              + (f"  ({stats['active']} actifs)" if stats else "  (sans données)"))553554    (OUT_DIR / "INDEX.md").write_text(render_index(rows), encoding="utf-8")555    print(f"  index : docs/connecteurs/INDEX.md ({len(rows)} sources)")556    con.close()557    return 0558559560if __name__ == "__main__":561    sys.exit(main())562
Files 85714fd20a5b6ad67fcb856d8d9fb281f1ebe76c
Menu

Theme