SPB Git forge

spb/job-ka

Public
226commits 1branches 0releases
37.5 MBsize
maindefault branch
9 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
35.5 KB · 809 lines python
Raw Blame History
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