#!/usr/bin/env python3 # ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : scripts/gen_connector_docs.py # Rôle : Générateur REJOUABLE de la documentation standardisée des # connecteurs (standard Groupe-KA, cf. lou-ka / sorti-ka) → # docs/connecteurs/INDEX.md + une fiche par plateforme ATS, par # employeur, par portail et pour le dépôt direct # Créé : 2026-08-18 Modifié : 2026-08-18 # ============================================================================= """Générateur de la documentation des connecteurs Job·Ka. Croise QUATRE vérités, sans rien inventer : 1. le registre data/sources.json (nom, URL carrières, secteurs, région) ; 2. l'introspection du code jobka/connectors/*.py — ast (endpoints, variables d'environnement) + registre runtime CONNECTORS (identifiants de tenant, throttle, backend) ; 3. la base LIVE data/jobka.db, ouverte en LECTURE SEULE (volumétrie, complétude par champ, dernier sync, erreurs sync_log) ; 4. l'historique git des fichiers connecteurs. Usage : .venv/bin/python scripts/gen_connector_docs.py (stdlib seulement côté script ; l'import du paquet jobka nécessite le venv) """ from __future__ import annotations import ast import json import re import sqlite3 import subprocess import sys from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) SOURCES_JSON = ROOT / "data" / "sources.json" DB_PATH = ROOT / "data" / "jobka.db" CONNECTORS_DIR = ROOT / "jobka" / "connectors" OUT_DIR = ROOT / "docs" / "connecteurs" NOW = datetime.now().astimezone() STAMP = NOW.strftime("%Y-%m-%d %H:%M %Z") # En-tête d'auteur (règle nº 1) apposé sur chaque fiche générée. def md_header(fname: str, role: str) -> str: return ( "\n\n") # Champs documentés dans « Champs → schéma » (colonne SQL, libellé, rôle). SCHEMA_FIELDS = [ ("title", "Titre", "titre du poste"), ("employer", "Employeur", "nom de l'entreprise"), ("url", "URL", "lien direct vers l'offre originale"), ("description", "Description", "texte complet (HTML nettoyé)"), ("city", "Ville", "municipalité normalisée"), ("region", "Région", "région administrative"), ("address", "Adresse", "adresse du lieu de travail"), ("postal_code", "Code postal", "code postal si publié"), ("work_mode", "Mode", "presentiel / hybride / teletravail"), ("employment_type", "Type", "temps_plein / temps_partiel / …"), ("salary", "Salaire ($)", "bornes numériques normalisées $/h et $/an"), ("salary_label", "Libellé salaire", "texte salarial original"), ("benefits", "Avantages", "avantages sociaux publiés (JSON)"), ("requirements", "Exigences", "scolarité, expérience, langues (JSON)"), ("date_posted", "Date de parution", "ISO 8601"), ("date_deadline", "Date limite", "date limite pour postuler"), ("category", "Catégorie", "taxonomie interne (TI, Santé, …)"), ("language", "Langue", "fr / en / bilingue (source ou heuristique)"), ("company_logo", "Logo employeur", "URL du logo si exposé par la source"), ("apply_url", "Candidature", "lien de candidature directe"), ("gps", "GPS", "lat/lng (géocodage sans invention)"), ] FIELD_CONDS = { "salary": "(salary_year_min IS NOT NULL OR salary_year_max IS NOT NULL)", "gps": "(lat IS NOT NULL AND lng IS NOT NULL)", "description": "(description IS NOT NULL AND LENGTH(description) > 50)", "benefits": "(benefits IS NOT NULL AND benefits NOT IN ('', '[]'))", "requirements": "(requirements IS NOT NULL AND requirements NOT IN ('', '{}'))", "work_mode": "(work_mode IS NOT NULL AND work_mode != '')", "employment_type": "(employment_type IS NOT NULL AND employment_type != '')", } for _col in ("title", "employer", "url", "city", "region", "address", "postal_code", "salary_label", "date_posted", "date_deadline", "category", "language", "company_logo", "apply_url"): FIELD_CONDS[_col] = f"({_col} IS NOT NULL AND {_col} != '')" # Complétude « clé » (INDEX + tableaux de tenants). KEY_METRICS = [("salary", "Salaire"), ("gps", "GPS"), ("work_mode", "Mode"), ("date_posted", "Parution")] # Conformité — texte par famille (repris dans docs/CONFORMITE.md). CONFO_ATS = ( "**Flux public du site carrières officiel de l'employeur** (API JSON de " "l'ATS ou HTML rendu serveur) — le même contenu que la page carrière " "affiche publiquement, prévu pour la diffusion des offres, sans " "authentification. Chaque offre conserve son URL originale et le bouton " "« Postuler chez {employer} » renvoie chez l'employeur. Aucune donnée " "personnelle de candidat n'est collectée. User-Agent identifié " "`JobKaBot/1.0 (+https://www.job-ka.com; contact@spboucher.ai)` sur les " "accès directs ; requêtes throttlées et plafonnées par budgets.") CONFO_SCRAPFLY_NOTE = ( "Certains domaines de cette plateforme sont servis derrière un " "gestionnaire anti-bot : la lecture passe alors par Scrapfly, toujours " "sur des pages strictement publiques, au même rythme throttlé.") CONFO_PORTAIL = { "guichet_emplois": ( "Scraping de pages **publiques** (gouvernement du Canada) avec lien " "vers la fiche Guichet-Emplois, qui référence l'offre originale. " "`robots.txt` respecté — **Crawl-delay: 5 s appliqué** " "(`request_delay = 5.0`), fenêtre bornée (pages + détails plafonnés). " "Aucune donnée personnelle de candidat collectée."), "jobillico": ( "Parcours des **sitemaps publics** (`sitemap_job_postings_*.xml`) et " "lecture du **JSON-LD schema.org/JobPosting** que chaque page offre " "publie à destination des moteurs de recherche. Lien vers l'offre " "originale sur Jobillico. robots.txt respecté ; budgets de visites " "par sync. Aucune donnée personnelle de candidat collectée."), "espresso_jobs": ( "Lecture du **JSON-LD schema.org** (ItemList + JobPosting) publié " "par les pages publiques d'Espresso-Jobs. Lien vers l'offre " "originale sur le portail. robots.txt respecté ; budgets de visites " "par sync. Aucune donnée personnelle de candidat collectée."), } CONFO_DEPOT = ( "Offres **soumises volontairement par les employeurs** via le formulaire " "public `/employeurs` (`POST /api/employeurs/offres`). Stockées " "INACTIVES en attente de **modération manuelle** ; pot de miel " "anti-robots ; seul un courriel d'affaires de contact (fourni " "volontairement) est conservé dans `details.contact_email`. Aucune " "donnée personnelle de candidat.") HIST_MISSION = ( "**Mission connecteurs du 2026-08-18** — passage de 135 à 217 employeurs " "directs connectés, de 9 à 17 plateformes ATS, ajout de 3 portails " "agrégateurs et du canal de dépôt direct :\n" "- `fd4bfc8` qualité lieux : rejet des villes hors Québec, bâtiments " "McGill → Montréal ;\n" "- `5a66f48` +59 employeurs (sondage massif d'ATS + détection de pages " "carrières) ;\n" "- `4917d57` 8 nouveaux ATS (Taleo, Njoyn, UltiPro, iCIMS, " "SuccessFactors, ADP WFN, Digital Recruiters, Workland) + 23 employeurs " "publics/parapublics ;\n" "- `f6e7571` portails Guichet-Emplois, Jobillico, Espresso-Jobs — " "l'offre directe gagne toujours la canonique (dédup) ;\n" "- `744cbfc` badge « Offre directe », stats directes/portails, dépôt " "direct employeurs ;\n" "- `1604ff9` reprise sur coupure WAF Guichet-Emplois (retry + acquis " "partiel).") # --------------------------------------------------------------------------- # Introspection code # --------------------------------------------------------------------------- def introspect_module(path: Path) -> dict: """Docstring, endpoints http et variables d'env d'un module connecteur.""" info = {"file": path.name, "doc": "", "endpoints": [], "env": [], "scrapfly": False} if not path.exists(): return info text = path.read_text(encoding="utf-8") info["scrapfly"] = "scrapfly" in text.lower() try: tree = ast.parse(text) except SyntaxError: return info info["doc"] = ast.get_docstring(tree) or "" for node in tree.body: if isinstance(node, ast.Assign) and len(node.targets) == 1 and \ isinstance(node.targets[0], ast.Name): val = node.value if isinstance(val, ast.Constant) and isinstance(val.value, str) \ and val.value.startswith("http"): info["endpoints"].append((node.targets[0].id, val.value)) env_re = re.compile( r'os\.environ\.get\(\s*"([A-Z_]+)"\s*(?:,\s*"([^"]*)")?\s*\)') for m in env_re.finditer(text): info["env"].append((m.group(1), m.group(2) or "—")) return info def class_identifiers(cls) -> list[tuple[str, str]]: """Attributs de classe MAJUSCULES (TENANT, SITE, BOARD…) = identifiants du tenant chez l'ATS. Introspection générique, aucune liste à maintenir.""" out = [] for name in sorted(dir(cls)): if not name.isupper() or name.startswith("_"): continue try: v = getattr(cls, name) except Exception: continue if isinstance(v, (str, int, float)) and v != "" and \ not callable(v) and len(str(v)) <= 120: out.append((name, str(v))) return out def pagination_hint(text: str) -> str: low = text.lower() hints = [] if "offset" in low: hints.append("offset/limit") if re.search(r"page_no|\?page=|page=\{|&page|pagenumber|/page/", low): hints.append("par numéro de page") if "sitemap" in low: hints.append("parcours de sitemap XML") if "cursor" in low or "nexturl" in low or "next_url" in low: hints.append("curseur / lien suivant") return ", ".join(dict.fromkeys(hints)) or "flux unique (une requête liste)" # --------------------------------------------------------------------------- # Base live (lecture seule) # --------------------------------------------------------------------------- class Live: def __init__(self, con: sqlite3.Connection): self.c = con.cursor() self.total = self._group("SELECT source, COUNT(*) FROM jobs GROUP BY source") self.active = self._group( "SELECT source, COUNT(*) FROM jobs WHERE active=1 GROUP BY source") self.masked = self._group( "SELECT source, COUNT(*) FROM jobs WHERE active=1 AND dup_of IS " "NOT NULL GROUP BY source") self.fields: dict[str, dict[str, int]] = {} for col, _l, _d in SCHEMA_FIELDS: self.fields[col] = self._group( f"SELECT source, COUNT(*) FROM jobs WHERE active=1 AND " f"{FIELD_CONDS[col]} GROUP BY source") self.last_sync: dict[str, sqlite3.Row] = {} for r in self.c.execute( "SELECT * FROM sync_log WHERE id IN " "(SELECT MAX(id) FROM sync_log GROUP BY source)"): self.last_sync[r["source"]] = r self.nsync = self._group( "SELECT source, COUNT(*) FROM sync_log GROUP BY source") self.errors: dict[str, list] = {} for r in self.c.execute( "SELECT source, message, COUNT(*) n, MAX(ts) ts FROM sync_log " "WHERE ok=0 GROUP BY source, message ORDER BY MAX(ts) DESC"): self.errors.setdefault(r["source"], []).append(r) def _group(self, sql: str) -> dict[str, int]: return {r[0]: r[1] for r in self.c.execute(sql)} def example(self, sid: str) -> dict: r = self.c.execute( "SELECT title, city, salary_label, date_posted, employment_type " "FROM jobs WHERE source=? AND active=1 " "ORDER BY (salary_label != '') DESC, date_posted DESC LIMIT 1", (sid,)).fetchone() return dict(r) if r else {} def fmt_ts(ts) -> str: if not ts: return "n/d" return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() \ .strftime("%Y-%m-%d %H:%M") def pct(n: int, d: int) -> str: return f"{100.0 * n / d:.0f} %" if d else "—" def esc(v) -> str: if v is None or v == "": return "∅" s = str(v).replace("\n", " ").replace("|", "\\|").strip() return (s[:90] + "…") if len(s) > 90 else s # --------------------------------------------------------------------------- # Historique git — un seul appel, mappé fichier → commits # --------------------------------------------------------------------------- def git_map() -> dict[str, list[str]]: out = subprocess.run( ["git", "log", "--date=short", "--format=@%h %ad %s", "--name-only", "--", "jobka/connectors"], cwd=ROOT, capture_output=True, text=True, timeout=60).stdout mapping: dict[str, list[str]] = {} cur = "" for line in out.splitlines(): if line.startswith("@"): cur = line[1:] elif line.strip().startswith("jobka/connectors/"): fname = line.strip().rsplit("/", 1)[-1] lst = mapping.setdefault(fname, []) if len(lst) < 10: lst.append(cur) return mapping # --------------------------------------------------------------------------- # Sections communes # --------------------------------------------------------------------------- def sec_champs(L: list[str], live: Live, sid: str) -> None: L.append("## Champs → schéma") L.append("") act = live.active.get(sid, 0) if not act: L.append("*Aucune offre active en base pour cette source.*") L.append("") return ex = live.example(sid) L.append(f"Complétude mesurée sur les **{act} offres actives** de la " f"source (base live, {STAMP}).") L.append("") L.append("| Champ (`jobs.*`) | Rôle | Complétude |") L.append("|---|---|---|") for col, label, desc in SCHEMA_FIELDS: n = live.fields[col].get(sid, 0) L.append(f"| `{col}` — {label} | {desc} | {pct(n, act)} |") L.append("") if ex: L.append(f"Exemple réel : *{esc(ex.get('title'))}* — " f"{esc(ex.get('city'))} · {esc(ex.get('salary_label'))} · " f"parue le {esc(ex.get('date_posted'))}.") L.append("") def sec_volumetrie(L: list[str], live: Live, sid: str) -> None: L.append("## Volumétrie & complétude live") L.append("") L.append(f"*(mesuré le {STAMP} dans `data/jobka.db`)*") L.append("") act, tot = live.active.get(sid, 0), live.total.get(sid, 0) L.append(f"- **Offres en base** : {tot} (dont **{act} actives**, " f"{live.masked.get(sid, 0)} masquées comme doublons `dup_of`)") for col, label in KEY_METRICS: L.append(f"- **{label}** : {live.fields[col].get(sid, 0)}/{act} " f"actives ({pct(live.fields[col].get(sid, 0), act)})") ls = live.last_sync.get(sid) if ls is not None: L.append(f"- **Dernier sync** : {fmt_ts(ls['ts'])} — trouvées " f"{ls['found']}, +{ls['added']}, ~{ls['updated']}, " f"-{ls['removed']}" + ("" if ls["ok"] else f", **ÉCHEC : {esc(ls['message'])}**")) L.append(f"- **Syncs enregistrés** (`sync_log`) : {live.nsync.get(sid, 0)}") L.append("") def sec_erreurs(L: list[str], live: Live, sid: str, extra: tuple | list = ()) -> None: L.append("## Erreurs & dépannage") L.append("") errs = live.errors.get(sid, []) if errs: L.append("Erreurs relevées dans `sync_log` :") L.append("") L.append("| Erreur | Occurrences | Dernière fois |") L.append("|---|---|---|") for e in errs[:5]: L.append(f"| {esc(e['message'])} | {e['n']} | {fmt_ts(e['ts'])} |") else: L.append("Aucune erreur dans `sync_log` pour cette source.") L.append("") for tip in extra: L.append(f"- {tip}") L.append(f"- Rejouer un sync isolé : `.venv/bin/python run.py sync {sid}` " f"puis inspecter `sync_log` : `sqlite3 data/jobka.db \"SELECT * " f"FROM sync_log WHERE source='{sid}' ORDER BY ts DESC LIMIT 5;\"`") L.append("- Garde-fous : une source qui retourne ≤ 25 % de sa médiane " "historique déclenche une alerte et les retraits sont suspendus ; " "une offre doit manquer 2 syncs consécutifs avant `active=0`.") L.append("") def sec_frequence(L: list[str], env: list, delay) -> None: L.append("## Fréquence & budgets") L.append("") L.append("- **Cadence** : resynchronisation **horaire** (PM2 `job-ka-sync` " "= `run.py watch 60`), suivie du géocodage en lot puis de la " "déduplication inter-sources.") if delay is not None: L.append(f"- **Throttle** : {delay} s minimum entre deux requêtes " f"vers la source (`request_delay`).") if env: L.append("- **Budgets / plafonds (variables d'environnement)** :") L.append("") L.append("| Variable | Défaut |") L.append("|---|---|") for var, default in dict(env).items(): L.append(f"| `{var}` | `{default}` |") L.append("- Cache BD des pages détail (`detail_cache`) : le détail d'une " "offre n'est re-téléchargé que si sa ligne de liste change.") L.append("") def sec_historique(L: list[str], gmap: dict, fname: str | None) -> None: L.append("## Historique") L.append("") L.append(HIST_MISSION) if fname and gmap.get(fname): L.append("") L.append(f"Commits touchant `jobka/connectors/{fname}` :") L.append("") for h in gmap[fname]: L.append(f"- `{h.split(' ', 1)[0]}` {h.split(' ', 1)[1]}") L.append("") # --------------------------------------------------------------------------- # Fiches # --------------------------------------------------------------------------- def fiche_employeur(src: dict, cls, ats_intro: dict, live: Live, gmap: dict) -> str: sid = src["id"] ats = src["connector"] fname = Path(sys.modules[cls.__module__].__file__).name if cls else None L: list[str] = [f"# {src['name']} (`{sid}`)", ""] L.append(f"**Employeur direct** · Plateforme ATS : " f"[`{ats}`](ats-{ats}.md) · Statut registre : " f"{src.get('status', 'n/d')}" + (f" · Connecteur : `jobka/connectors/{fname}`" if fname else "")) L.append("") L.append("## Description") L.append("") L.append(f"- **Site** : [{src['name']}]({src['url']})") L.append(f"- **Page carrières** : {src.get('careers_url', 'n/d')}") L.append(f"- **Secteurs** : {', '.join(src.get('sectors', [])) or 'n/d'}") L.append(f"- **Région** : {src.get('region', 'n/d')}") L.append("- Offres marquées **« Offre directe »** dans l'app (badge) : la " "candidature se fait chez l'employeur, jamais via un intermédiaire.") L.append("") L.append("## Accès") L.append("") L.append(f"- **Mécanique** : voir la fiche plateforme " f"[`ats-{ats}.md`](ats-{ats}.md) — API JSON publique du site " f"carrières, backend `requests` direct (Scrapfly seulement si " f"indiqué sur la fiche plateforme).") if cls is not None: ids = class_identifiers(cls) if ids: L.append("- **Identifiants du tenant** (attributs du connecteur) :") L.append("") L.append("| Attribut | Valeur |") L.append("|---|---|") for k, v in ids: L.append(f"| `{k}` | `{esc(v)}` |") delay = getattr(cls, "request_delay", None) if delay is not None: L.append(f"- **Throttle** : {delay} s entre requêtes.") L.append("- **Filtre Québec** : seuls les postes localisés au Québec sont " "conservés (facette serveur quand l'ATS en offre une, sinon " "`is_quebec_location` côté client).") L.append("") sec_champs(L, live, sid) sec_frequence(L, ats_intro.get("env", []), getattr(cls, "request_delay", None) if cls else None) sec_volumetrie(L, live, sid) sec_erreurs(L, live, sid) L.append("## Licence / conformité") L.append("") L.append(CONFO_ATS.format(employer=src["name"])) if ats_intro.get("scrapfly"): L.append("") L.append(CONFO_SCRAPFLY_NOTE) L.append("") sec_historique(L, gmap, fname) return md_header(f"{sid}.md", f"Fiche connecteur — {src['name']} (employeur direct, " f"ATS {ats})") + "\n".join(L) def fiche_ats(ats: str, tenants: list[dict], live: Live, gmap: dict) -> str: path = CONNECTORS_DIR / f"{ats}.py" intro = introspect_module(path) text = path.read_text(encoding="utf-8") if path.exists() else "" n_act = sum(live.active.get(t["id"], 0) for t in tenants) L: list[str] = [f"# Plateforme ATS `{ats}`", ""] L.append(f"**{len(tenants)} employeurs connectés** via cette plateforme — " f"**{n_act} offres actives**. Module partagé : " f"`jobka/connectors/{ats}.py` ; un employeur = une sous-classe " f"d'environ 10 lignes.") L.append("") L.append("## Description & mécanique") L.append("") if intro["doc"]: for line in intro["doc"].splitlines(): L.append(f"> {line}" if line.strip() else ">") else: L.append("*(module sans docstring)*") L.append("") L.append("## Accès") L.append("") if intro["endpoints"]: for name, url in intro["endpoints"]: L.append(f"- **Endpoint** (`{name}`) : `{url}`") L.append(f"- **Pagination** : {pagination_hint(text)}") backend = ("Scrapfly (anti-bot / rendu JS) via `BaseConnector.scrapfly()`" if "scrapfly" in text.lower() else "GET/POST direct `requests` (throttlé, User-Agent " "`JobKaBot/1.0` identifié)") L.append(f"- **Backend** : {backend}") L.append("- **Auth** : aucune — API/pages publiques du site carrières.") m = re.search(r"request_delay\s*=\s*([\d.]+)", text) if m: L.append(f"- **Throttle** : {m.group(1)} s entre requêtes.") if intro["env"]: L.append("- **Budgets (env)** : " + ", ".join(f"`{v}` (défaut `{d}`)" for v, d in dict(intro["env"]).items())) L.append("") L.append("## Tenants connectés") L.append("") L.append("| Employeur | Actives | Salaire | GPS | Mode | Dernier sync | OK |") L.append("|---|---|---|---|---|---|---|") for t in sorted(tenants, key=lambda t: -live.active.get(t["id"], 0)): sid = t["id"] act = live.active.get(sid, 0) ls = live.last_sync.get(sid) ok = ("✅" if ls is not None and ls["ok"] else ("❌" if ls is not None else "n/d")) cells = [pct(live.fields[c].get(sid, 0), act) for c in ("salary", "gps", "work_mode")] L.append(f"| [{t['name']}]({sid}.md) | {act} | " + " | ".join(cells) + f" | {fmt_ts(ls['ts']) if ls is not None else 'n/d'} | {ok} |") L.append("") L.append("## Licence / conformité") L.append("") L.append(CONFO_ATS.format(employer="l'employeur")) if intro["scrapfly"]: L.append("") L.append(CONFO_SCRAPFLY_NOTE) L.append("") sec_historique(L, gmap, f"{ats}.py") return md_header(f"ats-{ats}.md", f"Fiche plateforme ATS {ats} — mécanique partagée + " f"tableau des {len(tenants)} tenants") + "\n".join(L) def fiche_portail(src: dict, cls, live: Live, gmap: dict) -> str: sid = src["id"] path = CONNECTORS_DIR / f"{sid}.py" intro = introspect_module(path) text = path.read_text(encoding="utf-8") if path.exists() else "" L: list[str] = [f"# {src['name']} (`{sid}`) — portail agrégateur", ""] L.append("**Portail agrégateur** (`dedup.AGGREGATORS`) : source " "complémentaire, moins autoritaire qu'une page carrière. En cas " "de doublon avec une offre directe, **l'offre directe gagne la " "canonique** et la copie portail est masquée (`dup_of`). Ces " "offres ne portent pas le badge « Offre directe ».") L.append("") L.append("## Description") L.append("") L.append(f"- **Portail** : [{src['name']}]({src['url']})") L.append(f"- **Secteurs** : {', '.join(src.get('sectors', [])) or 'généraliste'}") if intro["doc"]: L.append("") for line in intro["doc"].splitlines(): L.append(f"> {line}" if line.strip() else ">") L.append("") L.append("## Accès") L.append("") for name, url in intro["endpoints"]: L.append(f"- **Endpoint** (`{name}`) : `{url}`") L.append(f"- **Pagination** : {pagination_hint(text)}") backend = ("Scrapfly (anti-bot / rendu JS)" if "scrapfly" in text.lower() else "GET direct `requests` (throttlé, UA `JobKaBot/1.0`)") L.append(f"- **Backend** : {backend}") delay = getattr(cls, "request_delay", None) if cls else None if delay is not None: L.append(f"- **Throttle** : {delay} s entre requêtes" + (" — **respect du `Crawl-delay: 5` de robots.txt**." if sid == "guichet_emplois" else ".")) L.append("") sec_champs(L, live, sid) sec_frequence(L, intro["env"], delay) sec_volumetrie(L, live, sid) tips = [] if sid == "guichet_emplois": tips.append("Le WAF gouvernemental coupe parfois la connexion en " "cours de pagination : le connecteur fait un retry puis " "conserve l'acquis partiel (commit `1604ff9`) — une " "erreur `RemoteDisconnected`/`Read timed out` ponctuelle " "est attendue et non bloquante.") if sid == "jobillico": tips.append("Un `410 Gone` sur une page offre est normal (offre " "retirée entre le sitemap et la visite) — non bloquant.") sec_erreurs(L, live, sid, tips) L.append("## Licence / conformité") L.append("") L.append(CONFO_PORTAIL[sid]) L.append("") sec_historique(L, gmap, path.name) return md_header(f"{sid}.md", f"Fiche connecteur — portail agrégateur {src['name']}") \ + "\n".join(L) def fiche_depot(live: Live) -> str: sid = "depot-direct" L: list[str] = ["# Dépôt direct employeurs (`depot-direct`)", ""] L.append("**Canal de dépôt** : pas un connecteur de collecte — les " "employeurs déposent eux-mêmes leurs offres.") L.append("") L.append("## Description") L.append("") L.append("- Formulaire public `/employeurs` du site ; endpoint " "`POST /api/employeurs/offres` (`jobka/web.py`).") L.append("- L'offre est stockée **INACTIVE** (`active=0`, " "`details.moderation = en_attente`) jusqu'à modération manuelle.") L.append("- Validations : courriel de contact, URL `http(s)://`, type " "d'emploi et mode de travail dans les vocabulaires fermés.") L.append("- **Pot de miel anti-robots** : champ caché `website` — s'il " "est rempli, l'offre est silencieusement ignorée.") L.append("") L.append("## Accès") L.append("") L.append("- **Endpoint** : `POST /api/employeurs/offres` (JSON).") L.append("- **Backend** : aucun fetch sortant — données poussées par " "l'employeur.") L.append("") sec_champs(L, live, sid) L.append("## Fréquence & budgets") L.append("") L.append("- Au fil de l'eau (soumissions) ; publication après modération " "manuelle.") L.append("") sec_volumetrie(L, live, sid) tot = live.total.get(sid, 0) act = live.active.get(sid, 0) L.append(f"Soumissions en attente de modération : **{tot - act}**.") L.append("") L.append("## Erreurs & dépannage") L.append("") L.append("- `422` sur le formulaire = validation (courriel, URL, " "vocabulaires). Les soumissions piégées par le pot de miel ne " "sont pas stockées.") L.append("- Lister les offres en attente : `sqlite3 data/jobka.db " "\"SELECT uid, employer, title FROM jobs WHERE " "source='depot-direct' AND active=0;\"`") L.append("") L.append("## Licence / conformité") L.append("") L.append(CONFO_DEPOT) L.append("") L.append("## Historique") L.append("") L.append(HIST_MISSION) L.append("") return md_header("depot-direct.md", "Fiche du canal de dépôt direct employeurs") + "\n".join(L) # --------------------------------------------------------------------------- # INDEX # --------------------------------------------------------------------------- def render_index(sources: list[dict], by_ats: dict, live: Live, n_fiches: int) -> str: employers = [s for s in sources if s["connector"] not in ("portail", "depot-direct")] portals = [s for s in sources if s["connector"] == "portail"] act_all = sum(live.active.values()) masked_all = sum(live.masked.values()) act_dir = sum(live.active.get(s["id"], 0) for s in employers) act_por = sum(live.active.get(s["id"], 0) for s in portals) L: list[str] = ["# Job·Ka — Connecteurs (index)", ""] L.append(f"> Index généré le {STAMP} par `scripts/gen_connector_docs.py` " f"(rejouable). Croise `data/sources.json`, l'introspection de " f"`jobka/connectors/*.py` et la base live `data/jobka.db`.") L.append("") L.append(f"**{len(employers)} employeurs directs** connectés via " f"**{len(by_ats)} plateformes ATS** + **{len(portals)} portails " f"agrégateurs** + le canal de **dépôt direct** — " f"**{act_all} offres actives** ({act_dir} directes, {act_por} " f"portails ; {masked_all} masquées comme doublons inter-sources, " f"soit {act_all - masked_all} visibles). {n_fiches} fiches.") L.append("") L.append("Conformité : voir [`docs/CONFORMITE.md`](../CONFORMITE.md). " "Chaque offre conserve son URL originale ; la candidature se " "fait chez l'employeur (badge « Offre directe » sur les sources " "directes).") L.append("") L.append("## Plateformes ATS") L.append("") L.append("| Plateforme | Employeurs | Actives | Salaire | GPS |") L.append("|---|---|---|---|---|") for ats in sorted(by_ats, key=lambda a: -sum( live.active.get(t["id"], 0) for t in by_ats[a])): ts = by_ats[ats] act = sum(live.active.get(t["id"], 0) for t in ts) sal = sum(live.fields["salary"].get(t["id"], 0) for t in ts) gps = sum(live.fields["gps"].get(t["id"], 0) for t in ts) L.append(f"| [`{ats}`](ats-{ats}.md) | {len(ts)} | {act} | " f"{pct(sal, act)} | {pct(gps, act)} |") L.append("") L.append("## Portails agrégateurs & dépôt") L.append("") L.append("| Source | Actives | Salaire | Dernier sync |") L.append("|---|---|---|---|") for s in portals: sid = s["id"] act = live.active.get(sid, 0) ls = live.last_sync.get(sid) L.append(f"| [{s['name']}]({sid}.md) | {act} | " f"{pct(live.fields['salary'].get(sid, 0), act)} | " f"{fmt_ts(ls['ts']) if ls is not None else 'n/d'} |") dd_tot = live.total.get("depot-direct", 0) dd_act = live.active.get("depot-direct", 0) L.append(f"| [Dépôt direct employeurs](depot-direct.md) | {dd_act} " f"({dd_tot - dd_act} en modération) | — | au fil de l'eau |") L.append("") L.append("## Employeurs directs (par volume d'offres actives)") L.append("") L.append("| Employeur | ATS | Actives | Salaire | GPS | Dernier sync OK |") L.append("|---|---|---|---|---|---|") for s in sorted(employers, key=lambda s: (-live.active.get(s["id"], 0), s["name"].lower())): sid = s["id"] act = live.active.get(sid, 0) ls = live.last_sync.get(sid) ok = ("✅" if ls is not None and ls["ok"] else ("❌" if ls is not None else "n/d")) L.append(f"| [{s['name']}]({sid}.md) | [`{s['connector']}`]" f"(ats-{s['connector']}.md) | {act} | " f"{pct(live.fields['salary'].get(sid, 0), act)} | " f"{pct(live.fields['gps'].get(sid, 0), act)} | {ok} |") L.append("") L.append("## Régénérer cette documentation") L.append("") L.append("```bash") L.append("cd ~/apps/job-ka && .venv/bin/python scripts/gen_connector_docs.py") L.append("```") L.append("") return md_header("INDEX.md", "Index généré de la documentation des " "connecteurs (ATS, employeurs, portails, dépôt)") \ + "\n".join(L) # --------------------------------------------------------------------------- def main() -> int: sources = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"] from jobka.connectors import CONNECTORS con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) con.row_factory = sqlite3.Row live = Live(con) gmap = git_map() OUT_DIR.mkdir(parents=True, exist_ok=True) for old in OUT_DIR.glob("*.md"): old.unlink() by_ats: dict[str, list[dict]] = {} n = 0 for src in sources: conn = src["connector"] if conn == "depot-direct": (OUT_DIR / "depot-direct.md").write_text( fiche_depot(live), encoding="utf-8") n += 1 elif conn == "portail": cls = CONNECTORS.get(src["id"]) (OUT_DIR / f"{src['id']}.md").write_text( fiche_portail(src, cls, live, gmap), encoding="utf-8") n += 1 else: by_ats.setdefault(conn, []).append(src) for ats, tenants in by_ats.items(): intro = introspect_module(CONNECTORS_DIR / f"{ats}.py") for t in tenants: cls = CONNECTORS.get(t["id"]) (OUT_DIR / f"{t['id']}.md").write_text( fiche_employeur(t, cls, intro, live, gmap), encoding="utf-8") n += 1 (OUT_DIR / f"ats-{ats}.md").write_text( fiche_ats(ats, tenants, live, gmap), encoding="utf-8") n += 1 (OUT_DIR / "INDEX.md").write_text( render_index(sources, by_ats, live, n + 1), encoding="utf-8") n += 1 print(f"[gen_connector_docs] {n} fichiers générés dans docs/connecteurs/ " f"({len(by_ats)} plateformes ATS, " f"{sum(len(v) for v in by_ats.values())} employeurs, " f"{sum(1 for s in sources if s['connector'] == 'portail')} portails, " f"1 dépôt direct).") con.close() return 0 if __name__ == "__main__": sys.exit(main())