Repository Public
1#!/usr/bin/env python32# =============================================================================3# Job·Ka — Groupe KA4# Auteur : Simon-Pierre Boucher5# Contact : contact@spboucher.ai6# Fichier : scripts/gen_connectors.py7# Rôle : Génération en série des connecteurs employeurs à partir d'un8# fichier de flux vérifiés (data/feeds.json) + MAJ de sources.json9# Créé : 2026-08-17 Modifié : 2026-08-1710# =============================================================================11"""Génère un connecteur (~15 lignes) par flux ATS vérifié.1213Usage :14 python3 scripts/gen_connectors.py data/feeds.json1516Format d'entrée (liste JSON) — champs selon l'ATS :17 {"ats":"workday","tenant":"x","host":"wd3","site":"Site","employer":"Nom",18 "qc":N, "cities":[...]}19 {"ats":"lever"|"greenhouse"|"smartrecruiters"|"ashby"|"workable"|20 "recruitee"|"breezy"|"bamboohr","org":"slug","employer":"Nom", ...}2122Idempotent : un connecteur existant n'est PAS réécrit (préserve les23ajustements manuels) ; sources.json est fusionné par id.24"""25from __future__ import annotations2627import datetime28import json29import re30import sys31import unicodedata32from pathlib import Path3334ROOT = Path(__file__).resolve().parent.parent35CONN_DIR = ROOT / "jobka" / "connectors"36SOURCES = ROOT / "data" / "sources.json"3738TODAY = datetime.date.today().isoformat()3940# ats -> (module de plateforme, classe de base, attribut(s) d'org)41PLATFORMS = {42 "workday": ("workday", "WorkdayConnector"),43 "lever": ("lever", "LeverConnector"),44 "greenhouse": ("greenhouse", "GreenhouseConnector"),45 "smartrecruiters": ("smartrecruiters", "SmartRecruitersConnector"),46 "ashby": ("ashby", "AshbyConnector"),47 "workable": ("workable", "WorkableConnector"),48 "recruitee": ("recruitee", "RecruiteeConnector"),49 "breezy": ("breezy", "BreezyConnector"),50 "bamboohr": ("bamboohr", "BambooHRConnector"),51}5253ORG_ATTR = {"lever": "ORG", "greenhouse": "BOARD",54 "smartrecruiters": "COMPANY", "ashby": "ORG", "workable": "ORG",55 "recruitee": "ORG", "breezy": "ORG", "bamboohr": "ORG"}5657# ATS à attributs multiples (secteur public/parapublic et grands employeurs) :58# feed[clé] -> attribut de classe. Voir data/feeds-public.json.59EXTRA_ATTRS = {60 "taleo": [("host", "HOST"), ("section", "SECTION"), ("portal", "PORTAL")],61 "njoyn": [("base", "BASE"), ("cl", "CL"), ("clid", "CLID"),62 ("use_scrapfly", "USE_SCRAPFLY")],63 "ultipro": [("org", "ORG"), ("board", "BOARD")],64 "icims": [("sub", "SUB")],65 "successfactors": [("base", "BASE")],66 "adp": [("cid", "CID"), ("ccid", "CCID")],67 "digitalrecruiters": [("base", "BASE")],68 "workland": [("list_url", "LIST_URL")],69}7071CAREERS_URL = {72 "workday": "https://{tenant}.{host}.myworkdayjobs.com/{site}",73 "lever": "https://jobs.lever.co/{org}",74 "greenhouse": "https://job-boards.greenhouse.io/{org}",75 "smartrecruiters": "https://jobs.smartrecruiters.com/company/{org}",76 "ashby": "https://jobs.ashbyhq.com/{org}",77 "workable": "https://apply.workable.com/{org}",78 "recruitee": "https://{org}.recruitee.com",79 "breezy": "https://{org}.breezy.hr",80 "bamboohr": "https://{org}.bamboohr.com/careers",81 "taleo": "https://{host}.taleo.net/careersection/{section}/jobsearch.ftl?lang=fr",82 "njoyn": "{base}/{cl}/xweb/xweb.asp?clid={clid}&page=joblisting&lang=2",83 "ultipro": "https://recruiting.ultipro.com/{org}/JobBoard/{board}",84 "icims": "https://{sub}.icims.com/jobs/search?ss=1",85 "successfactors": "{base}",86 "adp": ("https://workforcenow.adp.com/mascsr/default/mdf/recruitment/"87 "recruitment.html?cid={cid}&ccId={ccid}&lang=fr_CA"),88 "digitalrecruiters": "{base}",89 "workland": "{list_url}",90}9192PLATFORMS.update({93 "taleo": ("taleo", "TaleoConnector"),94 "njoyn": ("njoyn", "NjoynConnector"),95 "ultipro": ("ultipro", "UltiProConnector"),96 "icims": ("icims", "ICIMSConnector"),97 "successfactors": ("successfactors", "SuccessFactorsConnector"),98 "adp": ("adp", "ADPWorkforceNowConnector"),99 "digitalrecruiters": ("digitalrecruiters", "DigitalRecruitersConnector"),100 "workland": ("workland", "WorklandConnector"),101})102103104def slugify(s: str) -> str:105 s = unicodedata.normalize("NFD", s)106 s = "".join(c for c in s if unicodedata.category(c) != "Mn")107 s = re.sub(r"[^A-Za-z0-9]+", "_", s.lower()).strip("_")108 s = re.sub(r"_+", "_", s)109 # un module/identifiant Python ne peut pas commencer par un chiffre110 return f"x{s}" if s and s[0].isdigit() else s111112113def camel(s: str) -> str:114 return "".join(p.capitalize() for p in s.split("_"))115116117HEADER = """\118# =============================================================================119# Job·Ka — Groupe KA120# Auteur : Simon-Pierre Boucher121# Contact : contact@spboucher.ai122# Fichier : jobka/connectors/{fname}123# Rôle : Connecteur {employer} — {ats} ({detail})124# [généré par scripts/gen_connectors.py, flux vérifié le {today}]125# Créé : {today} Modifié : {today}126# =============================================================================127"""128129130def gen_one(feed: dict) -> tuple[str, str] | None:131 ats = feed["ats"]132 module, base = PLATFORMS[ats]133 sid = feed.get("source_id") or slugify(feed.get("org")134 or feed.get("tenant")135 or feed["employer"])136 fname = f"{sid}.py"137 path = CONN_DIR / fname138 if path.exists():139 return None # idempotent : ne pas écraser un connecteur existant140 cls = camel(sid) + "Connector"141142 if ats == "workday":143 detail = f"tenant {feed['tenant']}.{feed['host']}, site {feed['site']}"144 body = (f" TENANT = {feed['tenant']!r}\n"145 f" HOST = {feed['host']!r}\n"146 f" SITE = {feed['site']!r}\n")147 elif ats in EXTRA_ATTRS:148 pairs = [(k, a) for k, a in EXTRA_ATTRS[ats]149 if feed.get(k) not in (None, "")]150 detail = ", ".join(f"{a.lower()}={feed[k]!r}" for k, a in pairs[:2])151 body = "".join(f" {a} = {feed[k]!r}\n" for k, a in pairs)152 else:153 attr = ORG_ATTR[ats]154 detail = f"org « {feed['org']} »"155 body = f" {attr} = {feed['org']!r}\n"156157 src = (HEADER.format(fname=fname, employer=feed["employer"], ats=ats,158 detail=detail, today=TODAY)159 + f"from .{module} import {base}\n\n\n"160 + f"class {cls}({base}):\n"161 + f" source_id = {sid!r}\n"162 + f" EMPLOYER = {feed['employer']!r}\n"163 + body)164 path.write_text(src, encoding="utf-8")165 return sid, fname166167168def careers_url(feed: dict) -> str:169 return CAREERS_URL[feed["ats"]].format(**feed)170171172def main() -> None:173 feeds = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))174 reg = json.loads(SOURCES.read_text(encoding="utf-8"))175 known = {s["id"] for s in reg["sources"]}176177 created = skipped = 0178 for feed in feeds:179 sid = feed.get("source_id") or slugify(feed.get("org")180 or feed.get("tenant")181 or feed["employer"])182 feed["source_id"] = sid183 res = gen_one(feed)184 if res:185 created += 1186 print(f" + {res[1]}")187 else:188 skipped += 1189 if sid not in known:190 reg["sources"].append({191 "id": sid,192 "name": feed["employer"],193 "url": feed.get("url", ""),194 "careers_url": careers_url(feed),195 "sectors": feed.get("sectors", []),196 "connector": feed["ats"],197 "status": "actif",198 "region": ", ".join((feed.get("cities") or [])[:3]),199 })200 known.add(sid)201202 reg["_meta"]["updated"] = TODAY203 SOURCES.write_text(json.dumps(reg, ensure_ascii=False, indent=2) + "\n",204 encoding="utf-8")205 print(f"[gen] {created} connecteurs créés, {skipped} déjà présents, "206 f"{len(reg['sources'])} sources au registre")207208209if __name__ == "__main__":210 main()211