Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)
Python 73.6%
HTML 13.2%
TypeScript 6%
JavaScript 4.5%
CSS 1.7%
Dockerfile 0.6%
1#!/usr/bin/env python32# ==============================================================================3# Author: Simon-Pierre Boucher <contact@spboucher.ai>4# File: scripts/gen_connector_docs.py5# Desc: Documentation STANDARDISÉE des connecteurs Créa-Ka — génère6# docs/connecteurs/INDEX.md + une fiche docs/connecteurs/<id>.md par7# source du registre, de façon 100 % programmatique et rejouable :8# 1. registre data/sources.json (famille, palier, accès, cadence,9# signal d'identité, notes, statut) ;10# 2. introspection STATIQUE (ast) de creaka/connectors/*.py :11# classe, kind (discovery/enrichment), backend, endpoints,12# pagination, constantes de budget — sans exécuter le code ;13# 3. BD live data/creaka.db : créateurs découverts/enrichis par14# source (doc JSON), complétude des champs, comptes par15# plateforme, dernier sync, cadence observée, alertes récentes.16# Usage : python3 scripts/gen_connector_docs.py (racine du projet)17# ==============================================================================18from __future__ import annotations1920import ast21import json22import re23import sqlite324import statistics25from datetime import datetime, timezone26from pathlib import Path2728ROOT = Path(__file__).resolve().parents[1]29CONN_DIR = ROOT / "creaka" / "connectors"30DOCS_DIR = ROOT / "docs" / "connecteurs"31DB_PATH = ROOT / "data" / "creaka.db"32SOURCES_JSON = ROOT / "data" / "sources.json"3334SKIP_MODULES = {"__init__", "base"}35URL_RE = re.compile(r"https?://[^\s\"'\\)>,;]+")36DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")37INFRA_HOSTS = ("api.firecrawl.dev", "api.scrapfly.io", "crea-ka.com")3839# plateformes de la table `accounts` alimentées par chaque source d'enrichissement40SOURCE_PLATFORMS = {41 "instagram-profil": ("instagram",),42 "tiktok-profil": ("tiktok",),43 "youtube": ("youtube",),44 "youtube-recherche": ("youtube",),45 "twitch": ("twitch",),46 "x-profil": ("x",),47 "balados-rss": ("podcast",),48 "balados-itunes": ("podcast",),49 "palmares-apple": ("podcast",),50 "podcastindex": ("podcast",),51 "onlyqueb": ("onlyfans",),52}5354# champs du doc créateur documentés (complétude par source de découverte)55DOC_FIELDS = [56 ("display_name", "Nom public"),57 ("bio", "Bio"),58 ("region", "Région"),59 ("city", "Ville"),60 ("niches", "Niches (liste)"),61 ("languages", "Langues (liste)"),62 ("creator_type", "Type de créateur"),63 ("primary_platform", "Plateforme principale"),64 ("platforms", "Comptes sociaux (liste)"),65 ("avatar_url", "Avatar"),66 ("total_reach", "Portée totale (abonnés cumulés)"),67 ("link_in_bio_url", "Lien-en-bio"),68]6970ACC_FIELDS = [71 ("handle", "Handle", "handle IS NOT NULL AND handle != ''"),72 ("url", "URL du profil", "url IS NOT NULL AND url != ''"),73 ("followers", "Abonnés", "followers IS NOT NULL"),74 ("verified", "Badge vérifié", "verified IS NOT NULL"),75 ("confidence", "Confiance d'identité", "confidence IS NOT NULL"),76 ("signal", "Signal d'identité", "signal IS NOT NULL AND signal != ''"),77 ("metrics", "Métriques détaillées (JSON)",78 "metrics IS NOT NULL AND length(metrics) > 4"),79 ("last_checked", "Dernière vérification",80 "last_checked IS NOT NULL AND last_checked != ''"),81]828384def esc(s, limit: int = 100) -> str:85 s = str(s).replace("\\", "\\\\").replace("|", "\\|")86 s = re.sub(r"\s+", " ", s).strip()87 return s[: limit - 1] + "…" if len(s) > limit else s888990def pct(n, d) -> str:91 return f"{100.0 * (n or 0) / d:.0f} %" if d else "—"929394def parse_iso(ts: str) -> float | None:95 try:96 return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()97 except (ValueError, AttributeError):98 return None99100101def fmt_secs(sec: float) -> str:102 if sec < 5400:103 return f"≈ {sec / 60:.0f} min"104 if sec < 129600:105 return f"≈ {sec / 3600:.1f} h"106 return f"≈ {sec / 86400:.1f} j"107108109def filled(v) -> bool:110 return bool(v)111112113# -- introspection statique ------------------------------------------------------114115def banner_description(text: str) -> str:116 lines, started = [], False117 for raw in text.splitlines():118 if not raw.startswith("#"):119 if started:120 break121 continue122 body = raw.lstrip("#").strip()123 if set(body) <= {"-", "="}:124 continue125 if not started:126 if body.startswith("Desc:"):127 started = True128 lines.append(body[len("Desc:"):].strip())129 continue130 if re.match(r"^(Author|File):", body):131 break132 lines.append(body)133 return " ".join(l for l in lines if l).strip()134135136def introspect_module(path: Path) -> list[dict]:137 text = path.read_text(encoding="utf-8")138 try:139 tree = ast.parse(text)140 except SyntaxError:141 return []142 constants: dict = {}143 for node in tree.body:144 if isinstance(node, ast.Assign) and len(node.targets) == 1 \145 and isinstance(node.targets[0], ast.Name) \146 and node.targets[0].id.isupper():147 try:148 constants[node.targets[0].id] = ast.literal_eval(node.value)149 except (ValueError, TypeError, SyntaxError):150 seg = ast.get_source_segment(text, node.value) or ""151 urls = URL_RE.findall(seg)152 constants[node.targets[0].id] = urls if len(urls) > 1 else \153 (urls[0] if urls else None)154 urls_all = []155 for u in URL_RE.findall(text):156 u = u.rstrip('".')157 host = u.split("//", 1)[-1].split("/", 1)[0]158 if "." not in host: # fragment de f-string, pas une vraie URL159 continue160 if not any(h in u for h in INFRA_HOSTS) and u not in urls_all:161 urls_all.append(u)162 endpoint = None163 for name in ("BASE", "BASE_URL", "API", "API_URL", "API_BASE", "SPARQL",164 "ENDPOINT", "ROOT", "URL", "SEARCH_URL", "LOOKUP_URL"):165 v = constants.get(name)166 if isinstance(v, str) and v.startswith("http"):167 endpoint = v168 break169 if not endpoint and urls_all:170 endpoint = urls_all[0]171 budgets = {k: v for k, v in constants.items()172 if isinstance(v, (int, float)) and not isinstance(v, bool)173 and re.search(r"MAX|CAP|LIMIT|BUDGET|TTL|PER_PAGE|PAGES|DELAY|BATCH",174 k)}175 detailed, family = [], "direct"176 if re.search(r"self\.(get|post)\(|requests\.(get|post)\(", text):177 detailed.append("requests direct (session UA CreaKaBot, throttling poli)")178 if ".scrapfly(" in text:179 detailed.append("Scrapfly (asp + render_js — contournement anti-bot)")180 family = "Scrapfly"181 if "get_rendered(" in text:182 detailed.append("Firecrawl (HTML rendu, JavaScript exécuté)")183 family = "Firecrawl" if family == "direct" else family184 if not detailed:185 detailed.append("requests direct")186 low = text.lower()187 if "sparql" in low:188 flavor = "API SPARQL publique (Wikidata)"189 elif "graphql" in low:190 flavor = "API GraphQL"191 elif "itunes.apple.com" in low:192 flavor = "API iTunes Search/Lookup (publique)"193 elif "rss" in low and "feedparser" in low or "<rss" in low:194 flavor = "flux RSS publics"195 elif "sitemap" in low:196 flavor = "sitemap XML + JSON-LD des pages profil (SSR)"197 elif "json-ld" in low or "jsonld" in low or "profilepage" in low:198 flavor = "JSON-LD des pages profil publiques"199 elif re.search(r"\.json\(\)", text) and re.search(r"api[./_]", low):200 flavor = "API JSON"201 elif "dataset" in low or "seed" in low and "csv" in low:202 flavor = "dataset local compilé (data/seed)"203 else:204 flavor = "pages HTML publiques"205 hits = []206 if re.search(r"[?&]page=|[\"']page[\"']\s*[:=]", low):207 hits.append("pagination par numéro de page")208 if re.search(r"[?&]offset=|[\"']offset[\"']", low):209 hits.append("pagination par offset")210 if "cursor" in low or "continuation" in low:211 hits.append("curseur / continuation")212 if not hits:213 hits.append("réponse unique (pas de pagination)")214 base = {"module": path.stem, "path": f"creaka/connectors/{path.stem}.py",215 "banner": banner_description(text), "endpoint": endpoint,216 "urls": urls_all[:5], "budgets": budgets, "backends": detailed,217 "backend_family": family, "flavor": flavor,218 "pagination": " ; ".join(hits)}219 entries = []220 for node in tree.body:221 if not isinstance(node, ast.ClassDef):222 continue223 bases = {getattr(b, "id", getattr(b, "attr", "")) for b in node.bases}224 if "BaseConnector" not in bases:225 continue226 attrs = {"request_delay": 0.8, "timeout": 30, "kind": "discovery",227 "source_id": ""}228 for sub in node.body:229 if isinstance(sub, ast.Assign) and len(sub.targets) == 1 \230 and isinstance(sub.targets[0], ast.Name):231 try:232 attrs[sub.targets[0].id] = ast.literal_eval(sub.value)233 except (ValueError, TypeError, SyntaxError):234 pass235 if attrs["source_id"]:236 entries.append({**base, "class": node.name,237 "class_doc": ast.get_docstring(node) or "",238 **attrs})239 return entries240241242# -- BD live ----------------------------------------------------------------------243244def load_creators(con) -> list[dict]:245 out = []246 for (doc,) in con.execute("SELECT doc FROM creators"):247 try:248 out.append(json.loads(doc))249 except ValueError:250 pass251 return out252253254def db_stats(con, sid: str, creators: list[dict]) -> dict:255 mine = [c for c in creators if c.get("source") == sid]256 touched = sum(1 for c in creators257 if sid in (c.get("source_ids") or []) or c.get("source") == sid)258 doc_fill = {f: sum(1 for c in mine if filled(c.get(f)))259 for f, _l in DOC_FIELDS}260 sample = max(mine, key=lambda c: c.get("total_reach") or 0) if mine else None261262 acc = None263 acc_sample = None264 platforms = SOURCE_PLATFORMS.get(sid)265 if platforms:266 ph = ",".join("?" * len(platforms))267 parts = ", ".join(268 f"sum(CASE WHEN {cond} THEN 1 ELSE 0 END) AS f_{col}"269 for col, _l, cond in ACC_FIELDS)270 acc = con.execute(271 f"SELECT count(*) AS n, avg(confidence) AS conf, "272 f"max(last_checked) AS checked, {parts} FROM accounts "273 f"WHERE platform IN ({ph})", platforms).fetchone()274 acc_sample = con.execute(275 f"SELECT * FROM accounts WHERE platform IN ({ph}) AND followers IS "276 f"NOT NULL ORDER BY followers DESC LIMIT 1", platforms).fetchone()277278 runs = con.execute(279 "SELECT ts, creators, accounts, avg_confidence, added, updated, "280 "errors, seconds, alert FROM sync_log WHERE source=? "281 "ORDER BY ts DESC LIMIT 60", (sid,)).fetchall()282 ok_ts = sorted(t for t in (parse_iso(r["ts"]) for r in runs) if t)283 cadence = None284 if len(ok_ts) >= 3:285 deltas = [b - a for a, b in zip(ok_ts, ok_ts[1:]) if b - a > 60]286 if deltas:287 cadence = statistics.median(deltas)288 alerts = [r for r in runs if (r["errors"] or 0) > 0 or r["alert"]][:5]289 return {"mine": len(mine), "touched": touched, "doc_fill": doc_fill,290 "sample": sample, "acc": acc, "acc_sample": acc_sample,291 "platforms": platforms, "runs": runs, "cadence": cadence,292 "last": runs[0] if runs else None, "alerts": alerts}293294295# -- rendu ---------------------------------------------------------------------------296297def render_fiche(reg: dict, entry: dict | None, st: dict, now: str) -> str:298 sid = reg["id"]299 etat = reg.get("statut", "actif")300 fam = reg.get("famille", "—")301 out = [f"# `{sid}` — connecteur {fam} (palier {reg.get('palier', '—')})", "",302 f"_Fiche générée automatiquement par "303 f"`scripts/gen_connector_docs.py` le {now} — ne pas éditer à la "304 f"main, régénérer._", ""]305 kind = entry.get("kind") if entry else fam306 out.append(f"**État : {esc(etat, 80)}** · Famille : {fam} ({kind}) · "307 f"Backend : {entry['backend_family'] if entry else '—'} · "308 f"Créateurs découverts : {st['mine']} · touchés : "309 f"{st['touched']}")310 out.append("")311312 out.append("## Description de la source")313 out.append("")314 if entry and entry["banner"]:315 out.append(entry["banner"])316 out.append("")317 out.append(f"- **Notes (registre)** : {reg.get('notes', '—')}")318 out.append(f"- **Signal d'identité** : {reg.get('signal_identite', '—')} "319 f"(colonne `accounts.signal` / `confidence`)")320 if entry:321 out.append(f"- **Module** : `{entry['path']}` — classe "322 f"`{entry['class']}` (`kind = \"{entry['kind']}\"`)")323 else:324 out.append("- **Module** : introuvable (vérifier le registre)")325 out.append("")326327 out.append("## Accès")328 out.append("")329 out.append(f"- **Accès (registre)** : {reg.get('acces', '—')}")330 if entry:331 out.append(f"- **Type d'accès (code)** : {entry['flavor']}")332 out.append(f"- **Endpoint de base** : {entry['endpoint'] or '—'}")333 if entry["urls"]:334 out.append("- **URLs du module** : " + " · ".join(entry["urls"][:4]))335 out.append(f"- **Pagination** : {entry['pagination']}")336 out.append(f"- **Backend anti-bot / rendu** : "337 f"{' ; '.join(entry['backends'])}")338 out.append(f"- **Politesse** : {entry['request_delay']} s entre "339 f"requêtes, timeout {entry['timeout']} s, UA "340 f"`CreaKaBot/1.0 (+https://www.crea-ka.com/bot)`")341 needs_key = "clé" in (reg.get("acces") or "").lower() \342 or "clé" in str(etat).lower()343 auth = ("clé(s) API requise(s) — voir statut/registre" if needs_key344 else "aucune — contenu public / API anonyme")345 out.append(f"- **Authentification** : {auth}")346 out.append("")347348 out.append("## Champs récupérés → schéma cible")349 out.append("")350 if st["mine"]:351 out.append(f"Source de **découverte** : produit des fiches `Creator` "352 f"(tables `creators` + `accounts`). Complétude mesurée sur "353 f"les {st['mine']} créateurs découverts par cette source "354 f"(champ `doc.source`) ; exemple tiré d'une fiche réelle.")355 out.append("")356 out.append("| Champ du doc créateur | Contenu | Renseigné | Exemple réel |")357 out.append("|---|---|---|---|")358 s = st["sample"] or {}359 for f, label in DOC_FIELDS:360 v = s.get(f)361 if f == "platforms" and v:362 ex = esc(", ".join(f"{p.get('platform')}:@{p.get('handle')}"363 for p in v[:3]), 90)364 elif isinstance(v, list):365 ex = esc(", ".join(map(str, v[:4])), 90)366 else:367 ex = esc(v, 90) if filled(v) else "—"368 out.append(f"| `{f}` | {label} | "369 f"{pct(st['doc_fill'][f], st['mine'])} | {ex} |")370 out.append("")371 if st["acc"] is not None and st["acc"]["n"]:372 a = st["acc"]373 plats = ", ".join(st["platforms"])374 out.append(f"Cible d'**enrichissement** : colonnes de la table "375 f"`accounts` pour la/les plateforme(s) `{plats}` "376 f"({a['n']} comptes en BD, confiance moyenne "377 f"{a['conf']:.2f}).")378 out.append("")379 out.append("| Colonne `accounts` | Contenu | Renseignée | Exemple réel |")380 out.append("|---|---|---|---|")381 sm = st["acc_sample"]382 for col, label, _c in ACC_FIELDS:383 ex = esc(sm[col], 90) if sm is not None and sm[col] not in (384 None, "") else "—"385 out.append(f"| `{col}` | {label} | {pct(a[f'f_{col}'], a['n'])} "386 f"| {ex} |")387 out.append("")388 if not st["mine"] and (st["acc"] is None or not st["acc"]["n"]):389 out.append("Aucune donnée attribuable à cette source en BD pour "390 "l'instant (clés manquantes ou source en attente) — schéma "391 "cible : `creators` + `accounts`.")392 out.append("")393394 out.append("## Fréquence & budget")395 out.append("")396 out.append(f"- **Cadence déclarée (registre)** : {reg.get('cadence', '—')}")397 cad = fmt_secs(st["cadence"]) if st["cadence"] else "—"398 out.append(f"- **Cadence observée** (médiane sync_log) : {cad}")399 last = st["last"]400 if last:401 out.append(f"- **Dernier passage** : {esc(last['ts'], 20)} — "402 f"{last['creators'] or 0} créateurs, {last['accounts'] or 0} "403 f"comptes, +{last['added'] or 0} / ~{last['updated'] or 0}, "404 f"{last['errors'] or 0} erreur(s), "405 f"{(last['seconds'] or 0):.0f} s")406 if entry and entry["budgets"]:407 caps = ", ".join(f"`{k}` = {v}" for k, v in sorted(entry["budgets"].items()))408 out.append(f"- **Caps / budgets du module** : {caps}")409 if entry:410 out.append(f"- **Throttling** : {entry['request_delay']} s entre "411 f"requêtes (`request_delay`)")412 out.append("")413414 out.append("## Volumétrie & complétude")415 out.append("")416 out.append(f"- **Créateurs découverts par la source** (`doc.source`) : "417 f"{st['mine']} · **fiches touchées** (`source_ids`) : "418 f"{st['touched']}")419 if st["mine"]:420 out.append(f"- **Complétude clé (découverts)** : niches "421 f"{pct(st['doc_fill']['niches'], st['mine'])} · avatar "422 f"{pct(st['doc_fill']['avatar_url'], st['mine'])} · portée "423 f"{pct(st['doc_fill']['total_reach'], st['mine'])} · région "424 f"{pct(st['doc_fill']['region'], st['mine'])}")425 if st["acc"] is not None and st["acc"]["n"]:426 a = st["acc"]427 out.append(f"- **Comptes `{', '.join(st['platforms'])}` en BD** : "428 f"{a['n']} — abonnés {pct(a['f_followers'], a['n'])} · "429 f"métriques {pct(a['f_metrics'], a['n'])} · dernière "430 f"vérification {esc(a['checked'] or '—', 20)}")431 out.append(f"- **Runs journalisés (60 derniers)** : {len(st['runs'])}, "432 f"dont {sum(1 for r in st['runs'] if (r['errors'] or 0) > 0)} "433 f"avec erreurs")434 out.append("")435436 out.append("## Erreurs connues & dépannage")437 out.append("")438 if st["alerts"]:439 out.append("| Passage | Erreurs | Alerte (sync_log) |")440 out.append("|---|---|---|")441 for r in st["alerts"]:442 out.append(f"| {esc(r['ts'], 20)} | {r['errors'] or 0} | "443 f"{esc(r['alert'] or '—', 160)} |")444 out.append("")445 else:446 out.append("Aucune erreur ni alerte dans les 60 derniers runs "447 "journalisés.")448 out.append("")449 if str(etat) != "actif":450 out.append(f"**Statut du registre** : {etat}")451 out.append("")452 out.append(f"Rejouer la source seule : `python3 run.py sync {sid}` · "453 f"vérifier `sync_log` (`SELECT * FROM sync_log WHERE "454 f"source='{sid}' ORDER BY ts DESC LIMIT 5;`).")455 out.append("")456457 out.append("## Licence, attribution & conditions")458 out.append("")459 out.append(f"- **Cadre d'accès (registre)** : {reg.get('acces', '—')}")460 low = (reg.get("acces") or "").lower()461 if "wikidata" in low or "sparql" in low:462 out.append("- **Licence** : données Wikidata sous CC0 — réutilisation "463 "libre, mention « Source : Wikidata » affichée par "464 "courtoisie.")465 elif "dataset local" in low or "listes" in sid:466 out.append("- **Données compilées** de listes et palmarès médias "467 "**publics** ; seuls des faits publics (nom, handle, "468 "audience approximative) sont conservés.")469 elif "api" in low and ("publique" in low or "itunes" in low):470 out.append("- **API publique** utilisée selon ses conditions (pas de "471 "clé détournée, throttling poli) ; données limitées aux "472 "profils publics.")473 else:474 out.append("- **Profils publics uniquement** : UA identifiable "475 "`CreaKaBot/1.0 (+https://www.crea-ka.com/bot; "476 "contact@spboucher.ai)`, throttling poli, respect des "477 "opt-out (`data/optout.json`) et du volet éthique "478 "(`creaka/ethics.py` — mineurs exclus via `is_minor`).")479 out.append("- Retrait sur demande : contact@spboucher.ai (opt-out honoré "480 "à la prochaine ingestion).")481 out.append("")482483 out.append("## Historique")484 out.append("")485 blob = " ".join(str(reg.get(k, "")) for k in ("notes", "statut", "acces"))486 for d in sorted({m.group(0) for m in DATE_RE.finditer(blob)}):487 out.append(f"- {d} — date mentionnée au registre (voir notes/statut).")488 if st["runs"]:489 first = st["runs"][-1]["ts"]490 out.append(f"- {str(first)[:10]} — plus ancien passage journalisé dans "491 f"`sync_log` (fenêtre des 60 derniers).")492 out.append("- 2026-08-18 — vague d'enrichissement : standardisation de la "493 "documentation des connecteurs (fiche générée par "494 "`scripts/gen_connector_docs.py`).")495 out.append("")496 return "\n".join(out)497498499def main() -> None:500 now = datetime.now().strftime("%Y-%m-%d %H:%M")501 registry = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"]502 con = sqlite3.connect(DB_PATH)503 con.row_factory = sqlite3.Row504 creators = load_creators(con)505506 by_sid: dict[str, dict] = {}507 for path in sorted(CONN_DIR.glob("*.py")):508 if path.stem in SKIP_MODULES:509 continue510 for e in introspect_module(path):511 by_sid[e["source_id"]] = e512513 DOCS_DIR.mkdir(parents=True, exist_ok=True)514 for old in DOCS_DIR.glob("*.md"):515 old.unlink()516517 rows = []518 for reg in registry:519 sid = reg["id"]520 entry = by_sid.get(sid)521 st = db_stats(con, sid, creators)522 (DOCS_DIR / f"{sid}.md").write_text(523 render_fiche(reg, entry, st, now), encoding="utf-8")524 last = st["last"]525 acc = st["acc"]526 comptes = (f"{acc['n']} ({', '.join(st['platforms'])})"527 if acc is not None and acc["n"] else "—")528 rows.append(529 f"| [`{sid}`]({sid}.md) | {reg.get('famille', '—')} "530 f"| P{reg.get('palier', '—')} "531 f"| {esc(entry['flavor'] if entry else '—', 42)} "532 f"| {entry['backend_family'] if entry else '—'} "533 f"| {st['mine']} | {comptes} "534 f"| {esc(str(reg.get('statut', 'actif')).split('—')[0], 26)} "535 f"| {esc(last['ts'], 17) if last else '—'} |")536537 n_creators = con.execute("SELECT count(*) FROM creators").fetchone()[0]538 n_accounts = con.execute("SELECT count(*) FROM accounts").fetchone()[0]539 idx = [540 "# Créa-Ka — Index des connecteurs", "",541 f"_Généré automatiquement par `scripts/gen_connector_docs.py` le {now} "542 f"— ne pas éditer à la main, régénérer._", "",543 f"**{len(registry)} sources au registre** · **{n_creators} créateurs** "544 f"et **{n_accounts} comptes** en BD.", "",545 "| Source | Famille | Palier | Type d'accès | Backend | Découverts "546 "| Comptes (plateforme) | État | Dernier passage |",547 "|---|---|---|---|---|---|---|---|---|",548 ]549 idx.extend(rows)550 idx.append("")551 (DOCS_DIR / "INDEX.md").write_text("\n".join(idx), encoding="utf-8")552 con.close()553 print(f"[gen_connector_docs] {len(registry)} fiches + INDEX.md écrits dans "554 f"{DOCS_DIR}")555556557if __name__ == "__main__":558 main()559