SPB Git

spb/job-ka

Public
35.2 KB · 806 lines python
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
Files · b05bce417392318c4c21693c5e60d173972b94a2
Menu

Theme