1#!/usr/bin/env python32# =============================================================================3# Job·Ka — Groupe KA4# Auteur : Simon-Pierre Boucher5# Contact : contact@spboucher.ai6# Fichier : scripts/gen_connector_docs.py7# Rôle : Générateur REJOUABLE de la documentation standardisée des8# connecteurs (standard Groupe-KA, cf. lou-ka / sorti-ka) →9# docs/connecteurs/INDEX.md + une fiche par plateforme ATS, par10# employeur, par portail et pour le dépôt direct11# Créé : 2026-08-18 Modifié : 2026-08-1812# =============================================================================13"""Générateur de la documentation des connecteurs Job·Ka.1415Croise QUATRE vérités, sans rien inventer :16 1. le registre data/sources.json (nom, URL carrières, secteurs, région) ;17 2. l'introspection du code jobka/connectors/*.py — ast (endpoints,18 variables d'environnement) + registre runtime CONNECTORS (identifiants19 de tenant, throttle, backend) ;20 3. la base LIVE data/jobka.db, ouverte en LECTURE SEULE (volumétrie,21 complétude par champ, dernier sync, erreurs sync_log) ;22 4. l'historique git des fichiers connecteurs.2324Usage : .venv/bin/python scripts/gen_connector_docs.py25(stdlib seulement côté script ; l'import du paquet jobka nécessite le venv)26"""27from __future__ import annotations2829import ast30import json31import re32import sqlite333import subprocess34import sys35from datetime import datetime, timezone36from pathlib import Path3738ROOT = Path(__file__).resolve().parents[1]39sys.path.insert(0, str(ROOT))4041SOURCES_JSON = ROOT / "data" / "sources.json"42DB_PATH = ROOT / "data" / "jobka.db"43CONNECTORS_DIR = ROOT / "jobka" / "connectors"44OUT_DIR = ROOT / "docs" / "connecteurs"4546NOW = datetime.now().astimezone()47STAMP = NOW.strftime("%Y-%m-%d %H:%M %Z")484950# En-tête d'auteur (règle nº 1) apposé sur chaque fiche générée.51def md_header(fname: str, role: str) -> str:52 return (53 "<!--\n"54 "=============================================================================\n"55 "Job·Ka — Groupe KA\n"56 "Auteur : Simon-Pierre Boucher\n"57 "Contact : contact@spboucher.ai\n"58 f"Fichier : docs/connecteurs/{fname}\n"59 f"Rôle : {role}\n"60 f"Généré : {STAMP} par scripts/gen_connector_docs.py — NE PAS ÉDITER,\n"61 " relancer le script pour régénérer.\n"62 "=============================================================================\n"63 "-->\n\n")646566# Champs documentés dans « Champs → schéma » (colonne SQL, libellé, rôle).67SCHEMA_FIELDS = [68 ("title", "Titre", "titre du poste"),69 ("employer", "Employeur", "nom de l'entreprise"),70 ("url", "URL", "lien direct vers l'offre originale"),71 ("description", "Description", "texte complet (HTML nettoyé)"),72 ("city", "Ville", "municipalité normalisée"),73 ("region", "Région", "région administrative"),74 ("address", "Adresse", "adresse du lieu de travail"),75 ("postal_code", "Code postal", "code postal si publié"),76 ("work_mode", "Mode", "presentiel / hybride / teletravail"),77 ("employment_type", "Type", "temps_plein / temps_partiel / …"),78 ("salary", "Salaire ($)", "bornes numériques normalisées $/h et $/an"),79 ("salary_label", "Libellé salaire", "texte salarial original"),80 ("benefits", "Avantages", "avantages sociaux publiés (JSON)"),81 ("requirements", "Exigences", "scolarité, expérience, langues (JSON)"),82 ("date_posted", "Date de parution", "ISO 8601"),83 ("date_deadline", "Date limite", "date limite pour postuler"),84 ("category", "Catégorie", "taxonomie interne (TI, Santé, …)"),85 ("gps", "GPS", "lat/lng (géocodage sans invention)"),86]8788FIELD_CONDS = {89 "salary": "(salary_year_min IS NOT NULL OR salary_year_max IS NOT NULL)",90 "gps": "(lat IS NOT NULL AND lng IS NOT NULL)",91 "description": "(description IS NOT NULL AND LENGTH(description) > 50)",92 "benefits": "(benefits IS NOT NULL AND benefits NOT IN ('', '[]'))",93 "requirements": "(requirements IS NOT NULL AND requirements NOT IN ('', '{}'))",94 "work_mode": "(work_mode IS NOT NULL AND work_mode != '')",95 "employment_type": "(employment_type IS NOT NULL AND employment_type != '')",96}97for _col in ("title", "employer", "url", "city", "region", "address",98 "postal_code", "salary_label", "date_posted", "date_deadline",99 "category"):100 FIELD_CONDS[_col] = f"({_col} IS NOT NULL AND {_col} != '')"101102# Complétude « clé » (INDEX + tableaux de tenants).103KEY_METRICS = [("salary", "Salaire"), ("gps", "GPS"),104 ("work_mode", "Mode"), ("date_posted", "Parution")]105106# Conformité — texte par famille (repris dans docs/CONFORMITE.md).107CONFO_ATS = (108 "**Flux public du site carrières officiel de l'employeur** (API JSON de "109 "l'ATS ou HTML rendu serveur) — le même contenu que la page carrière "110 "affiche publiquement, prévu pour la diffusion des offres, sans "111 "authentification. Chaque offre conserve son URL originale et le bouton "112 "« Postuler chez {employer} » renvoie chez l'employeur. Aucune donnée "113 "personnelle de candidat n'est collectée. User-Agent identifié "114 "`JobKaBot/1.0 (+https://www.job-ka.com; contact@spboucher.ai)` sur les "115 "accès directs ; requêtes throttlées et plafonnées par budgets.")116CONFO_SCRAPFLY_NOTE = (117 "Certains domaines de cette plateforme sont servis derrière un "118 "gestionnaire anti-bot : la lecture passe alors par Scrapfly, toujours "119 "sur des pages strictement publiques, au même rythme throttlé.")120CONFO_PORTAIL = {121 "guichet_emplois": (122 "Scraping de pages **publiques** (gouvernement du Canada) avec lien "123 "vers la fiche Guichet-Emplois, qui référence l'offre originale. "124 "`robots.txt` respecté — **Crawl-delay: 5 s appliqué** "125 "(`request_delay = 5.0`), fenêtre bornée (pages + détails plafonnés). "126 "Aucune donnée personnelle de candidat collectée."),127 "jobillico": (128 "Parcours des **sitemaps publics** (`sitemap_job_postings_*.xml`) et "129 "lecture du **JSON-LD schema.org/JobPosting** que chaque page offre "130 "publie à destination des moteurs de recherche. Lien vers l'offre "131 "originale sur Jobillico. robots.txt respecté ; budgets de visites "132 "par sync. Aucune donnée personnelle de candidat collectée."),133 "espresso_jobs": (134 "Lecture du **JSON-LD schema.org** (ItemList + JobPosting) publié "135 "par les pages publiques d'Espresso-Jobs. Lien vers l'offre "136 "originale sur le portail. robots.txt respecté ; budgets de visites "137 "par sync. Aucune donnée personnelle de candidat collectée."),138}139CONFO_DEPOT = (140 "Offres **soumises volontairement par les employeurs** via le formulaire "141 "public `/employeurs` (`POST /api/employeurs/offres`). Stockées "142 "INACTIVES en attente de **modération manuelle** ; pot de miel "143 "anti-robots ; seul un courriel d'affaires de contact (fourni "144 "volontairement) est conservé dans `details.contact_email`. Aucune "145 "donnée personnelle de candidat.")146147HIST_MISSION = (148 "**Mission connecteurs du 2026-08-18** — passage de 135 à 217 employeurs "149 "directs connectés, de 9 à 17 plateformes ATS, ajout de 3 portails "150 "agrégateurs et du canal de dépôt direct :\n"151 "- `fd4bfc8` qualité lieux : rejet des villes hors Québec, bâtiments "152 "McGill → Montréal ;\n"153 "- `5a66f48` +59 employeurs (sondage massif d'ATS + détection de pages "154 "carrières) ;\n"155 "- `4917d57` 8 nouveaux ATS (Taleo, Njoyn, UltiPro, iCIMS, "156 "SuccessFactors, ADP WFN, Digital Recruiters, Workland) + 23 employeurs "157 "publics/parapublics ;\n"158 "- `f6e7571` portails Guichet-Emplois, Jobillico, Espresso-Jobs — "159 "l'offre directe gagne toujours la canonique (dédup) ;\n"160 "- `744cbfc` badge « Offre directe », stats directes/portails, dépôt "161 "direct employeurs ;\n"162 "- `1604ff9` reprise sur coupure WAF Guichet-Emplois (retry + acquis "163 "partiel).")164165166# ---------------------------------------------------------------------------167# Introspection code168# ---------------------------------------------------------------------------169170def introspect_module(path: Path) -> dict:171 """Docstring, endpoints http et variables d'env d'un module connecteur."""172 info = {"file": path.name, "doc": "", "endpoints": [], "env": [],173 "scrapfly": False}174 if not path.exists():175 return info176 text = path.read_text(encoding="utf-8")177 info["scrapfly"] = "scrapfly" in text.lower()178 try:179 tree = ast.parse(text)180 except SyntaxError:181 return info182 info["doc"] = ast.get_docstring(tree) or ""183 for node in tree.body:184 if isinstance(node, ast.Assign) and len(node.targets) == 1 and \185 isinstance(node.targets[0], ast.Name):186 val = node.value187 if isinstance(val, ast.Constant) and isinstance(val.value, str) \188 and val.value.startswith("http"):189 info["endpoints"].append((node.targets[0].id, val.value))190 env_re = re.compile(191 r'os\.environ\.get\(\s*"([A-Z_]+)"\s*(?:,\s*"([^"]*)")?\s*\)')192 for m in env_re.finditer(text):193 info["env"].append((m.group(1), m.group(2) or "—"))194 return info195196197def class_identifiers(cls) -> list[tuple[str, str]]:198 """Attributs de classe MAJUSCULES (TENANT, SITE, BOARD…) = identifiants199 du tenant chez l'ATS. Introspection générique, aucune liste à maintenir."""200 out = []201 for name in sorted(dir(cls)):202 if not name.isupper() or name.startswith("_"):203 continue204 try:205 v = getattr(cls, name)206 except Exception:207 continue208 if isinstance(v, (str, int, float)) and v != "" and \209 not callable(v) and len(str(v)) <= 120:210 out.append((name, str(v)))211 return out212213214def pagination_hint(text: str) -> str:215 low = text.lower()216 hints = []217 if "offset" in low:218 hints.append("offset/limit")219 if re.search(r"page_no|\?page=|page=\{|&page|pagenumber|/page/", low):220 hints.append("par numéro de page")221 if "sitemap" in low:222 hints.append("parcours de sitemap XML")223 if "cursor" in low or "nexturl" in low or "next_url" in low:224 hints.append("curseur / lien suivant")225 return ", ".join(dict.fromkeys(hints)) or "flux unique (une requête liste)"226227228# ---------------------------------------------------------------------------229# Base live (lecture seule)230# ---------------------------------------------------------------------------231232class Live:233 def __init__(self, con: sqlite3.Connection):234 self.c = con.cursor()235 self.total = self._group("SELECT source, COUNT(*) FROM jobs GROUP BY source")236 self.active = self._group(237 "SELECT source, COUNT(*) FROM jobs WHERE active=1 GROUP BY source")238 self.masked = self._group(239 "SELECT source, COUNT(*) FROM jobs WHERE active=1 AND dup_of IS "240 "NOT NULL GROUP BY source")241 self.fields: dict[str, dict[str, int]] = {}242 for col, _l, _d in SCHEMA_FIELDS:243 self.fields[col] = self._group(244 f"SELECT source, COUNT(*) FROM jobs WHERE active=1 AND "245 f"{FIELD_CONDS[col]} GROUP BY source")246 self.last_sync: dict[str, sqlite3.Row] = {}247 for r in self.c.execute(248 "SELECT * FROM sync_log WHERE id IN "249 "(SELECT MAX(id) FROM sync_log GROUP BY source)"):250 self.last_sync[r["source"]] = r251 self.nsync = self._group(252 "SELECT source, COUNT(*) FROM sync_log GROUP BY source")253 self.errors: dict[str, list] = {}254 for r in self.c.execute(255 "SELECT source, message, COUNT(*) n, MAX(ts) ts FROM sync_log "256 "WHERE ok=0 GROUP BY source, message ORDER BY MAX(ts) DESC"):257 self.errors.setdefault(r["source"], []).append(r)258259 def _group(self, sql: str) -> dict[str, int]:260 return {r[0]: r[1] for r in self.c.execute(sql)}261262 def example(self, sid: str) -> dict:263 r = self.c.execute(264 "SELECT title, city, salary_label, date_posted, employment_type "265 "FROM jobs WHERE source=? AND active=1 "266 "ORDER BY (salary_label != '') DESC, date_posted DESC LIMIT 1",267 (sid,)).fetchone()268 return dict(r) if r else {}269270271def fmt_ts(ts) -> str:272 if not ts:273 return "n/d"274 return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() \275 .strftime("%Y-%m-%d %H:%M")276277278def pct(n: int, d: int) -> str:279 return f"{100.0 * n / d:.0f} %" if d else "—"280281282def esc(v) -> str:283 if v is None or v == "":284 return "∅"285 s = str(v).replace("\n", " ").replace("|", "\\|").strip()286 return (s[:90] + "…") if len(s) > 90 else s287288289# ---------------------------------------------------------------------------290# Historique git — un seul appel, mappé fichier → commits291# ---------------------------------------------------------------------------292293def git_map() -> dict[str, list[str]]:294 out = subprocess.run(295 ["git", "log", "--date=short", "--format=@%h %ad %s", "--name-only",296 "--", "jobka/connectors"],297 cwd=ROOT, capture_output=True, text=True, timeout=60).stdout298 mapping: dict[str, list[str]] = {}299 cur = ""300 for line in out.splitlines():301 if line.startswith("@"):302 cur = line[1:]303 elif line.strip().startswith("jobka/connectors/"):304 fname = line.strip().rsplit("/", 1)[-1]305 lst = mapping.setdefault(fname, [])306 if len(lst) < 10:307 lst.append(cur)308 return mapping309310311# ---------------------------------------------------------------------------312# Sections communes313# ---------------------------------------------------------------------------314315def sec_champs(L: list[str], live: Live, sid: str) -> None:316 L.append("## Champs → schéma")317 L.append("")318 act = live.active.get(sid, 0)319 if not act:320 L.append("*Aucune offre active en base pour cette source.*")321 L.append("")322 return323 ex = live.example(sid)324 L.append(f"Complétude mesurée sur les **{act} offres actives** de la "325 f"source (base live, {STAMP}).")326 L.append("")327 L.append("| Champ (`jobs.*`) | Rôle | Complétude |")328 L.append("|---|---|---|")329 for col, label, desc in SCHEMA_FIELDS:330 n = live.fields[col].get(sid, 0)331 L.append(f"| `{col}` — {label} | {desc} | {pct(n, act)} |")332 L.append("")333 if ex:334 L.append(f"Exemple réel : *{esc(ex.get('title'))}* — "335 f"{esc(ex.get('city'))} · {esc(ex.get('salary_label'))} · "336 f"parue le {esc(ex.get('date_posted'))}.")337 L.append("")338339340def sec_volumetrie(L: list[str], live: Live, sid: str) -> None:341 L.append("## Volumétrie & complétude live")342 L.append("")343 L.append(f"*(mesuré le {STAMP} dans `data/jobka.db`)*")344 L.append("")345 act, tot = live.active.get(sid, 0), live.total.get(sid, 0)346 L.append(f"- **Offres en base** : {tot} (dont **{act} actives**, "347 f"{live.masked.get(sid, 0)} masquées comme doublons `dup_of`)")348 for col, label in KEY_METRICS:349 L.append(f"- **{label}** : {live.fields[col].get(sid, 0)}/{act} "350 f"actives ({pct(live.fields[col].get(sid, 0), act)})")351 ls = live.last_sync.get(sid)352 if ls is not None:353 L.append(f"- **Dernier sync** : {fmt_ts(ls['ts'])} — trouvées "354 f"{ls['found']}, +{ls['added']}, ~{ls['updated']}, "355 f"-{ls['removed']}" +356 ("" if ls["ok"] else f", **ÉCHEC : {esc(ls['message'])}**"))357 L.append(f"- **Syncs enregistrés** (`sync_log`) : {live.nsync.get(sid, 0)}")358 L.append("")359360361def sec_erreurs(L: list[str], live: Live, sid: str,362 extra: tuple | list = ()) -> None:363 L.append("## Erreurs & dépannage")364 L.append("")365 errs = live.errors.get(sid, [])366 if errs:367 L.append("Erreurs relevées dans `sync_log` :")368 L.append("")369 L.append("| Erreur | Occurrences | Dernière fois |")370 L.append("|---|---|---|")371 for e in errs[:5]:372 L.append(f"| {esc(e['message'])} | {e['n']} | {fmt_ts(e['ts'])} |")373 else:374 L.append("Aucune erreur dans `sync_log` pour cette source.")375 L.append("")376 for tip in extra:377 L.append(f"- {tip}")378 L.append(f"- Rejouer un sync isolé : `.venv/bin/python run.py sync {sid}` "379 f"puis inspecter `sync_log` : `sqlite3 data/jobka.db \"SELECT * "380 f"FROM sync_log WHERE source='{sid}' ORDER BY ts DESC LIMIT 5;\"`")381 L.append("- Garde-fous : une source qui retourne ≤ 25 % de sa médiane "382 "historique déclenche une alerte et les retraits sont suspendus ; "383 "une offre doit manquer 2 syncs consécutifs avant `active=0`.")384 L.append("")385386387def sec_frequence(L: list[str], env: list, delay) -> None:388 L.append("## Fréquence & budgets")389 L.append("")390 L.append("- **Cadence** : resynchronisation **horaire** (PM2 `job-ka-sync` "391 "= `run.py watch 60`), suivie du géocodage en lot puis de la "392 "déduplication inter-sources.")393 if delay is not None:394 L.append(f"- **Throttle** : {delay} s minimum entre deux requêtes "395 f"vers la source (`request_delay`).")396 if env:397 L.append("- **Budgets / plafonds (variables d'environnement)** :")398 L.append("")399 L.append("| Variable | Défaut |")400 L.append("|---|---|")401 for var, default in dict(env).items():402 L.append(f"| `{var}` | `{default}` |")403 L.append("- Cache BD des pages détail (`detail_cache`) : le détail d'une "404 "offre n'est re-téléchargé que si sa ligne de liste change.")405 L.append("")406407408def sec_historique(L: list[str], gmap: dict, fname: str | None) -> None:409 L.append("## Historique")410 L.append("")411 L.append(HIST_MISSION)412 if fname and gmap.get(fname):413 L.append("")414 L.append(f"Commits touchant `jobka/connectors/{fname}` :")415 L.append("")416 for h in gmap[fname]:417 L.append(f"- `{h.split(' ', 1)[0]}` {h.split(' ', 1)[1]}")418 L.append("")419420421# ---------------------------------------------------------------------------422# Fiches423# ---------------------------------------------------------------------------424425def fiche_employeur(src: dict, cls, ats_intro: dict, live: Live,426 gmap: dict) -> str:427 sid = src["id"]428 ats = src["connector"]429 fname = Path(sys.modules[cls.__module__].__file__).name if cls else None430 L: list[str] = [f"# {src['name']} (`{sid}`)", ""]431 L.append(f"**Employeur direct** · Plateforme ATS : "432 f"[`{ats}`](ats-{ats}.md) · Statut registre : "433 f"{src.get('status', 'n/d')}" +434 (f" · Connecteur : `jobka/connectors/{fname}`" if fname else ""))435 L.append("")436 L.append("## Description")437 L.append("")438 L.append(f"- **Site** : [{src['name']}]({src['url']})")439 L.append(f"- **Page carrières** : {src.get('careers_url', 'n/d')}")440 L.append(f"- **Secteurs** : {', '.join(src.get('sectors', [])) or 'n/d'}")441 L.append(f"- **Région** : {src.get('region', 'n/d')}")442 L.append("- Offres marquées **« Offre directe »** dans l'app (badge) : la "443 "candidature se fait chez l'employeur, jamais via un intermédiaire.")444 L.append("")445 L.append("## Accès")446 L.append("")447 L.append(f"- **Mécanique** : voir la fiche plateforme "448 f"[`ats-{ats}.md`](ats-{ats}.md) — API JSON publique du site "449 f"carrières, backend `requests` direct (Scrapfly seulement si "450 f"indiqué sur la fiche plateforme).")451 if cls is not None:452 ids = class_identifiers(cls)453 if ids:454 L.append("- **Identifiants du tenant** (attributs du connecteur) :")455 L.append("")456 L.append("| Attribut | Valeur |")457 L.append("|---|---|")458 for k, v in ids:459 L.append(f"| `{k}` | `{esc(v)}` |")460 delay = getattr(cls, "request_delay", None)461 if delay is not None:462 L.append(f"- **Throttle** : {delay} s entre requêtes.")463 L.append("- **Filtre Québec** : seuls les postes localisés au Québec sont "464 "conservés (facette serveur quand l'ATS en offre une, sinon "465 "`is_quebec_location` côté client).")466 L.append("")467 sec_champs(L, live, sid)468 sec_frequence(L, ats_intro.get("env", []),469 getattr(cls, "request_delay", None) if cls else None)470 sec_volumetrie(L, live, sid)471 sec_erreurs(L, live, sid)472 L.append("## Licence / conformité")473 L.append("")474 L.append(CONFO_ATS.format(employer=src["name"]))475 if ats_intro.get("scrapfly"):476 L.append("")477 L.append(CONFO_SCRAPFLY_NOTE)478 L.append("")479 sec_historique(L, gmap, fname)480 return md_header(f"{sid}.md",481 f"Fiche connecteur — {src['name']} (employeur direct, "482 f"ATS {ats})") + "\n".join(L)483484485def fiche_ats(ats: str, tenants: list[dict], live: Live, gmap: dict) -> str:486 path = CONNECTORS_DIR / f"{ats}.py"487 intro = introspect_module(path)488 text = path.read_text(encoding="utf-8") if path.exists() else ""489 n_act = sum(live.active.get(t["id"], 0) for t in tenants)490 L: list[str] = [f"# Plateforme ATS `{ats}`", ""]491 L.append(f"**{len(tenants)} employeurs connectés** via cette plateforme — "492 f"**{n_act} offres actives**. Module partagé : "493 f"`jobka/connectors/{ats}.py` ; un employeur = une sous-classe "494 f"d'environ 10 lignes.")495 L.append("")496 L.append("## Description & mécanique")497 L.append("")498 if intro["doc"]:499 for line in intro["doc"].splitlines():500 L.append(f"> {line}" if line.strip() else ">")501 else:502 L.append("*(module sans docstring)*")503 L.append("")504 L.append("## Accès")505 L.append("")506 if intro["endpoints"]:507 for name, url in intro["endpoints"]:508 L.append(f"- **Endpoint** (`{name}`) : `{url}`")509 L.append(f"- **Pagination** : {pagination_hint(text)}")510 backend = ("Scrapfly (anti-bot / rendu JS) via `BaseConnector.scrapfly()`"511 if "scrapfly" in text.lower() else512 "GET/POST direct `requests` (throttlé, User-Agent "513 "`JobKaBot/1.0` identifié)")514 L.append(f"- **Backend** : {backend}")515 L.append("- **Auth** : aucune — API/pages publiques du site carrières.")516 m = re.search(r"request_delay\s*=\s*([\d.]+)", text)517 if m:518 L.append(f"- **Throttle** : {m.group(1)} s entre requêtes.")519 if intro["env"]:520 L.append("- **Budgets (env)** : " +521 ", ".join(f"`{v}` (défaut `{d}`)"522 for v, d in dict(intro["env"]).items()))523 L.append("")524 L.append("## Tenants connectés")525 L.append("")526 L.append("| Employeur | Actives | Salaire | GPS | Mode | Dernier sync | OK |")527 L.append("|---|---|---|---|---|---|---|")528 for t in sorted(tenants, key=lambda t: -live.active.get(t["id"], 0)):529 sid = t["id"]530 act = live.active.get(sid, 0)531 ls = live.last_sync.get(sid)532 ok = ("✅" if ls is not None and ls["ok"] else533 ("❌" if ls is not None else "n/d"))534 cells = [pct(live.fields[c].get(sid, 0), act)535 for c in ("salary", "gps", "work_mode")]536 L.append(f"| [{t['name']}]({sid}.md) | {act} | " + " | ".join(cells) +537 f" | {fmt_ts(ls['ts']) if ls is not None else 'n/d'} | {ok} |")538 L.append("")539 L.append("## Licence / conformité")540 L.append("")541 L.append(CONFO_ATS.format(employer="l'employeur"))542 if intro["scrapfly"]:543 L.append("")544 L.append(CONFO_SCRAPFLY_NOTE)545 L.append("")546 sec_historique(L, gmap, f"{ats}.py")547 return md_header(f"ats-{ats}.md",548 f"Fiche plateforme ATS {ats} — mécanique partagée + "549 f"tableau des {len(tenants)} tenants") + "\n".join(L)550551552def fiche_portail(src: dict, cls, live: Live, gmap: dict) -> str:553 sid = src["id"]554 path = CONNECTORS_DIR / f"{sid}.py"555 intro = introspect_module(path)556 text = path.read_text(encoding="utf-8") if path.exists() else ""557 L: list[str] = [f"# {src['name']} (`{sid}`) — portail agrégateur", ""]558 L.append("**Portail agrégateur** (`dedup.AGGREGATORS`) : source "559 "complémentaire, moins autoritaire qu'une page carrière. En cas "560 "de doublon avec une offre directe, **l'offre directe gagne la "561 "canonique** et la copie portail est masquée (`dup_of`). Ces "562 "offres ne portent pas le badge « Offre directe ».")563 L.append("")564 L.append("## Description")565 L.append("")566 L.append(f"- **Portail** : [{src['name']}]({src['url']})")567 L.append(f"- **Secteurs** : {', '.join(src.get('sectors', [])) or 'généraliste'}")568 if intro["doc"]:569 L.append("")570 for line in intro["doc"].splitlines():571 L.append(f"> {line}" if line.strip() else ">")572 L.append("")573 L.append("## Accès")574 L.append("")575 for name, url in intro["endpoints"]:576 L.append(f"- **Endpoint** (`{name}`) : `{url}`")577 L.append(f"- **Pagination** : {pagination_hint(text)}")578 backend = ("Scrapfly (anti-bot / rendu JS)" if "scrapfly" in text.lower()579 else "GET direct `requests` (throttlé, UA `JobKaBot/1.0`)")580 L.append(f"- **Backend** : {backend}")581 delay = getattr(cls, "request_delay", None) if cls else None582 if delay is not None:583 L.append(f"- **Throttle** : {delay} s entre requêtes" +584 (" — **respect du `Crawl-delay: 5` de robots.txt**."585 if sid == "guichet_emplois" else "."))586 L.append("")587 sec_champs(L, live, sid)588 sec_frequence(L, intro["env"], delay)589 sec_volumetrie(L, live, sid)590 tips = []591 if sid == "guichet_emplois":592 tips.append("Le WAF gouvernemental coupe parfois la connexion en "593 "cours de pagination : le connecteur fait un retry puis "594 "conserve l'acquis partiel (commit `1604ff9`) — une "595 "erreur `RemoteDisconnected`/`Read timed out` ponctuelle "596 "est attendue et non bloquante.")597 if sid == "jobillico":598 tips.append("Un `410 Gone` sur une page offre est normal (offre "599 "retirée entre le sitemap et la visite) — non bloquant.")600 sec_erreurs(L, live, sid, tips)601 L.append("## Licence / conformité")602 L.append("")603 L.append(CONFO_PORTAIL[sid])604 L.append("")605 sec_historique(L, gmap, path.name)606 return md_header(f"{sid}.md",607 f"Fiche connecteur — portail agrégateur {src['name']}") \608 + "\n".join(L)609610611def fiche_depot(live: Live) -> str:612 sid = "depot-direct"613 L: list[str] = ["# Dépôt direct employeurs (`depot-direct`)", ""]614 L.append("**Canal de dépôt** : pas un connecteur de collecte — les "615 "employeurs déposent eux-mêmes leurs offres.")616 L.append("")617 L.append("## Description")618 L.append("")619 L.append("- Formulaire public `/employeurs` du site ; endpoint "620 "`POST /api/employeurs/offres` (`jobka/web.py`).")621 L.append("- L'offre est stockée **INACTIVE** (`active=0`, "622 "`details.moderation = en_attente`) jusqu'à modération manuelle.")623 L.append("- Validations : courriel de contact, URL `http(s)://`, type "624 "d'emploi et mode de travail dans les vocabulaires fermés.")625 L.append("- **Pot de miel anti-robots** : champ caché `website` — s'il "626 "est rempli, l'offre est silencieusement ignorée.")627 L.append("")628 L.append("## Accès")629 L.append("")630 L.append("- **Endpoint** : `POST /api/employeurs/offres` (JSON).")631 L.append("- **Backend** : aucun fetch sortant — données poussées par "632 "l'employeur.")633 L.append("")634 sec_champs(L, live, sid)635 L.append("## Fréquence & budgets")636 L.append("")637 L.append("- Au fil de l'eau (soumissions) ; publication après modération "638 "manuelle.")639 L.append("")640 sec_volumetrie(L, live, sid)641 tot = live.total.get(sid, 0)642 act = live.active.get(sid, 0)643 L.append(f"Soumissions en attente de modération : **{tot - act}**.")644 L.append("")645 L.append("## Erreurs & dépannage")646 L.append("")647 L.append("- `422` sur le formulaire = validation (courriel, URL, "648 "vocabulaires). Les soumissions piégées par le pot de miel ne "649 "sont pas stockées.")650 L.append("- Lister les offres en attente : `sqlite3 data/jobka.db "651 "\"SELECT uid, employer, title FROM jobs WHERE "652 "source='depot-direct' AND active=0;\"`")653 L.append("")654 L.append("## Licence / conformité")655 L.append("")656 L.append(CONFO_DEPOT)657 L.append("")658 L.append("## Historique")659 L.append("")660 L.append(HIST_MISSION)661 L.append("")662 return md_header("depot-direct.md",663 "Fiche du canal de dépôt direct employeurs") + "\n".join(L)664665666# ---------------------------------------------------------------------------667# INDEX668# ---------------------------------------------------------------------------669670def render_index(sources: list[dict], by_ats: dict, live: Live,671 n_fiches: int) -> str:672 employers = [s for s in sources673 if s["connector"] not in ("portail", "depot-direct")]674 portals = [s for s in sources if s["connector"] == "portail"]675 act_all = sum(live.active.values())676 masked_all = sum(live.masked.values())677 act_dir = sum(live.active.get(s["id"], 0) for s in employers)678 act_por = sum(live.active.get(s["id"], 0) for s in portals)679 L: list[str] = ["# Job·Ka — Connecteurs (index)", ""]680 L.append(f"> Index généré le {STAMP} par `scripts/gen_connector_docs.py` "681 f"(rejouable). Croise `data/sources.json`, l'introspection de "682 f"`jobka/connectors/*.py` et la base live `data/jobka.db`.")683 L.append("")684 L.append(f"**{len(employers)} employeurs directs** connectés via "685 f"**{len(by_ats)} plateformes ATS** + **{len(portals)} portails "686 f"agrégateurs** + le canal de **dépôt direct** — "687 f"**{act_all} offres actives** ({act_dir} directes, {act_por} "688 f"portails ; {masked_all} masquées comme doublons inter-sources, "689 f"soit {act_all - masked_all} visibles). {n_fiches} fiches.")690 L.append("")691 L.append("Conformité : voir [`docs/CONFORMITE.md`](../CONFORMITE.md). "692 "Chaque offre conserve son URL originale ; la candidature se "693 "fait chez l'employeur (badge « Offre directe » sur les sources "694 "directes).")695 L.append("")696 L.append("## Plateformes ATS")697 L.append("")698 L.append("| Plateforme | Employeurs | Actives | Salaire | GPS |")699 L.append("|---|---|---|---|---|")700 for ats in sorted(by_ats, key=lambda a: -sum(701 live.active.get(t["id"], 0) for t in by_ats[a])):702 ts = by_ats[ats]703 act = sum(live.active.get(t["id"], 0) for t in ts)704 sal = sum(live.fields["salary"].get(t["id"], 0) for t in ts)705 gps = sum(live.fields["gps"].get(t["id"], 0) for t in ts)706 L.append(f"| [`{ats}`](ats-{ats}.md) | {len(ts)} | {act} | "707 f"{pct(sal, act)} | {pct(gps, act)} |")708 L.append("")709 L.append("## Portails agrégateurs & dépôt")710 L.append("")711 L.append("| Source | Actives | Salaire | Dernier sync |")712 L.append("|---|---|---|---|")713 for s in portals:714 sid = s["id"]715 act = live.active.get(sid, 0)716 ls = live.last_sync.get(sid)717 L.append(f"| [{s['name']}]({sid}.md) | {act} | "718 f"{pct(live.fields['salary'].get(sid, 0), act)} | "719 f"{fmt_ts(ls['ts']) if ls is not None else 'n/d'} |")720 dd_tot = live.total.get("depot-direct", 0)721 dd_act = live.active.get("depot-direct", 0)722 L.append(f"| [Dépôt direct employeurs](depot-direct.md) | {dd_act} "723 f"({dd_tot - dd_act} en modération) | — | au fil de l'eau |")724 L.append("")725 L.append("## Employeurs directs (par volume d'offres actives)")726 L.append("")727 L.append("| Employeur | ATS | Actives | Salaire | GPS | Dernier sync OK |")728 L.append("|---|---|---|---|---|---|")729 for s in sorted(employers, key=lambda s: (-live.active.get(s["id"], 0),730 s["name"].lower())):731 sid = s["id"]732 act = live.active.get(sid, 0)733 ls = live.last_sync.get(sid)734 ok = ("✅" if ls is not None and ls["ok"] else735 ("❌" if ls is not None else "n/d"))736 L.append(f"| [{s['name']}]({sid}.md) | [`{s['connector']}`]"737 f"(ats-{s['connector']}.md) | {act} | "738 f"{pct(live.fields['salary'].get(sid, 0), act)} | "739 f"{pct(live.fields['gps'].get(sid, 0), act)} | {ok} |")740 L.append("")741 L.append("## Régénérer cette documentation")742 L.append("")743 L.append("```bash")744 L.append("cd ~/apps/job-ka && .venv/bin/python scripts/gen_connector_docs.py")745 L.append("```")746 L.append("")747 return md_header("INDEX.md", "Index généré de la documentation des "748 "connecteurs (ATS, employeurs, portails, dépôt)") \749 + "\n".join(L)750751752# ---------------------------------------------------------------------------753754def main() -> int:755 sources = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"]756 from jobka.connectors import CONNECTORS757 con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)758 con.row_factory = sqlite3.Row759 live = Live(con)760 gmap = git_map()761 OUT_DIR.mkdir(parents=True, exist_ok=True)762 for old in OUT_DIR.glob("*.md"):763 old.unlink()764765 by_ats: dict[str, list[dict]] = {}766 n = 0767 for src in sources:768 conn = src["connector"]769 if conn == "depot-direct":770 (OUT_DIR / "depot-direct.md").write_text(771 fiche_depot(live), encoding="utf-8")772 n += 1773 elif conn == "portail":774 cls = CONNECTORS.get(src["id"])775 (OUT_DIR / f"{src['id']}.md").write_text(776 fiche_portail(src, cls, live, gmap), encoding="utf-8")777 n += 1778 else:779 by_ats.setdefault(conn, []).append(src)780781 for ats, tenants in by_ats.items():782 intro = introspect_module(CONNECTORS_DIR / f"{ats}.py")783 for t in tenants:784 cls = CONNECTORS.get(t["id"])785 (OUT_DIR / f"{t['id']}.md").write_text(786 fiche_employeur(t, cls, intro, live, gmap), encoding="utf-8")787 n += 1788 (OUT_DIR / f"ats-{ats}.md").write_text(789 fiche_ats(ats, tenants, live, gmap), encoding="utf-8")790 n += 1791792 (OUT_DIR / "INDEX.md").write_text(793 render_index(sources, by_ats, live, n + 1), encoding="utf-8")794 n += 1795 print(f"[gen_connector_docs] {n} fichiers générés dans docs/connecteurs/ "796 f"({len(by_ats)} plateformes ATS, "797 f"{sum(len(v) for v in by_ats.values())} employeurs, "798 f"{sum(1 for s in sources if s['connector'] == 'portail')} portails, "799 f"1 dépôt direct).")800 con.close()801 return 0802803804if __name__ == "__main__":805 sys.exit(main())806