SPB Git

spb/job-ka

Public
41.5 KB · 969 lines python
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/normalize.py6# Rôle    : Normalisation commune des offres (salaires, dates, mode/type7#           d'emploi, lieu, catégorie, exigences, séniorité) — jamais de8#           valeur inventée9# Créé    : 2026-08-17   Modifié : 2026-10-2010# =============================================================================11"""Couche de normalisation commune appelée par JobPosting.finalize().1213Philosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de14mots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente15reste absente (None / chaîne vide), on ne devine jamais.16"""17from __future__ import annotations1819import datetime as _dt20import re21import unicodedata2223__all__ = [24    "strip_accents", "clean_text", "clean_html", "parse_salary",25    "sanitize_salary", "salary_from_text", "salary_to_yearly",26    "salary_to_hourly", "parse_date", "parse_work_mode",27    "parse_employment_type", "parse_location", "canonical_city",28    "is_quebec_location", "categorize", "detect_language",29    "extract_benefits", "clean_title", "extract_requirements",30    "parse_seniority",31]323334# ---------------------------------------------------------------------------35# Texte36# ---------------------------------------------------------------------------3738def strip_accents(s: str) -> str:39    return "".join(c for c in unicodedata.normalize("NFD", s)40                   if unicodedata.category(c) != "Mn")414243def _key(s: str) -> str:44    """Minuscules sans accents — pour le matching de mots-clés."""45    return strip_accents((s or "").lower())464748def clean_text(s: str) -> str:49    """Espaces insécables -> espace, blancs réduits."""50    s = (s or "").replace("\xa0", " ").replace(" ", " ").replace(" ", " ")51    return re.sub(r"[ \t]+", " ", s).strip()525354_BLOCK_TAGS = re.compile(55    r"</?(?:p|div|br|li|ul|ol|h[1-6]|tr|table|section|article)[^>]*>", re.I)5657# Résidu HTML après une passe : balise ouvrante plausible ou entité encore58# encodée — signe d'un contenu doublement échappé.59_HTML_LEFTOVER = re.compile(60    r"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\b|&(?:amp|lt|gt|nbsp|#\d+);",61    re.I)626364def clean_html(html: str) -> str:65    """HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.6667    Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin68    d'un vrai parseur utilisent BeautifulSoup eux-mêmes).69    """70    if not html:71        return ""72    import html as _html73    s = html74    # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :75    # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).76    for _ in range(3):77        s = _html.unescape(s)78        s = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", s, flags=re.I | re.S)79        s = _BLOCK_TAGS.sub("\n", s)80        s = re.sub(r"<[^>]+>", " ", s)81        if not _HTML_LEFTOVER.search(s):82            break83    s = s.replace("\xa0", " ").replace(" ", " ")84    s = re.sub(r"[ \t]+", " ", s)85    s = re.sub(r" ?\n ?", "\n", s)86    return re.sub(r"\n{3,}", "\n\n", s).strip()878889# ---------------------------------------------------------------------------90# Salaires — transparence salariale = différenciateur clé de Job·Ka91# ---------------------------------------------------------------------------9293# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de94# salaire inventé, pas de « 1 $/h » venu d'un parsing raté).95_SALARY_BOUNDS = {96    "hour": (14.0, 250.0),97    "day": (100.0, 2000.0),98    "week": (500.0, 10000.0),99    "biweek": (1000.0, 20000.0),100    "month": (1800.0, 45000.0),101    "year": (20000.0, 600000.0),102}103104# Heures/an de référence pour les conversions (semaine de 40 h).105_HOURS_PER_YEAR = 2080.0106107_UNIT_PATTERNS = [108    ("hour", r"(?:/|par|per|an?)\s*(?:h\b|hr\b|hre\b|heure|hour)|horaire|hourly"),109    ("year", r"(?:/|par|per|an?)\s*(?:an\b|année|annee|year|yr\b)|annuel|annual|yearly|per\s+annum"),110    ("month", r"(?:/|par|per)\s*(?:mois|month)|mensuel|monthly"),111    ("biweek", r"(?:aux|par|per)\s*(?:2|deux|two)\s*semaines|bi-?weekly|quinzaine"),112    ("week", r"(?:/|par|per)\s*(?:sem\b|semaine|week|wk\b)|hebdomadaire|weekly"),113    ("day", r"(?:/|par|per)\s*(?:jour|day)|journalier|daily"),114]115116_NUM_RE = re.compile(r"\d{1,3}(?:[   ,]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?[kK]?")117118119def _parse_number(tok: str) -> float | None:120    tok = tok.strip()121    k = tok.lower().endswith("k")122    if k:123        tok = tok[:-1]124    # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales)125    tok = tok.replace(" ", " ").replace(" ", " ")126    if re.fullmatch(r"\d{1,3}(?:[ ,]\d{3})+(?:[.,]\d{1,2})?", tok):127        # séparateur de milliers : retirer espaces/virgules de groupe128        tok = re.sub(r"[ ,](?=\d{3}\b)", "", tok)129    tok = tok.replace(",", ".").replace(" ", "")130    try:131        v = float(tok)132    except ValueError:133        return None134    return v * 1000 if k else v135136137def parse_salary(raw: str) -> tuple[float | None, float | None, str | None]:138    """Texte de salaire -> (min, max, unité) ou (None, None, None).139140    Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche141    des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire.142    """143    s = clean_text(raw)144    if not s:145        return (None, None, None)146    key = _key(s)147148    unit = None149    for u, pat in _UNIT_PATTERNS:150        if re.search(pat, key):151            unit = u152            break153154    has_money = bool(re.search(r"\$|cad\b|dollar", key))155    if not has_money and unit is None:156        return (None, None, None)157158    nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)]159    nums = [n for n in nums if n is not None]160    if not nums:161        return (None, None, None)162163    # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire)164    if unit is None:165        probe = max(nums)166        if probe < 300:167            unit = "hour"168        elif probe >= 18000:169            unit = "year"170        else:171            return (None, None, None)   # zone ambiguë : ne pas deviner172173    lo, hi = _SALARY_BOUNDS[unit]174    vals = sorted(n for n in nums if lo <= n <= hi)175    if not vals:176        return (None, None, None)177    return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit)178179180_TO_YEAR = {"hour": _HOURS_PER_YEAR, "day": 260.0, "week": 52.0,181            "biweek": 26.0, "month": 12.0, "year": 1.0}182183184def salary_to_yearly(value: float | None, unit: str | None) -> float | None:185    if value is None or unit not in _TO_YEAR:186        return None187    return round(value * _TO_YEAR[unit], 0)188189190def salary_to_hourly(value: float | None, unit: str | None) -> float | None:191    y = salary_to_yearly(value, unit)192    return round(y / _HOURS_PER_YEAR, 2) if y is not None else None193194195def sanitize_salary(lo: float | None, hi: float | None, unit: str | None196                    ) -> tuple[float | None, float | None, str | None]:197    """Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.198199    Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour200    un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la201    valeur sort des bornes de son unité, on tente une réinterprétation vers202    l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune203    unité plausible -> on jette (pas de salaire aberrant publié).204    """205    if lo is None or unit not in _SALARY_BOUNDS:206        return (lo, hi, unit)207    b = _SALARY_BOUNDS[unit]208    if not (b[0] <= lo <= b[1]):209        for candidate in ("year", "hour"):210            cb = _SALARY_BOUNDS[candidate]211            if candidate != unit and cb[0] <= lo <= cb[1]:212                unit, b = candidate, cb213                break214        else:215            return (None, None, None)216    if hi is not None and not (b[0] <= hi <= b[1]):217        hi = None            # max aberrant : on garde le min plausible seul218    if hi is not None and hi < lo:219        lo, hi = hi, lo220    return (lo, hi, unit)221222223# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,224# allocations…) — garde-fou de l'extraction depuis la description.225_NOT_SALARY_CONTEXT = re.compile(226    r"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|"227    r"revenu[s]?\b|revenue|chiffre d affaires|budget|remboursement|"228    r"reimburse|expense|allocation|allowance|subvention|credit d impot|"229    r"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|"230    r"signing bonus|prime (?:a l embauche|de demarrage|de reference)|"231    r"million|milliard")232233234def salary_from_text(text: str) -> tuple[float | None, float | None,235                                         str | None, str]:236    """Cherche une mention salariale dans un texte libre (description).237238    Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),239    ignore les contextes non salariaux (financement, remboursements…), puis240    parse la première phrase plausible. Retourne (min, max, unité, libellé).241    """242    if not text or "$" not in text:243        return (None, None, None, "")244    for sentence in re.split(r"(?<!\d)\.(?!\d)|\n|;", text):245        if "$" not in sentence or not re.search(r"\d", sentence):246            continue247        frag = clean_text(sentence)[:160]248        if _NOT_SALARY_CONTEXT.search(_key(frag)):249            continue250        lo, hi, unit = parse_salary(frag)251        if lo is not None:252            return (lo, hi, unit, frag)253    return (None, None, None, "")254255256# ---------------------------------------------------------------------------257# Dates — ISO 8601, jamais de date inventée258# ---------------------------------------------------------------------------259260_MONTHS = {261    "janvier": 1, "jan": 1, "january": 1,262    "fevrier": 2, "fev": 2, "february": 2, "feb": 2,263    "mars": 3, "mar": 3, "march": 3,264    "avril": 4, "avr": 4, "april": 4, "apr": 4,265    "mai": 5, "may": 5,266    "juin": 6, "june": 6, "jun": 6,267    "juillet": 7, "juil": 7, "july": 7, "jul": 7,268    "aout": 8, "august": 8, "aug": 8,269    "septembre": 9, "sept": 9, "sep": 9, "september": 9,270    "octobre": 10, "oct": 10, "october": 10,271    "novembre": 11, "nov": 11, "november": 11,272    "decembre": 12, "dec": 12, "december": 12,273}274275_REL_RE = re.compile(276    r"(?:il y a|posted)\s+(\d+)\+?\s*(jour|day|semaine|week|mois|month)|"277    r"(\d+)\+?\s*(jour|day|semaine|week|mois|month)s?\s+ago", re.I)278_TODAY_RE = re.compile(r"aujourd'?hui|today|posted today|date du jour", re.I)279_YESTERDAY_RE = re.compile(r"hier|yesterday", re.I)280281282def parse_date(raw, today: _dt.date | None = None) -> str | None:283    """Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None.284285    Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats286    textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates287    relatives (« il y a 3 jours », « Posted 30+ Days Ago »).288    """289    if raw is None:290        return None291    today = today or _dt.date.today()292293    if isinstance(raw, (int, float)):        # epoch (Lever : millisecondes)294        ts = float(raw)295        if ts > 1e12:296            ts /= 1000.0297        if 1e9 <= ts <= 4e9:298            return _dt.date.fromtimestamp(ts).isoformat()299        return None300301    s = clean_text(str(raw))302    if not s:303        return None304    key = _key(s)305306    m = re.search(r"(\d{4})-(\d{2})-(\d{2})", s)     # ISO (avec ou sans heure)307    if m:308        try:309            return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()310        except ValueError:311            return None312313    if _TODAY_RE.search(key):314        return today.isoformat()315    if _YESTERDAY_RE.search(key):316        return (today - _dt.timedelta(days=1)).isoformat()317318    m = _REL_RE.search(key)319    if m:320        n = int(m.group(1) or m.group(3))321        unit = (m.group(2) or m.group(4) or "").lower()322        days = n * (7 if unit.startswith(("sem", "week"))323                    else 30 if unit.startswith(("moi", "month")) else 1)324        return (today - _dt.timedelta(days=days)).isoformat()325326    # « Sun Oct 04 02:00:00 UTC 2026 » — java.util.Date.toString(), émis par327    # les meta itemprop="datePosted" des sites SuccessFactors CSB328    m = re.search(r"\b([a-z]{3,9})\s+(\d{1,2})\s+\d{1,2}:\d{2}:\d{2}\s+"329                  r"[a-z]{1,5}\s+(\d{4})", key)330    if m:331        mois = _MONTHS.get(m.group(1))332        if mois:333            try:334                return _dt.date(int(m.group(3)), mois,335                                int(m.group(2))).isoformat()336            except ValueError:337                return None338339    # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 »340    m = re.search(r"(?:(\d{1,2})(?:er|e|st|nd|rd|th)?\s+)?([a-z]{3,9})\.?,?\s+(?:(\d{1,2})(?:er|st|nd|rd|th)?,?\s+)?(\d{4})", key)341    if m:342        mois = _MONTHS.get(m.group(2))343        if mois:344            jour = int(m.group(1) or m.group(3) or 1)345            try:346                return _dt.date(int(m.group(4)), mois, jour).isoformat()347            except ValueError:348                return None349350    m = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(\d{4})", s)   # jj/mm/aaaa (usage QC)351    if m:352        d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))353        if mo > 12 and d <= 12:      # en fait mm/jj/aaaa354            d, mo = mo, d355        try:356            return _dt.date(y, mo, d).isoformat()357        except ValueError:358            return None359    return None360361362# ---------------------------------------------------------------------------363# Mode de travail / type d'emploi364# ---------------------------------------------------------------------------365366_MODE_RULES = [367    ("teletravail", r"100\s*%\s*(?:teletravail|remote)|full(?:y)?[ -]remote|"368                    r"teletravail|travail a distance|remote(?:\b|-first)|a distance"),369    ("hybride", r"hybrid|hybride|flexible?\s*(?:work|travail)"),370    ("presentiel", r"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)"),371]372373374def parse_work_mode(raw: str) -> str | None:375    key = _key(raw)376    if not key:377        return None378    # « hybride » prime sur les mentions accessoires de télétravail379    if re.search(_MODE_RULES[1][1], key):380        return "hybride"381    for mode, pat in _MODE_RULES:382        if re.search(pat, key):383            return mode384    return None385386387_TYPE_RULES = [388    ("stage", r"\bstage\b|stagiaire|intern(?:ship)?\b|co-?op\b|alternance"),389    ("saisonnier", r"saisonn|seasonal"),390    ("contractuel", r"contrat\b|contract(?:uel|or)?\b|temporaire|temporary|terme|"391                    r"fixed[- ]term|duree determinee|pigiste|freelance|consultant"),392    ("temps_partiel", r"temps[- ]partiel|part[- ]?time|partiel\b"),393    ("temps_plein", r"temps[- ]plein|full[- ]?time|permanent|regulier|"394                    r"regular\b|duree indeterminee"),395]396397398def parse_employment_type(raw: str) -> str | None:399    key = _key(raw)400    if not key:401        return None402    for typ, pat in _TYPE_RULES:403        if re.search(pat, key):404            return typ405    return None406407408# ---------------------------------------------------------------------------409# Lieu — extraction ville/région + garde-fou « offre au Québec »410# ---------------------------------------------------------------------------411412# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents).413# Sert à la fois au parsing et au filtre is_quebec_location.414_QC_CITIES = {415    "montreal": "Montréal", "quebec": "Québec", "ville de quebec": "Québec",416    "quebec city": "Québec", "laval": "Laval", "gatineau": "Gatineau",417    "longueuil": "Longueuil", "sherbrooke": "Sherbrooke",418    "saguenay": "Saguenay", "chicoutimi": "Saguenay",419    "levis": "Lévis", "trois-rivieres": "Trois-Rivières",420    "trois rivieres": "Trois-Rivières", "terrebonne": "Terrebonne",421    "brossard": "Brossard", "repentigny": "Repentigny",422    "boucherville": "Boucherville", "saint-jerome": "Saint-Jérôme",423    "drummondville": "Drummondville", "saint-jean-sur-richelieu":424    "Saint-Jean-sur-Richelieu", "granby": "Granby", "shawinigan": "Shawinigan",425    "joliette": "Joliette", "victoriaville": "Victoriaville",426    "rimouski": "Rimouski", "rouyn-noranda": "Rouyn-Noranda",427    "baie-comeau": "Baie-Comeau", "sept-iles": "Sept-Îles",428    "fermont": "Fermont",429    # Baie-D'Urfé (West Island de Montréal) : libellé Workday du tenant430    # galderma depuis oct. 2026 (usine relibellée « Montreal » -> « Baie-D'Urfé »,431    # ids republiés — found 16->0 sans ça, 51 offres QC invisibles).432    "baie-d'urfe": "Baie-D'Urfé", "baie d'urfe": "Baie-D'Urfé",433    "val-d'or": "Val-d'Or", "val d'or": "Val-d'Or", "alma": "Alma",434    "sainte-foy": "Québec", "anjou": "Montréal", "dorval": "Dorval",435    "lachine": "Lachine",436    "boisbriand": "Boisbriand", "blainville": "Blainville",437    "mirabel": "Mirabel", "saint-hyacinthe": "Saint-Hyacinthe",438    "beloeil": "Belœil", "chateauguay": "Châteauguay", "magog": "Magog",439    "thetford mines": "Thetford Mines", "saint-georges": "Saint-Georges",440    "riviere-du-loup": "Rivière-du-Loup", "matane": "Matane",441    "sorel-tracy": "Sorel-Tracy", "varennes": "Varennes",442    "vaudreuil-dorion": "Vaudreuil-Dorion", "salaberry-de-valleyfield":443    "Salaberry-de-Valleyfield", "kirkland": "Kirkland",444    "pointe-claire": "Pointe-Claire", "saint-laurent": "Montréal",445    "ville saint-laurent": "Montréal", "verdun": "Montréal",446    "westmount": "Westmount", "candiac": "Candiac", "la prairie": "La Prairie",447    "mont-tremblant": "Mont-Tremblant", "saint-bruno": "Saint-Bruno-de-Montarville",448    "bromont": "Bromont", "cowansville": "Cowansville",449    "rougemont": "Rougemont",450    "saint-augustin-de-desmaures": "Saint-Augustin-de-Desmaures",451    "l'ancienne-lorette": "L'Ancienne-Lorette",452}453454_QC_MARKERS = re.compile(455    r"\bqc\b|\bquebec\b|\bqu[e]bec, canada\b|province de quebec", re.I)456_POSTAL_RE = re.compile(r"\b([GHJ]\d[A-Z])\s*(\d[A-Z]\d)\b", re.I)  # G/H/J = Québec457458# « Quebec » suivi d'un type de voie anglophone = nom de RUE, pas la province459# (« 3401 Quebec St. Suite 8000, Denver, CO » — bug solmentalhealth 2026-09-14 :460# offre de Denver publiée comme Québec/Capitale-Nationale). Neutralisé avant461# tout matching de marqueur/ville. Le (?!-) protège les abréviations de saints462# des libellés québécois légitimes (« CAN Quebec St-Hubert », « Ste-Thérèse »).463_QUEBEC_STREET_RE = re.compile(464    r"\bquebec\s+(?:st|street|ave|avenue|blvd|boulevard|rd|road|dr|drive|"465    r"ct|court|ln|lane|way|pl|place|hwy|highway|pkwy|parkway)\b\.?(?!-)")466467# Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes468# dans les flux pancanadiens/mondiaux — jamais des villes d'offres469# québécoises : à rejeter lors du parsing de lieu (clés sans accents).470_NON_QC_CITIES = frozenset({471    "toronto", "ottawa", "vancouver", "calgary", "edmonton", "winnipeg",472    "mississauga", "brampton", "hamilton", "kitchener", "waterloo", "halifax",473    "dartmouth", "victoria", "saskatoon", "regina", "st. john's", "st johns",474    "saint john", "fredericton", "moncton", "charlottetown", "london",475    "markham", "vaughan", "richmond hill", "oakville", "burlington", "oshawa",476    "whitby", "ajax", "pickering", "guelph", "kingston", "barrie",477    "kelowna", "kamloops", "nanaimo", "abbotsford", "surrey", "burnaby",478    "coquitlam", "langley", "north york", "scarborough", "etobicoke",479    "north vancouver", "milton", "cambridge", "st. catharines",480    "niagara falls", "thunder bay", "sudbury", "greater sudbury",481    "sault ste. marie", "red deer", "lethbridge", "medicine hat",482    "grande prairie", "fort mcmurray", "yellowknife", "whitehorse", "iqaluit",483    "can", "canada", "ontario", "alberta", "british columbia", "manitoba",484    "saskatchewan", "nova scotia", "new brunswick",485    "new york", "new york city", "san francisco", "boston", "chicago",486    "seattle", "austin", "denver", "los angeles", "miami", "atlanta",487    "dallas", "houston", "philadelphia", "washington", "phoenix", "portland",488    "paris", "londres", "amsterdam", "berlin", "munich", "dublin", "madrid",489    "barcelona", "barcelone", "lisbon", "lisbonne", "sydney", "melbourne",490    "singapore", "singapour", "tokyo", "bangalore", "bengaluru", "mumbai",491    "mexico city", "sao paulo", "bogota", "buenos aires", "las vegas",492    "orlando", "nashville", "macau",493})494495496def canonical_city(raw: str) -> str:497    """Canonicalise un nom de ville (« Montreal » -> « Montréal »,498    « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé."""499    s = clean_text(raw)500    if not s:501        return ""502    key = _key(s)503    exact = _QC_CITIES.get(key)504    if exact:505        return exact506    for ck, city in _QC_CITIES.items():507        if re.fullmatch(re.escape(ck), key):508            return city509    return s510511512def parse_location(raw: str) -> dict:513    """Libellé de lieu -> {city, region, postal_code} (champs possiblement vides).514515    Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ».516    """517    s = clean_text(raw)518    out = {"city": "", "region": "", "postal_code": ""}519    if not s:520        return out521    m = _POSTAL_RE.search(s)522    if m:523        out["postal_code"] = f"{m.group(1).upper()} {m.group(2).upper()}"524    key = _QUEBEC_STREET_RE.sub(" ", _key(s))525    for ck, city in _QC_CITIES.items():526        if re.search(rf"(?<![a-z]){re.escape(ck)}(?![a-z])", key):527            out["city"] = city528            break529    if _QC_MARKERS.search(key) or out["city"] or (530            out["postal_code"] and out["postal_code"][0] in "GHJ"):531        out["region"] = "Québec"532    if not out["city"]:533        # premier segment avant la virgule, si ça ressemble à un nom de ville :534        # contient des lettres, ≤ 3 mots, pas un mot générique ni un nom de535        # succursale (« Caisse Desjardins de… », « Centre de services… »)536        seg = clean_text(s.split(",")[0])537        seg_key = _key(seg)538        if (seg and len(seg) <= 40 and re.search(r"[a-z]", seg_key)539                and len(seg.split()) <= 3540                and seg_key not in _NON_QC_CITIES541                and not re.search(542                    r"remote|teletravail|canada|multiple|various|partout|"543                    r"\d+\s+locations?|\d+\s+lieux|caisse|succursale|"544                    r"centre de services|siege social", seg_key)):545            out["city"] = seg546    return out547548549# Code de province canadienne ou d'État américain explicitement HORS Québec550# (« Kirkland, WA », « London, ON ») : jamais une offre québécoise, même si la551# ville est homonyme d'une ville du Québec (Kirkland WA ≠ Kirkland QC — bug552# derumarketcatering 2026-08-26 : restaurant de l'État de Washington affiché553# comme Montréal). Codes ambigus avec le français exclus volontairement :554# CA (Canada/Californie), OR (Val-d'Or), LA (La Prairie), DE, IN, ME, MT.555_NON_QC_REGION_RE = re.compile(556    r"[,\s](on|bc|ab|sk|mb|ns|nb|pe|pei|nl|yt|nt|nu|"557    r"wa|ny|tx|fl|il|ma|md|mi|mn|mo|ms|nc|nd|ne|nh|nj|nm|nv|oh|ok|"558    r"pa|ri|sc|sd|tn|ut|va|vt|wi|wv|wy|ak|al|ar|az|co|ct|dc|ga|hi|"559    r"ia|id|ks|ky)\.?(?=$|[,\s])")560561562def is_quebec_location(raw: str) -> bool:563    """Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens)."""564    key = _QUEBEC_STREET_RE.sub(" ", _key(clean_text(raw)))565    if not key:566        return False567    if key in _NON_QC_CITIES:      # libellé = ville canadienne/monde hors QC568        return False569    if _QC_MARKERS.search(key):570        return True571    m = _POSTAL_RE.search(raw)572    if m and m.group(1)[0].upper() in "GHJ":573        return True574    if _NON_QC_REGION_RE.search(key):   # « Kirkland, WA » : homonyme hors QC575        return False576    return any(re.search(rf"(?<![a-z]){re.escape(ck)}(?![a-z])", key)577               for ck in _QC_CITIES)578579580# ---------------------------------------------------------------------------581# Catégorisation — taxonomie interne déterministe (mots-clés FR/EN)582# ---------------------------------------------------------------------------583584_CATEGORY_RULES = [585    ("TI", r"developpe|developer|logiciel|software|devops|donnees|data\b|"586           r"informatique|programmeur|programmer|cybersecurit|cloud|fullstack|"587           r"full[- ]stack|backend|frontend|sre\b|qa\b|scrum|infrastructure ti|"588           r"intelligence artificielle|machine learning|\bia\b|\bai\b|analyste ti"),589    ("Santé", r"infirmier|infirmiere|nurse|medecin|pharmac|dentaire|sante\b|"590              r"prepose aux beneficiaires|\bpab\b|physiotherap|ergotherap|"591              r"psycholog|travailleur social|clinique|medical"),592    ("Ingénierie", r"ingenieur|engineer(?!ing manager, software)|genie\b|"593                   r"mecanique du batiment|electrique|civil\b|structurel"),594    ("Construction", r"construction|charpentier|menuisier|plombier|electricien|"595                     r"contremaitre|chantier|grutier|briqueteur|couvreur|paysagiste"),596    ("Finance", r"comptab|account(?:ant|ing)|finance|financier|paie\b|payroll|audit|"597                r"fiscalite|tresorerie|actuaire|actuarial|credit\b|placement"),598    ("Éducation", r"enseignant|professeur|teacher|educat|tuteur|formateur|"599                  r"pedagog|garderie|cpe\b"),600    ("Juridique", r"avocat|juriste|notaire|parajuriste|paralegal|legal counsel|"601                  r"conformite|compliance"),602    ("RH", r"ressources humaines|\brh\b|recrut|talent|human resources|\bhr\b|"603           r"acquisition de talents"),604    ("Marketing", r"marketing|communication|contenu|content|marque|brand|"605                  r"medias sociaux|social media|seo\b|publicite|graphiste|designer"),606    ("Ventes", r"vente|ventes|sales|representant|account (?:executive|manager)|"607               r"developpement des affaires|business development|conseiller commercial"),608    ("Service à la clientèle", r"service a la clientele|customer (?:service|success|"609                               r"support)|centre d'appels|call cent"),610    ("Transport", r"chauffeur|camionneur|driver|livreur|logistique|logistics|"611                  r"transport|entrepot|warehouse|cariste|repartiteur"),612    ("Production", r"production|manufactur|usine|operateur|machiniste|soudeur|"613                   r"welder|assembleur|journalier|emballage|maintenance industrielle"),614    ("Restauration", r"cuisinier|chef\b|serveur|barista|restauration|plongeur|"615                     r"boulanger|patissier|traiteur|banquet"),616    ("Commerce de détail", r"commis|caissier|retail|magasin|boutique|"617                           r"marchandiseur|gerant de magasin"),618    ("Administration", r"adjoint|administratif|secretaire|receptionniste|"619                       r"coordonnat|commis de bureau|office (?:manager|clerk)"),620]621622623# ---------------------------------------------------------------------------624# Langue de l'offre — heuristique légère par mots-outils (fr | en | bilingue)625# ---------------------------------------------------------------------------626627# Mots-outils très fréquents et quasi exclusifs à chaque langue (sans accents).628_FR_STOPWORDS = re.compile(629    r"\b(?:le|la|les|des|une|et|est|sont|pour|avec|vous|nous|notre|nos|vos|"630    r"dans|chez|aux|du|au|ainsi|plus de|afin|etre|sera|poste|equipe|"631    r"exigences|taches|milieu|travail)\b")632_EN_STOPWORDS = re.compile(633    r"\b(?:the|and|for|with|you|your|we|our|is|are|will|this|that|team|"634    r"work|skills|of|to be|as well|role|responsibilities|requirements|"635    r"experience in|ability)\b")636637638def detect_language(title: str, description: str = "") -> str:639    """Langue du texte de l'offre : « fr », « en », « bilingue », ou "".640641    Comptage de mots-outils sur le début du texte — déterministe, jamais de642    valeur inventée : texte trop court ou ambigu -> "".643    """644    text = _key(f"{title}\n{description or ''}"[:4000])645    if len(text) < 30:646        return ""647    fr = len(_FR_STOPWORDS.findall(text))648    en = len(_EN_STOPWORDS.findall(text))649    total = fr + en650    if total < 5:651        return ""652    # les deux langues nettement présentes -> offre bilingue653    if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:654        return "bilingue"655    if fr > en:656        return "fr"657    if en > fr:658        return "en"659    return ""660661662# ---------------------------------------------------------------------------663# Avantages — extraction des blocs « Avantages / Benefits » des descriptions664# ---------------------------------------------------------------------------665666_BENEFITS_HEADING = re.compile(667    r"^\s*(?:les\s+|nos\s+|our\s+|tes\s+|vos\s+)?(?:avantages(?:\s+sociaux|"668    r"\s+offerts|\s+et\s+conditions)?|"669    r"benefits(?:\s+&\s+perks|\s+offered)?|perks(?:\s+(?:and|&)\s+benefits)?|"670    r"ce que (?:nous|l'on|on) (?:t'|te\s+|vous\s+)?offr(?:ons|e)|"671    r"nous (?:t'|te\s+|vous\s+)?offrons|on (?:t'|te\s+|vous\s+)offre|"672    r"what we offer|what'?s in it for you|why join us|why work (?:with|for) us|"673    r"pourquoi (?:nous rejoindre|nous choisir|te joindre a nous|"674    r"vous joindre a nous|travailler (?:chez|avec) nous)|we offer)"675    r"\s*:?\s*$", re.I)676_BENEFIT_LINE_MAX = 120677678679def extract_benefits(description: str) -> list[str]:680    """Liste d'avantages depuis un bloc structuré de la description.681682    Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,683    puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la684    première ligne longue ou au prochain titre. Max 12 items — si le bloc ne685    ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).686    """687    if not description:688        return []689    lines = description.split("\n")690    for i, line in enumerate(lines):691        if not _BENEFITS_HEADING.match(strip_accents(line)):692            continue693        items: list[str] = []694        for nxt in lines[i + 1:]:695            t = clean_text(nxt).lstrip("•-–—*·◦▪ ").strip()696            if not t:697                if items:698                    break          # fin du bloc (ligne vide après les puces)699                continue700            if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(701                    strip_accents(t)):702                break703            # un « titre » de section suivant (courte ligne sans ponctuation704            # finale, capitalisée) termine aussi le bloc705            if items and len(t.split()) <= 4 and t.endswith(":"):706                break707            items.append(t.rstrip(" ;,.").strip())708            if len(items) >= 12:709                break710        if len(items) >= 2:711            return items712    return []713714715# ---------------------------------------------------------------------------716# Exigences — expérience, scolarité, langues (depuis la description)717# ---------------------------------------------------------------------------718719# Années d'expérience : « 3 à 5 ans d'expérience », « minimum de 5 ans »,720# « 5+ years of experience », « two (2) years of relevant experience »…721_EXP_PATTERNS = [722    # « X à Y ans/years … expérience » (fourchette) — on garde le MIN723    re.compile(r"(\d{1,2})\s*(?:a|-|to)\s*\d{1,2}\s*(?:ans?|years?)"724               r"[^.\n]{0,40}?(?:d )?experience", re.I),725    # « X ans/années/years (+) d'expérience »726    re.compile(r"(\d{1,2})\s*\+?\s*(?:ans?|annees?|years?)"727               r"[^.\n]{0,40}?(?:d )?experience", re.I),728    # « expérience … de X ans » / « experience of X+ years »729    re.compile(r"experience[^.\n]{0,50}?(?:de|of|d au moins|minimum(?: de)?|"730               r"at least)\s*(\d{1,2})\s*\+?\s*(?:ans?|annees?|years?)", re.I),731    # « minimum de X ans » / « at least X years » (sans le mot expérience)732    re.compile(r"(?:minimum(?: de)?|au moins|at least)\s*(\d{1,2})\s*"733               r"(?:ans?|annees?|years?)\b", re.I),734]735736# Scolarité : du plus élevé au moins élevé — on retourne le PLUS BAS exigé737# quand plusieurs sont acceptés (« bac ou maîtrise » -> baccalauréat).738# Les sigles QC (DEP/DEC/AEC/DES) ne comptent que dans un contexte scolaire739# (« dec. » est aussi l'abréviation de décembre).740_EDU_SCHOOL_CONTEXT = re.compile(741    r"diplome|dipl\.|etudes|formation|scolarite|degree|diploma|education|"742    r"detenir|detention|obtenu|completed?\b|graduate", re.I)743_EDU_LEVELS = [   # (niveau canonique, motif, sigle nécessitant le contexte ?)744    ("secondaire", r"diplome d etudes secondaires|\bdes\b|secondaire (?:5|v)\b|"745                   r"high school diploma|secondary school diploma", True),746    ("DEP", r"diplome d etudes professionnelles|\bdep\b|"747            r"vocational diploma", True),748    ("AEC", r"attestation d etudes collegiales|\baec\b", True),749    ("DEC", r"diplome d etudes collegiales|\bdec\b|techniques? collegiales?|"750            r"college diploma|cegep", True),751    ("certificat", r"certificat universitaire|university certificate", False),752    ("baccalauréat", r"baccalaureat|bachelor|\bbacc\b|bac universitaire|"753                     r"\bbac\b (?:en|in)\b|undergraduate degree|"754                     r"b\.?\s?(?:sc|ing|a\.?a|com)\b", False),755    ("maîtrise", r"maitrise|master s degree|masters degree|\bmba\b|"756                 r"m\.?\s?sc\.?\b|graduate degree", False),757    ("doctorat", r"doctorat|\bphd\b|ph\.?\s?d\b|doctoral", False),758]759760# Langues exigées : mention explicite d'exigence (pas la langue du TEXTE —761# ça, c'est detect_language). « bilingue », « anglais requis », « fluent in762# English », « maîtrise du français »…763_LANG_BILINGUAL = re.compile(r"bilingu", re.I)764_LANG_REQ_FR = re.compile(765    r"francais[^.\n]{0,30}?(?:requis|exige|obligatoire|essentiel|"766    r"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|"767    r"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |du )?"768    r"francais|french (?:is )?(?:required|mandatory|essential)|"769    r"fluent in french|fluency in french|french proficiency", re.I)770_LANG_REQ_EN = re.compile(771    r"anglais[^.\n]{0,30}?(?:requis|exige|obligatoire|essentiel|"772    r"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|"773    r"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |de l )?"774    r"anglais|english (?:is )?(?:required|mandatory|essential)|"775    r"fluent in english|fluency in english|english proficiency", re.I)776777778def extract_requirements(title: str, description: str) -> dict:779    """Exigences structurées depuis le texte de l'offre — clés présentes780    seulement quand l'information est explicite (jamais de valeur inventée) :781782    - ``experience_years`` : années d'expérience minimales exigées (int)783    - ``education``        : plus bas diplôme exigé (secondaire, DEP, AEC,784                             DEC, certificat, baccalauréat, maîtrise, doctorat)785    - ``languages``        : langues exigées (["français"], ["anglais"], …)786    """787    out: dict = {}788    text = _key(f"{title or ''}\n{description or ''}"[:8000])789    text = re.sub(r"[’']", " ", text)         # « d'expérience » -> « d experience »790    if len(text) < 20:791        return out792793    years: list[int] = []794    for pat in _EXP_PATTERNS:795        for m in pat.finditer(text):796            try:797                n = int(m.group(1))798            except (TypeError, ValueError):799                continue800            if 1 <= n <= 30:                  # bornes de plausibilité801                years.append(n)802    if years:803        out["experience_years"] = min(years)  # l'exigence MINIMALE804805    for level, pat, needs_ctx in _EDU_LEVELS:806        for m in re.finditer(pat, text):807            if needs_ctx:808                window = text[max(0, m.start() - 120):m.end() + 120]809                if not _EDU_SCHOOL_CONTEXT.search(window):810                    continue811            out["education"] = level812            break813        if "education" in out:814            break                             # plus bas niveau exigé trouvé815816    langs: list[str] = []817    if _LANG_BILINGUAL.search(text):818        langs = ["français", "anglais"]819    else:820        if _LANG_REQ_FR.search(text):821            langs.append("français")822        if _LANG_REQ_EN.search(text):823            langs.append("anglais")824    if langs:825        out["languages"] = langs826    return out827828829# ---------------------------------------------------------------------------830# Séniorité — stage | junior | intermediaire | senior | direction831# ---------------------------------------------------------------------------832833_SENIORITY_TITLE = [834    ("stage", r"\bstagiaire\b|\bstage\b|\bintern(?:ship)?\b|\bco-?op\b|"835              r"alternance|etudiant"),836    ("direction", r"directeur|directrice|\bdirector\b|vice[- ]?president|"837                  r"\bvp\b|\bpdg\b|\bceo\b|\bcfo\b|\bcto\b|\bcoo\b|"838                  r"chef de (?:service|departement|division|la direction)|"839                  r"head of|\bdg\b|gestionnaire de (?:service|departement)"),840    ("senior", r"\bsenior\b|\bsr\.?\b|\bprincipal(?:e)?\b|\blead\b|"841               r"\bexpert(?:e)?\b|chef d equipe|team lead|superviseur|"842               r"\bniveau (?:3|iii)\b|\biii\b"),843    ("junior", r"\bjunior\b|\bjr\.?\b|debutant|entry[- ]level|"844               r"\bniveau (?:1|i)\b|premier emploi|releve"),845    ("intermediaire", r"intermediaire|intermediate|\bniveau (?:2|ii)\b|\bii\b"),846]847848# libellés d'expérience des ATS (SmartRecruiters/LinkedIn-style) -> séniorité849_SENIORITY_ATS = [850    ("stage", r"internship|stage"),851    ("direction", r"director|executive|direction"),852    ("senior", r"mid[- ]?senior|senior"),853    ("intermediaire", r"associate|intermediaire|intermediate|mid[- ]?level"),854    ("junior", r"entry|junior|debutant"),855]856857858def parse_seniority(title: str, requirements: dict | None = None,859                    employment_type: str | None = None) -> str | None:860    """Niveau de séniorité du poste, ou None si aucun signal explicite.861862    Ordre des signaux : type d'emploi « stage », titre du poste, libellé863    d'expérience fourni par l'ATS, puis années d'expérience exigées.864    """865    if employment_type == "stage":866        return "stage"867    key = _key(title or "")868    if key:869        for level, pat in _SENIORITY_TITLE:870            if re.search(pat, key):871                return level872    req = requirements or {}873    label = _key(str(req.get("experience", "")))874    if label and "not applicable" not in label:875        for level, pat in _SENIORITY_ATS:876            if re.search(pat, label):877                return level878    years = req.get("experience_years")879    if isinstance(years, (int, float)):880        if years <= 2:881            return "junior"882        if years <= 4:883            return "intermediaire"884        return "senior"885    return None886887888# ---------------------------------------------------------------------------889# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés890# ---------------------------------------------------------------------------891892# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…893_REQ_CODE = re.compile(894    # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour895    # ne pas manger une année « 2026 - Programme d'été »)896    r"^\s*[\[(]?(?:req(?:uisition)?|jr|job)[\s#:-]*\d{3,10}[\])]?\s*[-–—:|]\s*|"897    r"^\s*[\[(]?\d{5,10}[\])]?\s*[-–—:|]\s*|"898    # en queue : « Titre - R-12345 », « Titre (JOB 004521) »899    r"\s*[-–—|(]\s*(?:req(?:uisition)?|job|poste)?\s*#?\s*(?:[A-Z]{1,4}-?)?\d{4,10}\s*[)\]]?\s*$|"900    # au milieu : « Titre [R-12345] suite »901    r"\s*[\[(](?:R|REQ|JR|JOB)[-_ ]?\d{3,10}[\])]\s*", re.I)902903# petits mots gardés en minuscules lors de la remise en casse d'un titre904# tout-majuscules ; sigles usuels gardés en majuscules905_TITLE_SMALL = {"de", "du", "des", "la", "le", "les", "et", "en", "à", "a",906                "au", "aux", "d'", "l'", "of", "the", "and", "in", "for",907                "to", "or", "ou", "un", "une", "sur", "pour"}908_TITLE_ACRONYMS = {"rh", "ti", "it", "qa", "qc", "pab", "cpa", "cfa", "erp",909                   "sap", "crm", "sst", "cnc", "hvac", "rpa", "bi", "ia",910                   "ai", "vp", "pdg", "dg"}911912913def _smart_case(title: str) -> str:914    """Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés)."""915    words = title.lower().split()916    out = []917    for i, w in enumerate(words):918        base = w.strip("()[],.;:/")919        if base in _TITLE_ACRONYMS:920            out.append(w.replace(base, base.upper()))921        elif i > 0 and base in _TITLE_SMALL:922            out.append(w)923        else:924            out.append(w[:1].upper() + w[1:])925    return " ".join(out)926927928def clean_title(title: str, city: str = "") -> str:929    """Titre d'affichage : codes de réquisition retirés, suffixe « - <ville> »930    redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est931    jamais modifié — ceci alimente une colonne d'affichage distincte."""932    t = clean_text(title)933    if not t:934        return ""935    prev = None936    while prev != t:              # les codes peuvent être empilés937        prev = t938        t = _REQ_CODE.sub(" ", t).strip(" -–—|:")939        t = clean_text(t)940    # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de941    # l'offre : repéré sans accents, coupé sur le titre original942    if city:943        ck = re.escape(strip_accents(city.lower()))944        m = re.search(945            rf"\s*[-–—|,(]\s*(?:ville de\s+)?{ck}(?:\s*,\s*(?:qc|quebec))?\s*\)?\s*$",946            strip_accents(t.lower()))947        if m and m.start() > 0:948            t = clean_text(t[:m.start()]).strip(" -–—|,(")949    if len(t) > 8 and t == t.upper() and t != t.lower():950        t = _smart_case(t)951    return t or clean_text(title)952953954def categorize(title: str, description: str = "") -> str:955    """Titre -> catégorie interne (repli : début de description), ou "".956957    Le titre est essayé SEUL d'abord : la description commence souvent par le958    boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait959    la catégorie du poste.960    """961    for texte in (title, description[:300]):962        key = _key(texte or "")963        if not key.strip():964            continue965        for cat, pat in _CATEGORY_RULES:966            if re.search(pat, key):967                return cat968    return ""969
Files · 7cbdaeb71238ac59822297f2d65ab604be37eee9
Menu

Theme