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