# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/normalize.py # Rôle : Normalisation commune des offres (salaires, dates, mode/type # d'emploi, lieu, catégorie, exigences, séniorité) — jamais de # valeur inventée # Créé : 2026-08-17 Modifié : 2026-09-18 # ============================================================================= """Couche de normalisation commune appelée par JobPosting.finalize(). Philosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de mots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente reste absente (None / chaîne vide), on ne devine jamais. """ from __future__ import annotations import datetime as _dt import re import unicodedata __all__ = [ "strip_accents", "clean_text", "clean_html", "parse_salary", "sanitize_salary", "salary_from_text", "salary_to_yearly", "salary_to_hourly", "parse_date", "parse_work_mode", "parse_employment_type", "parse_location", "canonical_city", "is_quebec_location", "categorize", "detect_language", "extract_benefits", "clean_title", "extract_requirements", "parse_seniority", ] # --------------------------------------------------------------------------- # Texte # --------------------------------------------------------------------------- def strip_accents(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") def _key(s: str) -> str: """Minuscules sans accents — pour le matching de mots-clés.""" return strip_accents((s or "").lower()) def clean_text(s: str) -> str: """Espaces insécables -> espace, blancs réduits.""" s = (s or "").replace("\xa0", " ").replace(" ", " ").replace(" ", " ") return re.sub(r"[ \t]+", " ", s).strip() _BLOCK_TAGS = re.compile( r"]*>", re.I) # Résidu HTML après une passe : balise ouvrante plausible ou entité encore # encodée — signe d'un contenu doublement échappé. _HTML_LEFTOVER = re.compile( r"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\b|&(?:amp|lt|gt|nbsp|#\d+);", re.I) def clean_html(html: str) -> str: """HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré. Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin d'un vrai parseur utilisent BeautifulSoup eux-mêmes). """ if not html: return "" import html as _html s = html # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) : # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes). for _ in range(3): s = _html.unescape(s) s = re.sub(r"<(script|style)[^>]*>.*?", " ", s, flags=re.I | re.S) s = _BLOCK_TAGS.sub("\n", s) s = re.sub(r"<[^>]+>", " ", s) if not _HTML_LEFTOVER.search(s): break s = s.replace("\xa0", " ").replace(" ", " ") s = re.sub(r"[ \t]+", " ", s) s = re.sub(r" ?\n ?", "\n", s) return re.sub(r"\n{3,}", "\n\n", s).strip() # --------------------------------------------------------------------------- # Salaires — transparence salariale = différenciateur clé de Job·Ka # --------------------------------------------------------------------------- # Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de # salaire inventé, pas de « 1 $/h » venu d'un parsing raté). _SALARY_BOUNDS = { "hour": (14.0, 250.0), "day": (100.0, 2000.0), "week": (500.0, 10000.0), "biweek": (1000.0, 20000.0), "month": (1800.0, 45000.0), "year": (20000.0, 600000.0), } # Heures/an de référence pour les conversions (semaine de 40 h). _HOURS_PER_YEAR = 2080.0 _UNIT_PATTERNS = [ ("hour", r"(?:/|par|per|an?)\s*(?:h\b|hr\b|hre\b|heure|hour)|horaire|hourly"), ("year", r"(?:/|par|per|an?)\s*(?:an\b|année|annee|year|yr\b)|annuel|annual|yearly|per\s+annum"), ("month", r"(?:/|par|per)\s*(?:mois|month)|mensuel|monthly"), ("biweek", r"(?:aux|par|per)\s*(?:2|deux|two)\s*semaines|bi-?weekly|quinzaine"), ("week", r"(?:/|par|per)\s*(?:sem\b|semaine|week|wk\b)|hebdomadaire|weekly"), ("day", r"(?:/|par|per)\s*(?:jour|day)|journalier|daily"), ] _NUM_RE = re.compile(r"\d{1,3}(?:[   ,]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?[kK]?") def _parse_number(tok: str) -> float | None: tok = tok.strip() k = tok.lower().endswith("k") if k: tok = tok[:-1] # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales) tok = tok.replace(" ", " ").replace(" ", " ") if re.fullmatch(r"\d{1,3}(?:[ ,]\d{3})+(?:[.,]\d{1,2})?", tok): # séparateur de milliers : retirer espaces/virgules de groupe tok = re.sub(r"[ ,](?=\d{3}\b)", "", tok) tok = tok.replace(",", ".").replace(" ", "") try: v = float(tok) except ValueError: return None return v * 1000 if k else v def parse_salary(raw: str) -> tuple[float | None, float | None, str | None]: """Texte de salaire -> (min, max, unité) ou (None, None, None). Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire. """ s = clean_text(raw) if not s: return (None, None, None) key = _key(s) unit = None for u, pat in _UNIT_PATTERNS: if re.search(pat, key): unit = u break has_money = bool(re.search(r"\$|cad\b|dollar", key)) if not has_money and unit is None: return (None, None, None) nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)] nums = [n for n in nums if n is not None] if not nums: return (None, None, None) # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire) if unit is None: probe = max(nums) if probe < 300: unit = "hour" elif probe >= 18000: unit = "year" else: return (None, None, None) # zone ambiguë : ne pas deviner lo, hi = _SALARY_BOUNDS[unit] vals = sorted(n for n in nums if lo <= n <= hi) if not vals: return (None, None, None) return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit) _TO_YEAR = {"hour": _HOURS_PER_YEAR, "day": 260.0, "week": 52.0, "biweek": 26.0, "month": 12.0, "year": 1.0} def salary_to_yearly(value: float | None, unit: str | None) -> float | None: if value is None or unit not in _TO_YEAR: return None return round(value * _TO_YEAR[unit], 0) def salary_to_hourly(value: float | None, unit: str | None) -> float | None: y = salary_to_yearly(value, unit) return round(y / _HOURS_PER_YEAR, 2) if y is not None else None def sanitize_salary(lo: float | None, hi: float | None, unit: str | None ) -> tuple[float | None, float | None, str | None]: """Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS. Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la valeur sort des bornes de son unité, on tente une réinterprétation vers l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune unité plausible -> on jette (pas de salaire aberrant publié). """ if lo is None or unit not in _SALARY_BOUNDS: return (lo, hi, unit) b = _SALARY_BOUNDS[unit] if not (b[0] <= lo <= b[1]): for candidate in ("year", "hour"): cb = _SALARY_BOUNDS[candidate] if candidate != unit and cb[0] <= lo <= cb[1]: unit, b = candidate, cb break else: return (None, None, None) if hi is not None and not (b[0] <= hi <= b[1]): hi = None # max aberrant : on garde le min plausible seul if hi is not None and hi < lo: lo, hi = hi, lo return (lo, hi, unit) # Contextes où un montant en $ n'est PAS un salaire (financement, dépenses, # allocations…) — garde-fou de l'extraction depuis la description. _NOT_SALARY_CONTEXT = re.compile( r"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|" r"revenu[s]?\b|revenue|chiffre d affaires|budget|remboursement|" r"reimburse|expense|allocation|allowance|subvention|credit d impot|" r"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|" r"signing bonus|prime (?:a l embauche|de demarrage|de reference)|" r"million|milliard") def salary_from_text(text: str) -> tuple[float | None, float | None, str | None, str]: """Cherche une mention salariale dans un texte libre (description). Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas), ignore les contextes non salariaux (financement, remboursements…), puis parse la première phrase plausible. Retourne (min, max, unité, libellé). """ if not text or "$" not in text: return (None, None, None, "") for sentence in re.split(r"(? str | None: """Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None. Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates relatives (« il y a 3 jours », « Posted 30+ Days Ago »). """ if raw is None: return None today = today or _dt.date.today() if isinstance(raw, (int, float)): # epoch (Lever : millisecondes) ts = float(raw) if ts > 1e12: ts /= 1000.0 if 1e9 <= ts <= 4e9: return _dt.date.fromtimestamp(ts).isoformat() return None s = clean_text(str(raw)) if not s: return None key = _key(s) m = re.search(r"(\d{4})-(\d{2})-(\d{2})", s) # ISO (avec ou sans heure) if m: try: return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat() except ValueError: return None if _TODAY_RE.search(key): return today.isoformat() if _YESTERDAY_RE.search(key): return (today - _dt.timedelta(days=1)).isoformat() m = _REL_RE.search(key) if m: n = int(m.group(1) or m.group(3)) unit = (m.group(2) or m.group(4) or "").lower() days = n * (7 if unit.startswith(("sem", "week")) else 30 if unit.startswith(("moi", "month")) else 1) return (today - _dt.timedelta(days=days)).isoformat() # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 » 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) if m: mois = _MONTHS.get(m.group(2)) if mois: jour = int(m.group(1) or m.group(3) or 1) try: return _dt.date(int(m.group(4)), mois, jour).isoformat() except ValueError: return None m = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(\d{4})", s) # jj/mm/aaaa (usage QC) if m: d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) if mo > 12 and d <= 12: # en fait mm/jj/aaaa d, mo = mo, d try: return _dt.date(y, mo, d).isoformat() except ValueError: return None return None # --------------------------------------------------------------------------- # Mode de travail / type d'emploi # --------------------------------------------------------------------------- _MODE_RULES = [ ("teletravail", r"100\s*%\s*(?:teletravail|remote)|full(?:y)?[ -]remote|" r"teletravail|travail a distance|remote(?:\b|-first)|a distance"), ("hybride", r"hybrid|hybride|flexible?\s*(?:work|travail)"), ("presentiel", r"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)"), ] def parse_work_mode(raw: str) -> str | None: key = _key(raw) if not key: return None # « hybride » prime sur les mentions accessoires de télétravail if re.search(_MODE_RULES[1][1], key): return "hybride" for mode, pat in _MODE_RULES: if re.search(pat, key): return mode return None _TYPE_RULES = [ ("stage", r"\bstage\b|stagiaire|intern(?:ship)?\b|co-?op\b|alternance"), ("saisonnier", r"saisonn|seasonal"), ("contractuel", r"contrat\b|contract(?:uel|or)?\b|temporaire|temporary|terme|" r"fixed[- ]term|duree determinee|pigiste|freelance|consultant"), ("temps_partiel", r"temps[- ]partiel|part[- ]?time|partiel\b"), ("temps_plein", r"temps[- ]plein|full[- ]?time|permanent|regulier|" r"regular\b|duree indeterminee"), ] def parse_employment_type(raw: str) -> str | None: key = _key(raw) if not key: return None for typ, pat in _TYPE_RULES: if re.search(pat, key): return typ return None # --------------------------------------------------------------------------- # Lieu — extraction ville/région + garde-fou « offre au Québec » # --------------------------------------------------------------------------- # Villes québécoises fréquentes dans les libellés d'offres (clé sans accents). # Sert à la fois au parsing et au filtre is_quebec_location. _QC_CITIES = { "montreal": "Montréal", "quebec": "Québec", "ville de quebec": "Québec", "quebec city": "Québec", "laval": "Laval", "gatineau": "Gatineau", "longueuil": "Longueuil", "sherbrooke": "Sherbrooke", "saguenay": "Saguenay", "chicoutimi": "Saguenay", "levis": "Lévis", "trois-rivieres": "Trois-Rivières", "trois rivieres": "Trois-Rivières", "terrebonne": "Terrebonne", "brossard": "Brossard", "repentigny": "Repentigny", "boucherville": "Boucherville", "saint-jerome": "Saint-Jérôme", "drummondville": "Drummondville", "saint-jean-sur-richelieu": "Saint-Jean-sur-Richelieu", "granby": "Granby", "shawinigan": "Shawinigan", "joliette": "Joliette", "victoriaville": "Victoriaville", "rimouski": "Rimouski", "rouyn-noranda": "Rouyn-Noranda", "baie-comeau": "Baie-Comeau", "sept-iles": "Sept-Îles", "fermont": "Fermont", "val-d'or": "Val-d'Or", "val d'or": "Val-d'Or", "alma": "Alma", "sainte-foy": "Québec", "anjou": "Montréal", "dorval": "Dorval", "boisbriand": "Boisbriand", "blainville": "Blainville", "mirabel": "Mirabel", "saint-hyacinthe": "Saint-Hyacinthe", "beloeil": "Belœil", "chateauguay": "Châteauguay", "magog": "Magog", "thetford mines": "Thetford Mines", "saint-georges": "Saint-Georges", "riviere-du-loup": "Rivière-du-Loup", "matane": "Matane", "sorel-tracy": "Sorel-Tracy", "varennes": "Varennes", "vaudreuil-dorion": "Vaudreuil-Dorion", "salaberry-de-valleyfield": "Salaberry-de-Valleyfield", "kirkland": "Kirkland", "pointe-claire": "Pointe-Claire", "saint-laurent": "Montréal", "ville saint-laurent": "Montréal", "verdun": "Montréal", "westmount": "Westmount", "candiac": "Candiac", "la prairie": "La Prairie", "mont-tremblant": "Mont-Tremblant", "saint-bruno": "Saint-Bruno-de-Montarville", "bromont": "Bromont", "cowansville": "Cowansville", "rougemont": "Rougemont", "saint-augustin-de-desmaures": "Saint-Augustin-de-Desmaures", "l'ancienne-lorette": "L'Ancienne-Lorette", } _QC_MARKERS = re.compile( r"\bqc\b|\bquebec\b|\bqu[e]bec, canada\b|province de quebec", re.I) _POSTAL_RE = re.compile(r"\b([GHJ]\d[A-Z])\s*(\d[A-Z]\d)\b", re.I) # G/H/J = Québec # « Quebec » suivi d'un type de voie anglophone = nom de RUE, pas la province # (« 3401 Quebec St. Suite 8000, Denver, CO » — bug solmentalhealth 2026-09-14 : # offre de Denver publiée comme Québec/Capitale-Nationale). Neutralisé avant # tout matching de marqueur/ville. Le (?!-) protège les abréviations de saints # des libellés québécois légitimes (« CAN Quebec St-Hubert », « Ste-Thérèse »). _QUEBEC_STREET_RE = re.compile( r"\bquebec\s+(?:st|street|ave|avenue|blvd|boulevard|rd|road|dr|drive|" r"ct|court|ln|lane|way|pl|place|hwy|highway|pkwy|parkway)\b\.?(?!-)") # Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes # dans les flux pancanadiens/mondiaux — jamais des villes d'offres # québécoises : à rejeter lors du parsing de lieu (clés sans accents). _NON_QC_CITIES = frozenset({ "toronto", "ottawa", "vancouver", "calgary", "edmonton", "winnipeg", "mississauga", "brampton", "hamilton", "kitchener", "waterloo", "halifax", "dartmouth", "victoria", "saskatoon", "regina", "st. john's", "st johns", "saint john", "fredericton", "moncton", "charlottetown", "london", "markham", "vaughan", "richmond hill", "oakville", "burlington", "oshawa", "whitby", "ajax", "pickering", "guelph", "kingston", "barrie", "kelowna", "kamloops", "nanaimo", "abbotsford", "surrey", "burnaby", "coquitlam", "langley", "north york", "scarborough", "etobicoke", "north vancouver", "milton", "cambridge", "st. catharines", "niagara falls", "thunder bay", "sudbury", "greater sudbury", "sault ste. marie", "red deer", "lethbridge", "medicine hat", "grande prairie", "fort mcmurray", "yellowknife", "whitehorse", "iqaluit", "can", "canada", "ontario", "alberta", "british columbia", "manitoba", "saskatchewan", "nova scotia", "new brunswick", "new york", "new york city", "san francisco", "boston", "chicago", "seattle", "austin", "denver", "los angeles", "miami", "atlanta", "dallas", "houston", "philadelphia", "washington", "phoenix", "portland", "paris", "londres", "amsterdam", "berlin", "munich", "dublin", "madrid", "barcelona", "barcelone", "lisbon", "lisbonne", "sydney", "melbourne", "singapore", "singapour", "tokyo", "bangalore", "bengaluru", "mumbai", "mexico city", "sao paulo", "bogota", "buenos aires", "las vegas", "orlando", "nashville", "macau", }) def canonical_city(raw: str) -> str: """Canonicalise un nom de ville (« Montreal » -> « Montréal », « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé.""" s = clean_text(raw) if not s: return "" key = _key(s) exact = _QC_CITIES.get(key) if exact: return exact for ck, city in _QC_CITIES.items(): if re.fullmatch(re.escape(ck), key): return city return s def parse_location(raw: str) -> dict: """Libellé de lieu -> {city, region, postal_code} (champs possiblement vides). Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ». """ s = clean_text(raw) out = {"city": "", "region": "", "postal_code": ""} if not s: return out m = _POSTAL_RE.search(s) if m: out["postal_code"] = f"{m.group(1).upper()} {m.group(2).upper()}" key = _QUEBEC_STREET_RE.sub(" ", _key(s)) for ck, city in _QC_CITIES.items(): if re.search(rf"(? bool: """Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens).""" key = _QUEBEC_STREET_RE.sub(" ", _key(clean_text(raw))) if not key: return False if key in _NON_QC_CITIES: # libellé = ville canadienne/monde hors QC return False if _QC_MARKERS.search(key): return True m = _POSTAL_RE.search(raw) if m and m.group(1)[0].upper() in "GHJ": return True if _NON_QC_REGION_RE.search(key): # « Kirkland, WA » : homonyme hors QC return False return any(re.search(rf"(? str: """Langue du texte de l'offre : « fr », « en », « bilingue », ou "". Comptage de mots-outils sur le début du texte — déterministe, jamais de valeur inventée : texte trop court ou ambigu -> "". """ text = _key(f"{title}\n{description or ''}"[:4000]) if len(text) < 30: return "" fr = len(_FR_STOPWORDS.findall(text)) en = len(_EN_STOPWORDS.findall(text)) total = fr + en if total < 5: return "" # les deux langues nettement présentes -> offre bilingue if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35: return "bilingue" if fr > en: return "fr" if en > fr: return "en" return "" # --------------------------------------------------------------------------- # Avantages — extraction des blocs « Avantages / Benefits » des descriptions # --------------------------------------------------------------------------- _BENEFITS_HEADING = re.compile( r"^\s*(?:les\s+|nos\s+|our\s+|tes\s+|vos\s+)?(?:avantages(?:\s+sociaux|" r"\s+offerts|\s+et\s+conditions)?|" r"benefits(?:\s+&\s+perks|\s+offered)?|perks(?:\s+(?:and|&)\s+benefits)?|" r"ce que (?:nous|l'on|on) (?:t'|te\s+|vous\s+)?offr(?:ons|e)|" r"nous (?:t'|te\s+|vous\s+)?offrons|on (?:t'|te\s+|vous\s+)offre|" r"what we offer|what'?s in it for you|why join us|why work (?:with|for) us|" r"pourquoi (?:nous rejoindre|nous choisir|te joindre a nous|" r"vous joindre a nous|travailler (?:chez|avec) nous)|we offer)" r"\s*:?\s*$", re.I) _BENEFIT_LINE_MAX = 120 def extract_benefits(description: str) -> list[str]: """Liste d'avantages depuis un bloc structuré de la description. Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne, puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la première ligne longue ou au prochain titre. Max 12 items — si le bloc ne ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près). """ if not description: return [] lines = description.split("\n") for i, line in enumerate(lines): if not _BENEFITS_HEADING.match(strip_accents(line)): continue items: list[str] = [] for nxt in lines[i + 1:]: t = clean_text(nxt).lstrip("•-–—*·◦▪ ").strip() if not t: if items: break # fin du bloc (ligne vide après les puces) continue if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match( strip_accents(t)): break # un « titre » de section suivant (courte ligne sans ponctuation # finale, capitalisée) termine aussi le bloc if items and len(t.split()) <= 4 and t.endswith(":"): break items.append(t.rstrip(" ;,.").strip()) if len(items) >= 12: break if len(items) >= 2: return items return [] # --------------------------------------------------------------------------- # Exigences — expérience, scolarité, langues (depuis la description) # --------------------------------------------------------------------------- # Années d'expérience : « 3 à 5 ans d'expérience », « minimum de 5 ans », # « 5+ years of experience », « two (2) years of relevant experience »… _EXP_PATTERNS = [ # « X à Y ans/years … expérience » (fourchette) — on garde le MIN re.compile(r"(\d{1,2})\s*(?:a|-|to)\s*\d{1,2}\s*(?:ans?|years?)" r"[^.\n]{0,40}?(?:d )?experience", re.I), # « X ans/années/years (+) d'expérience » re.compile(r"(\d{1,2})\s*\+?\s*(?:ans?|annees?|years?)" r"[^.\n]{0,40}?(?:d )?experience", re.I), # « expérience … de X ans » / « experience of X+ years » re.compile(r"experience[^.\n]{0,50}?(?:de|of|d au moins|minimum(?: de)?|" r"at least)\s*(\d{1,2})\s*\+?\s*(?:ans?|annees?|years?)", re.I), # « minimum de X ans » / « at least X years » (sans le mot expérience) re.compile(r"(?:minimum(?: de)?|au moins|at least)\s*(\d{1,2})\s*" r"(?:ans?|annees?|years?)\b", re.I), ] # Scolarité : du plus élevé au moins élevé — on retourne le PLUS BAS exigé # quand plusieurs sont acceptés (« bac ou maîtrise » -> baccalauréat). # Les sigles QC (DEP/DEC/AEC/DES) ne comptent que dans un contexte scolaire # (« dec. » est aussi l'abréviation de décembre). _EDU_SCHOOL_CONTEXT = re.compile( r"diplome|dipl\.|etudes|formation|scolarite|degree|diploma|education|" r"detenir|detention|obtenu|completed?\b|graduate", re.I) _EDU_LEVELS = [ # (niveau canonique, motif, sigle nécessitant le contexte ?) ("secondaire", r"diplome d etudes secondaires|\bdes\b|secondaire (?:5|v)\b|" r"high school diploma|secondary school diploma", True), ("DEP", r"diplome d etudes professionnelles|\bdep\b|" r"vocational diploma", True), ("AEC", r"attestation d etudes collegiales|\baec\b", True), ("DEC", r"diplome d etudes collegiales|\bdec\b|techniques? collegiales?|" r"college diploma|cegep", True), ("certificat", r"certificat universitaire|university certificate", False), ("baccalauréat", r"baccalaureat|bachelor|\bbacc\b|bac universitaire|" r"\bbac\b (?:en|in)\b|undergraduate degree|" r"b\.?\s?(?:sc|ing|a\.?a|com)\b", False), ("maîtrise", r"maitrise|master s degree|masters degree|\bmba\b|" r"m\.?\s?sc\.?\b|graduate degree", False), ("doctorat", r"doctorat|\bphd\b|ph\.?\s?d\b|doctoral", False), ] # Langues exigées : mention explicite d'exigence (pas la langue du TEXTE — # ça, c'est detect_language). « bilingue », « anglais requis », « fluent in # English », « maîtrise du français »… _LANG_BILINGUAL = re.compile(r"bilingu", re.I) _LANG_REQ_FR = re.compile( r"francais[^.\n]{0,30}?(?:requis|exige|obligatoire|essentiel|" r"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|" r"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |du )?" r"francais|french (?:is )?(?:required|mandatory|essential)|" r"fluent in french|fluency in french|french proficiency", re.I) _LANG_REQ_EN = re.compile( r"anglais[^.\n]{0,30}?(?:requis|exige|obligatoire|essentiel|" r"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|" r"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |de l )?" r"anglais|english (?:is )?(?:required|mandatory|essential)|" r"fluent in english|fluency in english|english proficiency", re.I) def extract_requirements(title: str, description: str) -> dict: """Exigences structurées depuis le texte de l'offre — clés présentes seulement quand l'information est explicite (jamais de valeur inventée) : - ``experience_years`` : années d'expérience minimales exigées (int) - ``education`` : plus bas diplôme exigé (secondaire, DEP, AEC, DEC, certificat, baccalauréat, maîtrise, doctorat) - ``languages`` : langues exigées (["français"], ["anglais"], …) """ out: dict = {} text = _key(f"{title or ''}\n{description or ''}"[:8000]) text = re.sub(r"[’']", " ", text) # « d'expérience » -> « d experience » if len(text) < 20: return out years: list[int] = [] for pat in _EXP_PATTERNS: for m in pat.finditer(text): try: n = int(m.group(1)) except (TypeError, ValueError): continue if 1 <= n <= 30: # bornes de plausibilité years.append(n) if years: out["experience_years"] = min(years) # l'exigence MINIMALE for level, pat, needs_ctx in _EDU_LEVELS: for m in re.finditer(pat, text): if needs_ctx: window = text[max(0, m.start() - 120):m.end() + 120] if not _EDU_SCHOOL_CONTEXT.search(window): continue out["education"] = level break if "education" in out: break # plus bas niveau exigé trouvé langs: list[str] = [] if _LANG_BILINGUAL.search(text): langs = ["français", "anglais"] else: if _LANG_REQ_FR.search(text): langs.append("français") if _LANG_REQ_EN.search(text): langs.append("anglais") if langs: out["languages"] = langs return out # --------------------------------------------------------------------------- # Séniorité — stage | junior | intermediaire | senior | direction # --------------------------------------------------------------------------- _SENIORITY_TITLE = [ ("stage", r"\bstagiaire\b|\bstage\b|\bintern(?:ship)?\b|\bco-?op\b|" r"alternance|etudiant"), ("direction", r"directeur|directrice|\bdirector\b|vice[- ]?president|" r"\bvp\b|\bpdg\b|\bceo\b|\bcfo\b|\bcto\b|\bcoo\b|" r"chef de (?:service|departement|division|la direction)|" r"head of|\bdg\b|gestionnaire de (?:service|departement)"), ("senior", r"\bsenior\b|\bsr\.?\b|\bprincipal(?:e)?\b|\blead\b|" r"\bexpert(?:e)?\b|chef d equipe|team lead|superviseur|" r"\bniveau (?:3|iii)\b|\biii\b"), ("junior", r"\bjunior\b|\bjr\.?\b|debutant|entry[- ]level|" r"\bniveau (?:1|i)\b|premier emploi|releve"), ("intermediaire", r"intermediaire|intermediate|\bniveau (?:2|ii)\b|\bii\b"), ] # libellés d'expérience des ATS (SmartRecruiters/LinkedIn-style) -> séniorité _SENIORITY_ATS = [ ("stage", r"internship|stage"), ("direction", r"director|executive|direction"), ("senior", r"mid[- ]?senior|senior"), ("intermediaire", r"associate|intermediaire|intermediate|mid[- ]?level"), ("junior", r"entry|junior|debutant"), ] def parse_seniority(title: str, requirements: dict | None = None, employment_type: str | None = None) -> str | None: """Niveau de séniorité du poste, ou None si aucun signal explicite. Ordre des signaux : type d'emploi « stage », titre du poste, libellé d'expérience fourni par l'ATS, puis années d'expérience exigées. """ if employment_type == "stage": return "stage" key = _key(title or "") if key: for level, pat in _SENIORITY_TITLE: if re.search(pat, key): return level req = requirements or {} label = _key(str(req.get("experience", ""))) if label and "not applicable" not in label: for level, pat in _SENIORITY_ATS: if re.search(pat, label): return level years = req.get("experience_years") if isinstance(years, (int, float)): if years <= 2: return "junior" if years <= 4: return "intermediaire" return "senior" return None # --------------------------------------------------------------------------- # Titre d'affichage — casse propre, codes de réquisition et suffixes retirés # --------------------------------------------------------------------------- # codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »… _REQ_CODE = re.compile( # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour # ne pas manger une année « 2026 - Programme d'été ») r"^\s*[\[(]?(?:req(?:uisition)?|jr|job)[\s#:-]*\d{3,10}[\])]?\s*[-–—:|]\s*|" r"^\s*[\[(]?\d{5,10}[\])]?\s*[-–—:|]\s*|" # en queue : « Titre - R-12345 », « Titre (JOB 004521) » r"\s*[-–—|(]\s*(?:req(?:uisition)?|job|poste)?\s*#?\s*(?:[A-Z]{1,4}-?)?\d{4,10}\s*[)\]]?\s*$|" # au milieu : « Titre [R-12345] suite » r"\s*[\[(](?:R|REQ|JR|JOB)[-_ ]?\d{3,10}[\])]\s*", re.I) # petits mots gardés en minuscules lors de la remise en casse d'un titre # tout-majuscules ; sigles usuels gardés en majuscules _TITLE_SMALL = {"de", "du", "des", "la", "le", "les", "et", "en", "à", "a", "au", "aux", "d'", "l'", "of", "the", "and", "in", "for", "to", "or", "ou", "un", "une", "sur", "pour"} _TITLE_ACRONYMS = {"rh", "ti", "it", "qa", "qc", "pab", "cpa", "cfa", "erp", "sap", "crm", "sst", "cnc", "hvac", "rpa", "bi", "ia", "ai", "vp", "pdg", "dg"} def _smart_case(title: str) -> str: """Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés).""" words = title.lower().split() out = [] for i, w in enumerate(words): base = w.strip("()[],.;:/") if base in _TITLE_ACRONYMS: out.append(w.replace(base, base.upper())) elif i > 0 and base in _TITLE_SMALL: out.append(w) else: out.append(w[:1].upper() + w[1:]) return " ".join(out) def clean_title(title: str, city: str = "") -> str: """Titre d'affichage : codes de réquisition retirés, suffixe « - » redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est jamais modifié — ceci alimente une colonne d'affichage distincte.""" t = clean_text(title) if not t: return "" prev = None while prev != t: # les codes peuvent être empilés prev = t t = _REQ_CODE.sub(" ", t).strip(" -–—|:") t = clean_text(t) # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de # l'offre : repéré sans accents, coupé sur le titre original if city: ck = re.escape(strip_accents(city.lower())) m = re.search( rf"\s*[-–—|,(]\s*(?:ville de\s+)?{ck}(?:\s*,\s*(?:qc|quebec))?\s*\)?\s*$", strip_accents(t.lower())) if m and m.start() > 0: t = clean_text(t[:m.start()]).strip(" -–—|,(") if len(t) > 8 and t == t.upper() and t != t.lower(): t = _smart_case(t) return t or clean_text(title) def categorize(title: str, description: str = "") -> str: """Titre -> catégorie interne (repli : début de description), ou "". Le titre est essayé SEUL d'abord : la description commence souvent par le boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait la catégorie du poste. """ for texte in (title, description[:300]): key = _key(texte or "") if not key.strip(): continue for cat, pat in _CATEGORY_RULES: if re.search(pat, key): return cat return ""