#!/usr/bin/env python3 # ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : scripts/merge_confirmed.py # Rôle : Fusion + nettoyage des sorties de probe_ats.py (plusieurs vagues) # en un feeds-*.json propre pour gen_connectors.py — dédoublonnage, # filtre des faux positifs « Québec » (Alma Michigan…), nettoyage # des noms d'employeurs issus des titres Google, villes normalisées # Créé : 2026-08-23 Modifié : 2026-08-23 # ============================================================================= """Usage : python3 scripts/merge_confirmed.py OUT.json BRUT1.json [BRUT2.json ...] """ from __future__ import annotations import json import re import sys import unicodedata from pathlib import Path ROOT = Path(__file__).resolve().parent.parent # lieux qui ont trompé is_quebec_location (villes homonymes hors Québec) _BAD_LOC = re.compile( r"michigan|ohio|georgia|texas|florida|california|colorado|illinois|" r"indiana|iowa|kansas|kentucky|maine\b|maryland|missouri|nebraska|" r"nevada|oregon|tennessee|utah|virginia|wisconsin|arkansas|alabama|" r"oklahoma|louisiana|pennsylvania|new (york|jersey|mexico|hampshire)|" r"(north|south) (carolina|dakota)|arizona|washington|idaho|montana\b|" r"wyoming|delaware|connecticut|massachusetts|rhode island|vermont\b|" r"minnesota|mississippi|hawaii|alaska|, [a-z]{2} \d{5}|" r", (mi|oh|ga|tx|fl|co|il|ia|ks|ky|md|mo|ne|nv|tn|ut|va|wi|ar|al|ok|" r"pa|nj|nh|nm|az|wa|id|mt|wy|de|ct|ma|ri|vt|mn|ms|hi|ak|ny), " r"?(usa?|united states|us)|" r"france|belgique|belgium|suisse|switzerland|maroc|morocco|" r"united states|estados|deutschland", re.I) _QC_HINT = re.compile( r"qu[ée]bec|qc\b|montr[ée]al|laval|gatineau|sherbrooke|longueuil|" r"trois.rivi|saguenay|l[ée]vis|brossard|drummondville|granby|" r"terrebonne|boucherville|saint-|ste?-|rimouski|victoriaville|" r"rouyn|sept-[îi]les|joliette|mirabel|blainville|repentigny|" r"chicoutimi|kirkland|pointe-claire|dorval|anjou|lachine|varennes|" r"bromont|magog|thetford|boisbriand|sorel|alma, q|shawinigan|val-d|beloeil|vaudreuil|ch[âa]teauguay|candiac|" r"remote.*(canada|qu[ée]bec)|canada.*remote", re.I) _NOISE = re.compile( r"^(jobs?|careers?|emplois?|carri[èe]res?|current openings|" r"offres? d'emplois?|open positions|job openings|opportunit[ée]s?)" r"( (at|@|chez|-|:|\|))?\s*", re.I) _TAIL = re.compile( r"\s*(\||–|—|-|:)?\s*(jobs?|careers?|emplois?|carri[èe]res?|" r"job board|hiring|apply|recrutement|current openings)\s*$", re.I) # noms qui sont en fait des titres de poste (titre Google de la page détail) _TITLE_LIKE = re.compile( r"planificat|technicien|conseill|directeur|directrice|coordonn|" r"d[ée]veloppeur|ing[ée]nieur|analyste|adjoint|pr[ée]pos|superviseur|" r"gestionnaire|repr[ée]sentant|sp[ée]cialiste|op[ée]rateur|m[ée]canicien|" r"soudeur|chauffeur|commis|caissi|engineer\b|manager\b|developer\b|" r"analyst\b|specialist\b|technician\b|supervisor\b|coordinator\b|" r"director\b|lead\b|intern(e|ship)?\b|stagiaire|temps (plein|partiel)|" r"full.time|part.time|\(h/f\)|remote\b", re.I) def clean_employer(e: str, api_name: str, org: str) -> str: if api_name and len(api_name) > 2: e = api_name if " @ " in e: # « Titre du poste @ Employeur » e = e.split(" @ ")[-1] e = re.sub(r"\s+", " ", e).strip(" '\"·•…") prev = None while prev != e: prev = e e = _NOISE.sub("", e).strip() e = _TAIL.sub("", e).strip() if len(e) < 3 or _TITLE_LIKE.search(e): e = org.replace("-", " ").replace("_", " ").title() return e def city_label(loc: str) -> str: c = loc.split(",")[0].split("(")[0].strip() c = re.sub(r"\s*-\s*(hq|si[èe]ge|office|bureau).*$", "", c, flags=re.I) c = re.sub(r"\s+(office|bureau|campus|hq|studio)s?$", "", c, flags=re.I) fix = {"montreal": "Montréal", "quebec": "Québec", "quebec city": "Québec", "levis": "Lévis", "trois-rivieres": "Trois-Rivières"} return fix.get(c.lower(), c[:1].upper() + c[1:]) if c else "" def main() -> None: out_path, *ins = sys.argv[1:] # slugs déjà connectés pat = re.compile( r"^\s*(?:ORG|BOARD|COMPANY|TENANT|NS)\s*=\s*['\"]([^'\"]+)", re.M) known: set[str] = set() for f in (ROOT / "jobka" / "connectors").glob("*.py"): for m in pat.finditer(f.read_text(encoding="utf-8")): known.add(m.group(1).lower()) merged: dict[tuple, dict] = {} dropped_qc, dropped_known = [], 0 for p in ins: if not Path(p).exists(): print(f" ! absent : {p}") continue for e in json.loads(Path(p).read_text(encoding="utf-8")): org = (e.get("org") or e.get("tenant") or e.get("ns") or e.get("slug") or "") key = (e["ats"], org.lower()) if re.search(r"demo|sandbox|test[0-9]|sample", org, re.I): continue if org.lower() in {"ashby", "greenhouse", "lever", "workable", "recruitee", "breezy", "bamboohr", "workday", "smartrecruiters", "dayforce"}: continue if org.lower() in known: dropped_known += 1 continue if key in merged: continue locs = e.get("_qc_locations") or [] good = [l for l in locs if not _BAD_LOC.search(l) and _QC_HINT.search(l)] if locs and not good: dropped_qc.append((e.get("employer", org), locs[:2])) continue e["employer"] = clean_employer( e.get("employer", ""), e.get("_api_name") or "", org) cities = [] for l in good[:4]: c = city_label(l) if c and c not in cities and not re.search( r"remote|t[ée]l[ée]travail|canada$|qu[ée]bec - ", c, re.I): cities.append(c) e["cities"] = cities or e.get("cities") or [] e.setdefault("sectors", []) e.setdefault("url", "") for k in list(e): if k.startswith("_") or k == "slug": e.pop(k) merged[key] = e feeds = sorted(merged.values(), key=lambda x: (x["ats"], x.get("org") or x.get("tenant", ""))) Path(out_path).write_text( json.dumps(feeds, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") print(f"[merge] {len(feeds)} flux -> {out_path} " f"({dropped_known} déjà connectés, {len(dropped_qc)} faux QC)") for n, locs in dropped_qc[:15]: print(f" faux QC : {n} {locs}") if __name__ == "__main__": main()