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%
6.9 KB · 163 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/merge_confirmed.py7# Rôle    : Fusion + nettoyage des sorties de probe_ats.py (plusieurs vagues)8#           en un feeds-*.json propre pour gen_connectors.py — dédoublonnage,9#           filtre des faux positifs « Québec » (Alma Michigan…), nettoyage10#           des noms d'employeurs issus des titres Google, villes normalisées11# Créé    : 2026-08-23   Modifié : 2026-08-2312# =============================================================================13"""Usage :14    python3 scripts/merge_confirmed.py OUT.json BRUT1.json [BRUT2.json ...]15"""16from __future__ import annotations1718import json19import re20import sys21import unicodedata22from pathlib import Path2324ROOT = Path(__file__).resolve().parent.parent2526# lieux qui ont trompé is_quebec_location (villes homonymes hors Québec)27_BAD_LOC = re.compile(28    r"michigan|ohio|georgia|texas|florida|california|colorado|illinois|"29    r"indiana|iowa|kansas|kentucky|maine\b|maryland|missouri|nebraska|"30    r"nevada|oregon|tennessee|utah|virginia|wisconsin|arkansas|alabama|"31    r"oklahoma|louisiana|pennsylvania|new (york|jersey|mexico|hampshire)|"32    r"(north|south) (carolina|dakota)|arizona|washington|idaho|montana\b|"33    r"wyoming|delaware|connecticut|massachusetts|rhode island|vermont\b|"34    r"minnesota|mississippi|hawaii|alaska|, [a-z]{2} \d{5}|"35    r", (mi|oh|ga|tx|fl|co|il|ia|ks|ky|md|mo|ne|nv|tn|ut|va|wi|ar|al|ok|"36    r"pa|nj|nh|nm|az|wa|id|mt|wy|de|ct|ma|ri|vt|mn|ms|hi|ak|ny), "37    r"?(usa?|united states|us)|"38    r"france|belgique|belgium|suisse|switzerland|maroc|morocco|"39    r"united states|estados|deutschland",40    re.I)4142_QC_HINT = re.compile(43    r"qu[ée]bec|qc\b|montr[ée]al|laval|gatineau|sherbrooke|longueuil|"44    r"trois.rivi|saguenay|l[ée]vis|brossard|drummondville|granby|"45    r"terrebonne|boucherville|saint-|ste?-|rimouski|victoriaville|"46    r"rouyn|sept-[îi]les|joliette|mirabel|blainville|repentigny|"47    r"chicoutimi|kirkland|pointe-claire|dorval|anjou|lachine|varennes|"48    r"bromont|magog|thetford|boisbriand|sorel|alma, q|shawinigan|val-d|beloeil|vaudreuil|ch[âa]teauguay|candiac|"49    r"remote.*(canada|qu[ée]bec)|canada.*remote", re.I)5051_NOISE = re.compile(52    r"^(jobs?|careers?|emplois?|carri[èe]res?|current openings|"53    r"offres? d'emplois?|open positions|job openings|opportunit[ée]s?)"54    r"( (at|@|chez|-|:|\|))?\s*", re.I)55_TAIL = re.compile(56    r"\s*(\||||-|:)?\s*(jobs?|careers?|emplois?|carri[èe]res?|"57    r"job board|hiring|apply|recrutement|current openings)\s*$", re.I)5859# noms qui sont en fait des titres de poste (titre Google de la page détail)60_TITLE_LIKE = re.compile(61    r"planificat|technicien|conseill|directeur|directrice|coordonn|"62    r"d[ée]veloppeur|ing[ée]nieur|analyste|adjoint|pr[ée]pos|superviseur|"63    r"gestionnaire|repr[ée]sentant|sp[ée]cialiste|op[ée]rateur|m[ée]canicien|"64    r"soudeur|chauffeur|commis|caissi|engineer\b|manager\b|developer\b|"65    r"analyst\b|specialist\b|technician\b|supervisor\b|coordinator\b|"66    r"director\b|lead\b|intern(e|ship)?\b|stagiaire|temps (plein|partiel)|"67    r"full.time|part.time|\(h/f\)|remote\b", re.I)686970def clean_employer(e: str, api_name: str, org: str) -> str:71    if api_name and len(api_name) > 2:72        e = api_name73    if " @ " in e:                       # « Titre du poste @ Employeur »74        e = e.split(" @ ")[-1]75    e = re.sub(r"\s+", " ", e).strip(" '\"·•…")76    prev = None77    while prev != e:78        prev = e79        e = _NOISE.sub("", e).strip()80        e = _TAIL.sub("", e).strip()81    if len(e) < 3 or _TITLE_LIKE.search(e):82        e = org.replace("-", " ").replace("_", " ").title()83    return e848586def city_label(loc: str) -> str:87    c = loc.split(",")[0].split("(")[0].strip()88    c = re.sub(r"\s*-\s*(hq|si[èe]ge|office|bureau).*$", "", c, flags=re.I)89    c = re.sub(r"\s+(office|bureau|campus|hq|studio)s?$", "", c, flags=re.I)90    fix = {"montreal": "Montréal", "quebec": "Québec",91           "quebec city": "Québec", "levis": "Lévis",92           "trois-rivieres": "Trois-Rivières"}93    return fix.get(c.lower(), c[:1].upper() + c[1:]) if c else ""949596def main() -> None:97    out_path, *ins = sys.argv[1:]98    # slugs déjà connectés99    pat = re.compile(100        r"^\s*(?:ORG|BOARD|COMPANY|TENANT|NS)\s*=\s*['\"]([^'\"]+)", re.M)101    known: set[str] = set()102    for f in (ROOT / "jobka" / "connectors").glob("*.py"):103        for m in pat.finditer(f.read_text(encoding="utf-8")):104            known.add(m.group(1).lower())105106    merged: dict[tuple, dict] = {}107    dropped_qc, dropped_known = [], 0108    for p in ins:109        if not Path(p).exists():110            print(f"  ! absent : {p}")111            continue112        for e in json.loads(Path(p).read_text(encoding="utf-8")):113            org = (e.get("org") or e.get("tenant") or e.get("ns")114                   or e.get("slug") or "")115            key = (e["ats"], org.lower())116            if re.search(r"demo|sandbox|test[0-9]|sample", org, re.I):117                continue118            if org.lower() in {"ashby", "greenhouse", "lever", "workable",119                               "recruitee", "breezy", "bamboohr", "workday",120                               "smartrecruiters", "dayforce"}:121                continue122            if org.lower() in known:123                dropped_known += 1124                continue125            if key in merged:126                continue127            locs = e.get("_qc_locations") or []128            good = [l for l in locs129                    if not _BAD_LOC.search(l) and _QC_HINT.search(l)]130            if locs and not good:131                dropped_qc.append((e.get("employer", org), locs[:2]))132                continue133            e["employer"] = clean_employer(134                e.get("employer", ""), e.get("_api_name") or "", org)135            cities = []136            for l in good[:4]:137                c = city_label(l)138                if c and c not in cities and not re.search(139                        r"remote|t[ée]l[ée]travail|canada$|qu[ée]bec - ", c,140                        re.I):141                    cities.append(c)142            e["cities"] = cities or e.get("cities") or []143            e.setdefault("sectors", [])144            e.setdefault("url", "")145            for k in list(e):146                if k.startswith("_") or k == "slug":147                    e.pop(k)148            merged[key] = e149150    feeds = sorted(merged.values(),151                   key=lambda x: (x["ats"], x.get("org") or x.get("tenant", "")))152    Path(out_path).write_text(153        json.dumps(feeds, ensure_ascii=False, indent=1) + "\n",154        encoding="utf-8")155    print(f"[merge] {len(feeds)} flux -> {out_path} "156          f"({dropped_known} déjà connectés, {len(dropped_qc)} faux QC)")157    for n, locs in dropped_qc[:15]:158        print(f"    faux QC : {n} {locs}")159160161if __name__ == "__main__":162    main()163