HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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 ("language", "Langue", "fr / en / bilingue (source ou heuristique)"),86 ("company_logo", "Logo employeur", "URL du logo si exposé par la source"),87 ("apply_url", "Candidature", "lien de candidature directe"),88 ("gps", "GPS", "lat/lng (géocodage sans invention)"),89]9091FIELD_CONDS = {92 "salary": "(salary_year_min IS NOT NULL OR salary_year_max IS NOT NULL)",93 "gps": "(lat IS NOT NULL AND lng IS NOT NULL)",94 "description": "(description IS NOT NULL AND LENGTH(description) > 50)",95 "benefits": "(benefits IS NOT NULL AND benefits NOT IN ('', '[]'))",96 "requirements": "(requirements IS NOT NULL AND requirements NOT IN ('', '{}'))",97 "work_mode": "(work_mode IS NOT NULL AND work_mode != '')",98 "employment_type": "(employment_type IS NOT NULL AND employment_type != '')",99}100for _col in ("title", "employer", "url", "city", "region", "address",101 "postal_code", "salary_label", "date_posted", "date_deadline",102 "category", "language", "company_logo", "apply_url"):103 FIELD_CONDS[_col] = f"({_col} IS NOT NULL AND {_col} != '')"104105# Complétude « clé » (INDEX + tableaux de tenants).106KEY_METRICS = [("salary", "Salaire"), ("gps", "GPS"),107 ("work_mode", "Mode"), ("date_posted", "Parution")]108109# Conformité — texte par famille (repris dans docs/CONFORMITE.md).110CONFO_ATS = (111 "**Flux public du site carrières officiel de l'employeur** (API JSON de "112 "l'ATS ou HTML rendu serveur) — le même contenu que la page carrière "113 "affiche publiquement, prévu pour la diffusion des offres, sans "114 "authentification. Chaque offre conserve son URL originale et le bouton "115 "« Postuler chez {employer} » renvoie chez l'employeur. Aucune donnée "116 "personnelle de candidat n'est collectée. User-Agent identifié "117 "`JobKaBot/1.0 (+https://www.job-ka.com; contact@spboucher.ai)` sur les "118 "accès directs ; requêtes throttlées et plafonnées par budgets.")119CONFO_SCRAPFLY_NOTE = (120 "Certains domaines de cette plateforme sont servis derrière un "121 "gestionnaire anti-bot : la lecture passe alors par Scrapfly, toujours "122 "sur des pages strictement publiques, au même rythme throttlé.")123CONFO_PORTAIL = {124 "guichet_emplois": (125 "Scraping de pages **publiques** (gouvernement du Canada) avec lien "126 "vers la fiche Guichet-Emplois, qui référence l'offre originale. "127 "`robots.txt` respecté — **Crawl-delay: 5 s appliqué** "128 "(`request_delay = 5.0`), fenêtre bornée (pages + détails plafonnés). "129 "Aucune donnée personnelle de candidat collectée."),130 "jobillico": (131 "Parcours des **sitemaps publics** (`sitemap_job_postings_*.xml`) et "132 "lecture du **JSON-LD schema.org/JobPosting** que chaque page offre "133 "publie à destination des moteurs de recherche. Lien vers l'offre "134 "originale sur Jobillico. robots.txt respecté ; budgets de visites "135 "par sync. Aucune donnée personnelle de candidat collectée."),136 "espresso_jobs": (137 "Lecture du **JSON-LD schema.org** (ItemList + JobPosting) publié "138 "par les pages publiques d'Espresso-Jobs. Lien vers l'offre "139 "originale sur le portail. robots.txt respecté ; budgets de visites "140 "par sync. Aucune donnée personnelle de candidat collectée."),141}142CONFO_DEPOT = (143 "Offres **soumises volontairement par les employeurs** via le formulaire "144 "public `/employeurs` (`POST /api/employeurs/offres`). Stockées "145 "INACTIVES en attente de **modération manuelle** ; pot de miel "146 "anti-robots ; seul un courriel d'affaires de contact (fourni "147 "volontairement) est conservé dans `details.contact_email`. Aucune "148 "donnée personnelle de candidat.")149150HIST_MISSION = (151 "**Mission connecteurs du 2026-08-18** — passage de 135 à 217 employeurs "152 "directs connectés, de 9 à 17 plateformes ATS, ajout de 3 portails "153 "agrégateurs et du canal de dépôt direct :\n"154 "- `fd4bfc8` qualité lieux : rejet des villes hors Québec, bâtiments "155 "McGill → Montréal ;\n"156 "- `5a66f48` +59 employeurs (sondage massif d'ATS + détection de pages "157 "carrières) ;\n"158 "- `4917d57` 8 nouveaux ATS (Taleo, Njoyn, UltiPro, iCIMS, "159 "SuccessFactors, ADP WFN, Digital Recruiters, Workland) + 23 employeurs "160 "publics/parapublics ;\n"161 "- `f6e7571` portails Guichet-Emplois, Jobillico, Espresso-Jobs — "162 "l'offre directe gagne toujours la canonique (dédup) ;\n"163 "- `744cbfc` badge « Offre directe », stats directes/portails, dépôt "164 "direct employeurs ;\n"165 "- `1604ff9` reprise sur coupure WAF Guichet-Emplois (retry + acquis "166 "partiel).")167168169# ---------------------------------------------------------------------------170# Introspection code171# ---------------------------------------------------------------------------172173def introspect_module(path: Path) -> dict:174 """Docstring, endpoints http et variables d'env d'un module connecteur."""175 info = {"file": path.name, "doc": "", "endpoints": [], "env": [],176 "scrapfly": False}177 if not path.exists():178 return info179 text = path.read_text(encoding="utf-8")180 info["scrapfly"] = "scrapfly" in text.lower()181 try:182 tree = ast.parse(text)183 except SyntaxError:184 return info185 info["doc"] = ast.get_docstring(tree) or ""186 for node in tree.body:187 if isinstance(node, ast.Assign) and len(node.targets) == 1 and \188 isinstance(node.targets[0], ast.Name):189 val = node.value190 if isinstance(val, ast.Constant) and isinstance(val.value, str) \191 and val.value.startswith("http"):192 info["endpoints"].append((node.targets[0].id, val.value))193 env_re = re.compile(194 r'os\.environ\.get\(\s*"([A-Z_]+)"\s*(?:,\s*"([^"]*)")?\s*\)')195 for m in env_re.finditer(text):196 info["env"].append((m.group(1), m.group(2) or "—"))197 return info198199200def class_identifiers(cls) -> list[tuple[str, str]]:201 """Attributs de classe MAJUSCULES (TENANT, SITE, BOARD…) = identifiants202 du tenant chez l'ATS. Introspection générique, aucune liste à maintenir."""203 out = []204 for name in sorted(dir(cls)):205 if not name.isupper() or name.startswith("_"):206 continue207 try:208 v = getattr(cls, name)209 except Exception:210 continue211 if isinstance(v, (str, int, float)) and v != "" and \212 not callable(v) and len(str(v)) <= 120:213 out.append((name, str(v)))214 return out215216217def pagination_hint(text: str) -> str:218 low = text.lower()219 hints = []220 if "offset" in low:221 hints.append("offset/limit")222 if re.search(r"page_no|\?page=|page=\{|&page|pagenumber|/page/", low):223 hints.append("par numéro de page")224 if "sitemap" in low:225 hints.append("parcours de sitemap XML")226 if "cursor" in low or "nexturl" in low or "next_url" in low:227 hints.append("curseur / lien suivant")228 return ", ".join(dict.fromkeys(hints)) or "flux unique (une requête liste)"229230231# ---------------------------------------------------------------------------232# Base live (lecture seule)233# ---------------------------------------------------------------------------234235class Live:236 def __init__(self, con: sqlite3.Connection):237 self.c = con.cursor()238 self.total = self._group("SELECT source, COUNT(*) FROM jobs GROUP BY source")239 self.active = self._group(240 "SELECT source, COUNT(*) FROM jobs WHERE active=1 GROUP BY source")241 self.masked = self._group(242 "SELECT source, COUNT(*) FROM jobs WHERE active=1 AND dup_of IS "243 "NOT NULL GROUP BY source")244 self.fields: dict[str, dict[str, int]] = {}245 for col, _l, _d in SCHEMA_FIELDS:246 self.fields[col] = self._group(247 f"SELECT source, COUNT(*) FROM jobs WHERE active=1 AND "248 f"{FIELD_CONDS[col]} GROUP BY source")249 self.last_sync: dict[str, sqlite3.Row] = {}250 for r in self.c.execute(251 "SELECT * FROM sync_log WHERE id IN "252 "(SELECT MAX(id) FROM sync_log GROUP BY source)"):253 self.last_sync[r["source"]] = r254 self.nsync = self._group(255 "SELECT source, COUNT(*) FROM sync_log GROUP BY source")256 self.errors: dict[str, list] = {}257 for r in self.c.execute(258 "SELECT source, message, COUNT(*) n, MAX(ts) ts FROM sync_log "259 "WHERE ok=0 GROUP BY source, message ORDER BY MAX(ts) DESC"):260 self.errors.setdefault(r["source"], []).append(r)261262 def _group(self, sql: str) -> dict[str, int]:263 return {r[0]: r[1] for r in self.c.execute(sql)}264265 def example(self, sid: str) -> dict:266 r = self.c.execute(267 "SELECT title, city, salary_label, date_posted, employment_type "268 "FROM jobs WHERE source=? AND active=1 "269 "ORDER BY (salary_label != '') DESC, date_posted DESC LIMIT 1",270 (sid,)).fetchone()271 return dict(r) if r else {}272273274def fmt_ts(ts) -> str:275 if not ts:276 return "n/d"277 return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() \278 .strftime("%Y-%m-%d %H:%M")279280281def pct(n: int, d: int) -> str:282 return f"{100.0 * n / d:.0f} %" if d else "—"283284285def esc(v) -> str:286 if v is None or v == "":287 return "∅"288 s = str(v).replace("\n", " ").replace("|", "\\|").strip()289 return (s[:90] + "…") if len(s) > 90 else s290291292# ---------------------------------------------------------------------------293# Historique git — un seul appel, mappé fichier → commits294# ---------------------------------------------------------------------------295296def git_map() -> dict[str, list[str]]:297 out = subprocess.run(298 ["git", "log", "--date=short", "--format=@%h %ad %s", "--name-only",299 "--", "jobka/connectors"],300 cwd=ROOT, capture_output=True, text=True, timeout=60).stdout301 mapping: dict[str, list[str]] = {}302 cur = ""303 for line in out.splitlines():304 if line.startswith("@"):305 cur = line[1:]306 elif line.strip().startswith("jobka/connectors/"):307 fname = line.strip().rsplit("/", 1)[-1]308 lst = mapping.setdefault(fname, [])309 if len(lst) < 10:310 lst.append(cur)311 return mapping312313314# ---------------------------------------------------------------------------315# Sections communes316# ---------------------------------------------------------------------------317318def sec_champs(L: list[str], live: Live, sid: str) -> None:319 L.append("## Champs → schéma")320 L.append("")321 act = live.active.get(sid, 0)322 if not act:323 L.append("*Aucune offre active en base pour cette source.*")324 L.append("")325 return326 ex = live.example(sid)327 L.append(f"Complétude mesurée sur les **{act} offres actives** de la "328 f"source (base live, {STAMP}).")329 L.append("")330 L.append("| Champ (`jobs.*`) | Rôle | Complétude |")331 L.append("|---|---|---|")332 for col, label, desc in SCHEMA_FIELDS:333 n = live.fields[col].get(sid, 0)334 L.append(f"| `{col}` — {label} | {desc} | {pct(n, act)} |")335 L.append("")336 if ex:337 L.append(f"Exemple réel : *{esc(ex.get('title'))}* — "338 f"{esc(ex.get('city'))} · {esc(ex.get('salary_label'))} · "339 f"parue le {esc(ex.get('date_posted'))}.")340 L.append("")341342343def sec_volumetrie(L: list[str], live: Live, sid: str) -> None:344 L.append("## Volumétrie & complétude live")345 L.append("")346 L.append(f"*(mesuré le {STAMP} dans `data/jobka.db`)*")347 L.append("")348 act, tot = live.active.get(sid, 0), live.total.get(sid, 0)349 L.append(f"- **Offres en base** : {tot} (dont **{act} actives**, "350 f"{live.masked.get(sid, 0)} masquées comme doublons `dup_of`)")351 for col, label in KEY_METRICS:352 L.append(f"- **{label}** : {live.fields[col].get(sid, 0)}/{act} "353 f"actives ({pct(live.fields[col].get(sid, 0), act)})")354 ls = live.last_sync.get(sid)355 if ls is not None:356 L.append(f"- **Dernier sync** : {fmt_ts(ls['ts'])} — trouvées "357 f"{ls['found']}, +{ls['added']}, ~{ls['updated']}, "358 f"-{ls['removed']}" +359 ("" if ls["ok"] else f", **ÉCHEC : {esc(ls['message'])}**"))360 L.append(f"- **Syncs enregistrés** (`sync_log`) : {live.nsync.get(sid, 0)}")361 L.append("")362363364def sec_erreurs(L: list[str], live: Live, sid: str,365 extra: tuple | list = ()) -> None:366 L.append("## Erreurs & dépannage")367 L.append("")368 errs = live.errors.get(sid, [])369 if errs:370 L.append("Erreurs relevées dans `sync_log` :")371 L.append("")372 L.append("| Erreur | Occurrences | Dernière fois |")373 L.append("|---|---|---|")374 for e in errs[:5]:375 L.append(f"| {esc(e['message'])} | {e['n']} | {fmt_ts(e['ts'])} |")376 else:377 L.append("Aucune erreur dans `sync_log` pour cette source.")378 L.append("")379 for tip in extra:380 L.append(f"- {tip}")381 L.append(f"- Rejouer un sync isolé : `.venv/bin/python run.py sync {sid}` "382 f"puis inspecter `sync_log` : `sqlite3 data/jobka.db \"SELECT * "383 f"FROM sync_log WHERE source='{sid}' ORDER BY ts DESC LIMIT 5;\"`")384 L.append("- Garde-fous : une source qui retourne ≤ 25 % de sa médiane "385 "historique déclenche une alerte et les retraits sont suspendus ; "386 "une offre doit manquer 2 syncs consécutifs avant `active=0`.")387 L.append("")388389390def sec_frequence(L: list[str], env: list, delay) -> None:391 L.append("## Fréquence & budgets")392 L.append("")393 L.append("- **Cadence** : resynchronisation **horaire** (PM2 `job-ka-sync` "394 "= `run.py watch 60`), suivie du géocodage en lot puis de la "395 "déduplication inter-sources.")396 if delay is not None:397 L.append(f"- **Throttle** : {delay} s minimum entre deux requêtes "398 f"vers la source (`request_delay`).")399 if env:400 L.append("- **Budgets / plafonds (variables d'environnement)** :")401 L.append("")402 L.append("| Variable | Défaut |")403 L.append("|---|---|")404 for var, default in dict(env).items():405 L.append(f"| `{var}` | `{default}` |")406 L.append("- Cache BD des pages détail (`detail_cache`) : le détail d'une "407 "offre n'est re-téléchargé que si sa ligne de liste change.")408 L.append("")409410411def sec_historique(L: list[str], gmap: dict, fname: str | None) -> None:412 L.append("## Historique")413 L.append("")414 L.append(HIST_MISSION)415 if fname and gmap.get(fname):416 L.append("")417 L.append(f"Commits touchant `jobka/connectors/{fname}` :")418 L.append("")419 for h in gmap[fname]:420 L.append(f"- `{h.split(' ', 1)[0]}` {h.split(' ', 1)[1]}")421 L.append("")422423424# ---------------------------------------------------------------------------425# Fiches426# ---------------------------------------------------------------------------427428def fiche_employeur(src: dict, cls, ats_intro: dict, live: Live,429 gmap: dict) -> str:430 sid = src["id"]431 ats = src["connector"]432 fname = Path(sys.modules[cls.__module__].__file__).name if cls else None433 L: list[str] = [f"# {src['name']} (`{sid}`)", ""]434 L.append(f"**Employeur direct** · Plateforme ATS : "435 f"[`{ats}`](ats-{ats}.md) · Statut registre : "436 f"{src.get('status', 'n/d')}" +437 (f" · Connecteur : `jobka/connectors/{fname}`" if fname else ""))438 L.append("")439 L.append("## Description")440 L.append("")441 L.append(f"- **Site** : [{src['name']}]({src['url']})")442 L.append(f"- **Page carrières** : {src.get('careers_url', 'n/d')}")443 L.append(f"- **Secteurs** : {', '.join(src.get('sectors', [])) or 'n/d'}")444 L.append(f"- **Région** : {src.get('region', 'n/d')}")445 L.append("- Offres marquées **« Offre directe »** dans l'app (badge) : la "446 "candidature se fait chez l'employeur, jamais via un intermédiaire.")447 L.append("")448 L.append("## Accès")449 L.append("")450 L.append(f"- **Mécanique** : voir la fiche plateforme "451 f"[`ats-{ats}.md`](ats-{ats}.md) — API JSON publique du site "452 f"carrières, backend `requests` direct (Scrapfly seulement si "453 f"indiqué sur la fiche plateforme).")454 if cls is not None:455 ids = class_identifiers(cls)456 if ids:457 L.append("- **Identifiants du tenant** (attributs du connecteur) :")458 L.append("")459 L.append("| Attribut | Valeur |")460 L.append("|---|---|")461 for k, v in ids:462 L.append(f"| `{k}` | `{esc(v)}` |")463 delay = getattr(cls, "request_delay", None)464 if delay is not None:465 L.append(f"- **Throttle** : {delay} s entre requêtes.")466 L.append("- **Filtre Québec** : seuls les postes localisés au Québec sont "467 "conservés (facette serveur quand l'ATS en offre une, sinon "468 "`is_quebec_location` côté client).")469 L.append("")470 sec_champs(L, live, sid)471 sec_frequence(L, ats_intro.get("env", []),472 getattr(cls, "request_delay", None) if cls else None)473 sec_volumetrie(L, live, sid)474 sec_erreurs(L, live, sid)475 L.append("## Licence / conformité")476 L.append("")477 L.append(CONFO_ATS.format(employer=src["name"]))478 if ats_intro.get("scrapfly"):479 L.append("")480 L.append(CONFO_SCRAPFLY_NOTE)481 L.append("")482 sec_historique(L, gmap, fname)483 return md_header(f"{sid}.md",484 f"Fiche connecteur — {src['name']} (employeur direct, "485 f"ATS {ats})") + "\n".join(L)486487488def fiche_ats(ats: str, tenants: list[dict], live: Live, gmap: dict) -> str:489 path = CONNECTORS_DIR / f"{ats}.py"490 intro = introspect_module(path)491 text = path.read_text(encoding="utf-8") if path.exists() else ""492 n_act = sum(live.active.get(t["id"], 0) for t in tenants)493 L: list[str] = [f"# Plateforme ATS `{ats}`", ""]494 L.append(f"**{len(tenants)} employeurs connectés** via cette plateforme — "495 f"**{n_act} offres actives**. Module partagé : "496 f"`jobka/connectors/{ats}.py` ; un employeur = une sous-classe "497 f"d'environ 10 lignes.")498 L.append("")499 L.append("## Description & mécanique")500 L.append("")501 if intro["doc"]:502 for line in intro["doc"].splitlines():503 L.append(f"> {line}" if line.strip() else ">")504 else:505 L.append("*(module sans docstring)*")506 L.append("")507 L.append("## Accès")508 L.append("")509 if intro["endpoints"]:510 for name, url in intro["endpoints"]:511 L.append(f"- **Endpoint** (`{name}`) : `{url}`")512 L.append(f"- **Pagination** : {pagination_hint(text)}")513 backend = ("Scrapfly (anti-bot / rendu JS) via `BaseConnector.scrapfly()`"514 if "scrapfly" in text.lower() else515 "GET/POST direct `requests` (throttlé, User-Agent "516 "`JobKaBot/1.0` identifié)")517 L.append(f"- **Backend** : {backend}")518 L.append("- **Auth** : aucune — API/pages publiques du site carrières.")519 m = re.search(r"request_delay\s*=\s*([\d.]+)", text)520 if m:521 L.append(f"- **Throttle** : {m.group(1)} s entre requêtes.")522 if intro["env"]:523 L.append("- **Budgets (env)** : " +524 ", ".join(f"`{v}` (défaut `{d}`)"525 for v, d in dict(intro["env"]).items()))526 L.append("")527 L.append("## Tenants connectés")528 L.append("")529 L.append("| Employeur | Actives | Salaire | GPS | Mode | Dernier sync | OK |")530 L.append("|---|---|---|---|---|---|---|")531 for t in sorted(tenants, key=lambda t: -live.active.get(t["id"], 0)):532 sid = t["id"]533 act = live.active.get(sid, 0)534 ls = live.last_sync.get(sid)535 ok = ("✅" if ls is not None and ls["ok"] else536 ("❌" if ls is not None else "n/d"))537 cells = [pct(live.fields[c].get(sid, 0), act)538 for c in ("salary", "gps", "work_mode")]539 L.append(f"| [{t['name']}]({sid}.md) | {act} | " + " | ".join(cells) +540 f" | {fmt_ts(ls['ts']) if ls is not None else 'n/d'} | {ok} |")541 L.append("")542 L.append("## Licence / conformité")543 L.append("")544 L.append(CONFO_ATS.format(employer="l'employeur"))545 if intro["scrapfly"]:546 L.append("")547 L.append(CONFO_SCRAPFLY_NOTE)548 L.append("")549 sec_historique(L, gmap, f"{ats}.py")550 return md_header(f"ats-{ats}.md",551 f"Fiche plateforme ATS {ats} — mécanique partagée + "552 f"tableau des {len(tenants)} tenants") + "\n".join(L)553554555def fiche_portail(src: dict, cls, live: Live, gmap: dict) -> str:556 sid = src["id"]557 path = CONNECTORS_DIR / f"{sid}.py"558 intro = introspect_module(path)559 text = path.read_text(encoding="utf-8") if path.exists() else ""560 L: list[str] = [f"# {src['name']} (`{sid}`) — portail agrégateur", ""]561 L.append("**Portail agrégateur** (`dedup.AGGREGATORS`) : source "562 "complémentaire, moins autoritaire qu'une page carrière. En cas "563 "de doublon avec une offre directe, **l'offre directe gagne la "564 "canonique** et la copie portail est masquée (`dup_of`). Ces "565 "offres ne portent pas le badge « Offre directe ».")566 L.append("")567 L.append("## Description")568 L.append("")569 L.append(f"- **Portail** : [{src['name']}]({src['url']})")570 L.append(f"- **Secteurs** : {', '.join(src.get('sectors', [])) or 'généraliste'}")571 if intro["doc"]:572 L.append("")573 for line in intro["doc"].splitlines():574 L.append(f"> {line}" if line.strip() else ">")575 L.append("")576 L.append("## Accès")577 L.append("")578 for name, url in intro["endpoints"]:579 L.append(f"- **Endpoint** (`{name}`) : `{url}`")580 L.append(f"- **Pagination** : {pagination_hint(text)}")581 backend = ("Scrapfly (anti-bot / rendu JS)" if "scrapfly" in text.lower()582 else "GET direct `requests` (throttlé, UA `JobKaBot/1.0`)")583 L.append(f"- **Backend** : {backend}")584 delay = getattr(cls, "request_delay", None) if cls else None585 if delay is not None:586 L.append(f"- **Throttle** : {delay} s entre requêtes" +587 (" — **respect du `Crawl-delay: 5` de robots.txt**."588 if sid == "guichet_emplois" else "."))589 L.append("")590 sec_champs(L, live, sid)591 sec_frequence(L, intro["env"], delay)592 sec_volumetrie(L, live, sid)593 tips = []594 if sid == "guichet_emplois":595 tips.append("Le WAF gouvernemental coupe parfois la connexion en "596 "cours de pagination : le connecteur fait un retry puis "597 "conserve l'acquis partiel (commit `1604ff9`) — une "598 "erreur `RemoteDisconnected`/`Read timed out` ponctuelle "599 "est attendue et non bloquante.")600 if sid == "jobillico":601 tips.append("Un `410 Gone` sur une page offre est normal (offre "602 "retirée entre le sitemap et la visite) — non bloquant.")603 sec_erreurs(L, live, sid, tips)604 L.append("## Licence / conformité")605 L.append("")606 L.append(CONFO_PORTAIL[sid])607 L.append("")608 sec_historique(L, gmap, path.name)609 return md_header(f"{sid}.md",610 f"Fiche connecteur — portail agrégateur {src['name']}") \611 + "\n".join(L)612613614def fiche_depot(live: Live) -> str:615 sid = "depot-direct"616 L: list[str] = ["# Dépôt direct employeurs (`depot-direct`)", ""]617 L.append("**Canal de dépôt** : pas un connecteur de collecte — les "618 "employeurs déposent eux-mêmes leurs offres.")619 L.append("")620 L.append("## Description")621 L.append("")622 L.append("- Formulaire public `/employeurs` du site ; endpoint "623 "`POST /api/employeurs/offres` (`jobka/web.py`).")624 L.append("- L'offre est stockée **INACTIVE** (`active=0`, "625 "`details.moderation = en_attente`) jusqu'à modération manuelle.")626 L.append("- Validations : courriel de contact, URL `http(s)://`, type "627 "d'emploi et mode de travail dans les vocabulaires fermés.")628 L.append("- **Pot de miel anti-robots** : champ caché `website` — s'il "629 "est rempli, l'offre est silencieusement ignorée.")630 L.append("")631 L.append("## Accès")632 L.append("")633 L.append("- **Endpoint** : `POST /api/employeurs/offres` (JSON).")634 L.append("- **Backend** : aucun fetch sortant — données poussées par "635 "l'employeur.")636 L.append("")637 sec_champs(L, live, sid)638 L.append("## Fréquence & budgets")639 L.append("")640 L.append("- Au fil de l'eau (soumissions) ; publication après modération "641 "manuelle.")642 L.append("")643 sec_volumetrie(L, live, sid)644 tot = live.total.get(sid, 0)645 act = live.active.get(sid, 0)646 L.append(f"Soumissions en attente de modération : **{tot - act}**.")647 L.append("")648 L.append("## Erreurs & dépannage")649 L.append("")650 L.append("- `422` sur le formulaire = validation (courriel, URL, "651 "vocabulaires). Les soumissions piégées par le pot de miel ne "652 "sont pas stockées.")653 L.append("- Lister les offres en attente : `sqlite3 data/jobka.db "654 "\"SELECT uid, employer, title FROM jobs WHERE "655 "source='depot-direct' AND active=0;\"`")656 L.append("")657 L.append("## Licence / conformité")658 L.append("")659 L.append(CONFO_DEPOT)660 L.append("")661 L.append("## Historique")662 L.append("")663 L.append(HIST_MISSION)664 L.append("")665 return md_header("depot-direct.md",666 "Fiche du canal de dépôt direct employeurs") + "\n".join(L)667668669# ---------------------------------------------------------------------------670# INDEX671# ---------------------------------------------------------------------------672673def render_index(sources: list[dict], by_ats: dict, live: Live,674 n_fiches: int) -> str:675 employers = [s for s in sources676 if s["connector"] not in ("portail", "depot-direct")]677 portals = [s for s in sources if s["connector"] == "portail"]678 act_all = sum(live.active.values())679 masked_all = sum(live.masked.values())680 act_dir = sum(live.active.get(s["id"], 0) for s in employers)681 act_por = sum(live.active.get(s["id"], 0) for s in portals)682 L: list[str] = ["# Job·Ka — Connecteurs (index)", ""]683 L.append(f"> Index généré le {STAMP} par `scripts/gen_connector_docs.py` "684 f"(rejouable). Croise `data/sources.json`, l'introspection de "685 f"`jobka/connectors/*.py` et la base live `data/jobka.db`.")686 L.append("")687 L.append(f"**{len(employers)} employeurs directs** connectés via "688 f"**{len(by_ats)} plateformes ATS** + **{len(portals)} portails "689 f"agrégateurs** + le canal de **dépôt direct** — "690 f"**{act_all} offres actives** ({act_dir} directes, {act_por} "691 f"portails ; {masked_all} masquées comme doublons inter-sources, "692 f"soit {act_all - masked_all} visibles). {n_fiches} fiches.")693 L.append("")694 L.append("Conformité : voir [`docs/CONFORMITE.md`](../CONFORMITE.md). "695 "Chaque offre conserve son URL originale ; la candidature se "696 "fait chez l'employeur (badge « Offre directe » sur les sources "697 "directes).")698 L.append("")699 L.append("## Plateformes ATS")700 L.append("")701 L.append("| Plateforme | Employeurs | Actives | Salaire | GPS |")702 L.append("|---|---|---|---|---|")703 for ats in sorted(by_ats, key=lambda a: -sum(704 live.active.get(t["id"], 0) for t in by_ats[a])):705 ts = by_ats[ats]706 act = sum(live.active.get(t["id"], 0) for t in ts)707 sal = sum(live.fields["salary"].get(t["id"], 0) for t in ts)708 gps = sum(live.fields["gps"].get(t["id"], 0) for t in ts)709 L.append(f"| [`{ats}`](ats-{ats}.md) | {len(ts)} | {act} | "710 f"{pct(sal, act)} | {pct(gps, act)} |")711 L.append("")712 L.append("## Portails agrégateurs & dépôt")713 L.append("")714 L.append("| Source | Actives | Salaire | Dernier sync |")715 L.append("|---|---|---|---|")716 for s in portals:717 sid = s["id"]718 act = live.active.get(sid, 0)719 ls = live.last_sync.get(sid)720 L.append(f"| [{s['name']}]({sid}.md) | {act} | "721 f"{pct(live.fields['salary'].get(sid, 0), act)} | "722 f"{fmt_ts(ls['ts']) if ls is not None else 'n/d'} |")723 dd_tot = live.total.get("depot-direct", 0)724 dd_act = live.active.get("depot-direct", 0)725 L.append(f"| [Dépôt direct employeurs](depot-direct.md) | {dd_act} "726 f"({dd_tot - dd_act} en modération) | — | au fil de l'eau |")727 L.append("")728 L.append("## Employeurs directs (par volume d'offres actives)")729 L.append("")730 L.append("| Employeur | ATS | Actives | Salaire | GPS | Dernier sync OK |")731 L.append("|---|---|---|---|---|---|")732 for s in sorted(employers, key=lambda s: (-live.active.get(s["id"], 0),733 s["name"].lower())):734 sid = s["id"]735 act = live.active.get(sid, 0)736 ls = live.last_sync.get(sid)737 ok = ("✅" if ls is not None and ls["ok"] else738 ("❌" if ls is not None else "n/d"))739 L.append(f"| [{s['name']}]({sid}.md) | [`{s['connector']}`]"740 f"(ats-{s['connector']}.md) | {act} | "741 f"{pct(live.fields['salary'].get(sid, 0), act)} | "742 f"{pct(live.fields['gps'].get(sid, 0), act)} | {ok} |")743 L.append("")744 L.append("## Régénérer cette documentation")745 L.append("")746 L.append("```bash")747 L.append("cd ~/apps/job-ka && .venv/bin/python scripts/gen_connector_docs.py")748 L.append("```")749 L.append("")750 return md_header("INDEX.md", "Index généré de la documentation des "751 "connecteurs (ATS, employeurs, portails, dépôt)") \752 + "\n".join(L)753754755# ---------------------------------------------------------------------------756757def main() -> int:758 sources = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"]759 from jobka.connectors import CONNECTORS760 con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)761 con.row_factory = sqlite3.Row762 live = Live(con)763 gmap = git_map()764 OUT_DIR.mkdir(parents=True, exist_ok=True)765 for old in OUT_DIR.glob("*.md"):766 old.unlink()767768 by_ats: dict[str, list[dict]] = {}769 n = 0770 for src in sources:771 conn = src["connector"]772 if conn == "depot-direct":773 (OUT_DIR / "depot-direct.md").write_text(774 fiche_depot(live), encoding="utf-8")775 n += 1776 elif conn == "portail":777 cls = CONNECTORS.get(src["id"])778 (OUT_DIR / f"{src['id']}.md").write_text(779 fiche_portail(src, cls, live, gmap), encoding="utf-8")780 n += 1781 else:782 by_ats.setdefault(conn, []).append(src)783784 for ats, tenants in by_ats.items():785 intro = introspect_module(CONNECTORS_DIR / f"{ats}.py")786 for t in tenants:787 cls = CONNECTORS.get(t["id"])788 (OUT_DIR / f"{t['id']}.md").write_text(789 fiche_employeur(t, cls, intro, live, gmap), encoding="utf-8")790 n += 1791 (OUT_DIR / f"ats-{ats}.md").write_text(792 fiche_ats(ats, tenants, live, gmap), encoding="utf-8")793 n += 1794795 (OUT_DIR / "INDEX.md").write_text(796 render_index(sources, by_ats, live, n + 1), encoding="utf-8")797 n += 1798 print(f"[gen_connector_docs] {n} fichiers générés dans docs/connecteurs/ "799 f"({len(by_ats)} plateformes ATS, "800 f"{sum(len(v) for v in by_ats.values())} employeurs, "801 f"{sum(1 for s in sources if s['connector'] == 'portail')} portails, "802 f"1 dépôt direct).")803 con.close()804 return 0805806807if __name__ == "__main__":808 sys.exit(main())809