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) — jamais de valeur inventée8# Créé : 2026-08-17 Modifié : 2026-08-189# =============================================================================10"""Couche de normalisation commune appelée par JobPosting.finalize().1112Philosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de13mots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente14reste absente (None / chaîne vide), on ne devine jamais.15"""16from __future__ import annotations1718import datetime as _dt19import re20import unicodedata2122__all__ = [23 "strip_accents", "clean_text", "clean_html", "parse_salary",24 "sanitize_salary", "salary_from_text", "salary_to_yearly",25 "salary_to_hourly", "parse_date", "parse_work_mode",26 "parse_employment_type", "parse_location", "canonical_city",27 "is_quebec_location", "categorize", "detect_language",28 "extract_benefits", "clean_title",29]303132# ---------------------------------------------------------------------------33# Texte34# ---------------------------------------------------------------------------3536def strip_accents(s: str) -> str:37 return "".join(c for c in unicodedata.normalize("NFD", s)38 if unicodedata.category(c) != "Mn")394041def _key(s: str) -> str:42 """Minuscules sans accents — pour le matching de mots-clés."""43 return strip_accents((s or "").lower())444546def clean_text(s: str) -> str:47 """Espaces insécables -> espace, blancs réduits."""48 s = (s or "").replace("\xa0", " ").replace(" ", " ").replace(" ", " ")49 return re.sub(r"[ \t]+", " ", s).strip()505152_BLOCK_TAGS = re.compile(53 r"</?(?:p|div|br|li|ul|ol|h[1-6]|tr|table|section|article)[^>]*>", re.I)5455# Résidu HTML après une passe : balise ouvrante plausible ou entité encore56# encodée — signe d'un contenu doublement échappé.57_HTML_LEFTOVER = re.compile(58 r"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\b|&(?:amp|lt|gt|nbsp|#\d+);",59 re.I)606162def clean_html(html: str) -> str:63 """HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.6465 Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin66 d'un vrai parseur utilisent BeautifulSoup eux-mêmes).67 """68 if not html:69 return ""70 import html as _html71 s = html72 # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :73 # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).74 for _ in range(3):75 s = _html.unescape(s)76 s = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", s, flags=re.I | re.S)77 s = _BLOCK_TAGS.sub("\n", s)78 s = re.sub(r"<[^>]+>", " ", s)79 if not _HTML_LEFTOVER.search(s):80 break81 s = s.replace("\xa0", " ").replace(" ", " ")82 s = re.sub(r"[ \t]+", " ", s)83 s = re.sub(r" ?\n ?", "\n", s)84 return re.sub(r"\n{3,}", "\n\n", s).strip()858687# ---------------------------------------------------------------------------88# Salaires — transparence salariale = différenciateur clé de Job·Ka89# ---------------------------------------------------------------------------9091# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de92# salaire inventé, pas de « 1 $/h » venu d'un parsing raté).93_SALARY_BOUNDS = {94 "hour": (14.0, 250.0),95 "day": (100.0, 2000.0),96 "week": (500.0, 10000.0),97 "biweek": (1000.0, 20000.0),98 "month": (1800.0, 45000.0),99 "year": (20000.0, 600000.0),100}101102# Heures/an de référence pour les conversions (semaine de 40 h).103_HOURS_PER_YEAR = 2080.0104105_UNIT_PATTERNS = [106 ("hour", r"(?:/|par|per|an?)\s*(?:h\b|hr\b|hre\b|heure|hour)|horaire|hourly"),107 ("year", r"(?:/|par|per|an?)\s*(?:an\b|année|annee|year|yr\b)|annuel|annual|yearly|per\s+annum"),108 ("month", r"(?:/|par|per)\s*(?:mois|month)|mensuel|monthly"),109 ("biweek", r"(?:aux|par|per)\s*(?:2|deux|two)\s*semaines|bi-?weekly|quinzaine"),110 ("week", r"(?:/|par|per)\s*(?:sem\b|semaine|week|wk\b)|hebdomadaire|weekly"),111 ("day", r"(?:/|par|per)\s*(?:jour|day)|journalier|daily"),112]113114_NUM_RE = re.compile(r"\d{1,3}(?:[ ,]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?[kK]?")115116117def _parse_number(tok: str) -> float | None:118 tok = tok.strip()119 k = tok.lower().endswith("k")120 if k:121 tok = tok[:-1]122 # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales)123 tok = tok.replace(" ", " ").replace(" ", " ")124 if re.fullmatch(r"\d{1,3}(?:[ ,]\d{3})+(?:[.,]\d{1,2})?", tok):125 # séparateur de milliers : retirer espaces/virgules de groupe126 tok = re.sub(r"[ ,](?=\d{3}\b)", "", tok)127 tok = tok.replace(",", ".").replace(" ", "")128 try:129 v = float(tok)130 except ValueError:131 return None132 return v * 1000 if k else v133134135def parse_salary(raw: str) -> tuple[float | None, float | None, str | None]:136 """Texte de salaire -> (min, max, unité) ou (None, None, None).137138 Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche139 des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire.140 """141 s = clean_text(raw)142 if not s:143 return (None, None, None)144 key = _key(s)145146 unit = None147 for u, pat in _UNIT_PATTERNS:148 if re.search(pat, key):149 unit = u150 break151152 has_money = bool(re.search(r"\$|cad\b|dollar", key))153 if not has_money and unit is None:154 return (None, None, None)155156 nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)]157 nums = [n for n in nums if n is not None]158 if not nums:159 return (None, None, None)160161 # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire)162 if unit is None:163 probe = max(nums)164 if probe < 300:165 unit = "hour"166 elif probe >= 18000:167 unit = "year"168 else:169 return (None, None, None) # zone ambiguë : ne pas deviner170171 lo, hi = _SALARY_BOUNDS[unit]172 vals = sorted(n for n in nums if lo <= n <= hi)173 if not vals:174 return (None, None, None)175 return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit)176177178_TO_YEAR = {"hour": _HOURS_PER_YEAR, "day": 260.0, "week": 52.0,179 "biweek": 26.0, "month": 12.0, "year": 1.0}180181182def salary_to_yearly(value: float | None, unit: str | None) -> float | None:183 if value is None or unit not in _TO_YEAR:184 return None185 return round(value * _TO_YEAR[unit], 0)186187188def salary_to_hourly(value: float | None, unit: str | None) -> float | None:189 y = salary_to_yearly(value, unit)190 return round(y / _HOURS_PER_YEAR, 2) if y is not None else None191192193def sanitize_salary(lo: float | None, hi: float | None, unit: str | None194 ) -> tuple[float | None, float | None, str | None]:195 """Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.196197 Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour198 un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la199 valeur sort des bornes de son unité, on tente une réinterprétation vers200 l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune201 unité plausible -> on jette (pas de salaire aberrant publié).202 """203 if lo is None or unit not in _SALARY_BOUNDS:204 return (lo, hi, unit)205 b = _SALARY_BOUNDS[unit]206 if not (b[0] <= lo <= b[1]):207 for candidate in ("year", "hour"):208 cb = _SALARY_BOUNDS[candidate]209 if candidate != unit and cb[0] <= lo <= cb[1]:210 unit, b = candidate, cb211 break212 else:213 return (None, None, None)214 if hi is not None and not (b[0] <= hi <= b[1]):215 hi = None # max aberrant : on garde le min plausible seul216 if hi is not None and hi < lo:217 lo, hi = hi, lo218 return (lo, hi, unit)219220221# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,222# allocations…) — garde-fou de l'extraction depuis la description.223_NOT_SALARY_CONTEXT = re.compile(224 r"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|"225 r"revenu[s]?\b|revenue|chiffre d affaires|budget|remboursement|"226 r"reimburse|expense|allocation|allowance|subvention|credit d impot|"227 r"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|"228 r"signing bonus|prime (?:a l embauche|de demarrage|de reference)|"229 r"million|milliard")230231232def salary_from_text(text: str) -> tuple[float | None, float | None,233 str | None, str]:234 """Cherche une mention salariale dans un texte libre (description).235236 Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),237 ignore les contextes non salariaux (financement, remboursements…), puis238 parse la première phrase plausible. Retourne (min, max, unité, libellé).239 """240 if not text or "$" not in text:241 return (None, None, None, "")242 for sentence in re.split(r"(?<!\d)\.(?!\d)|\n|;", text):243 if "$" not in sentence or not re.search(r"\d", sentence):244 continue245 frag = clean_text(sentence)[:160]246 if _NOT_SALARY_CONTEXT.search(_key(frag)):247 continue248 lo, hi, unit = parse_salary(frag)249 if lo is not None:250 return (lo, hi, unit, frag)251 return (None, None, None, "")252253254# ---------------------------------------------------------------------------255# Dates — ISO 8601, jamais de date inventée256# ---------------------------------------------------------------------------257258_MONTHS = {259 "janvier": 1, "jan": 1, "january": 1,260 "fevrier": 2, "fev": 2, "february": 2, "feb": 2,261 "mars": 3, "mar": 3, "march": 3,262 "avril": 4, "avr": 4, "april": 4, "apr": 4,263 "mai": 5, "may": 5,264 "juin": 6, "june": 6, "jun": 6,265 "juillet": 7, "juil": 7, "july": 7, "jul": 7,266 "aout": 8, "august": 8, "aug": 8,267 "septembre": 9, "sept": 9, "sep": 9, "september": 9,268 "octobre": 10, "oct": 10, "october": 10,269 "novembre": 11, "nov": 11, "november": 11,270 "decembre": 12, "dec": 12, "december": 12,271}272273_REL_RE = re.compile(274 r"(?:il y a|posted)\s+(\d+)\+?\s*(jour|day|semaine|week|mois|month)|"275 r"(\d+)\+?\s*(jour|day|semaine|week|mois|month)s?\s+ago", re.I)276_TODAY_RE = re.compile(r"aujourd'?hui|today|posted today|date du jour", re.I)277_YESTERDAY_RE = re.compile(r"hier|yesterday", re.I)278279280def parse_date(raw, today: _dt.date | None = None) -> str | None:281 """Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None.282283 Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats284 textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates285 relatives (« il y a 3 jours », « Posted 30+ Days Ago »).286 """287 if raw is None:288 return None289 today = today or _dt.date.today()290291 if isinstance(raw, (int, float)): # epoch (Lever : millisecondes)292 ts = float(raw)293 if ts > 1e12:294 ts /= 1000.0295 if 1e9 <= ts <= 4e9:296 return _dt.date.fromtimestamp(ts).isoformat()297 return None298299 s = clean_text(str(raw))300 if not s:301 return None302 key = _key(s)303304 m = re.search(r"(\d{4})-(\d{2})-(\d{2})", s) # ISO (avec ou sans heure)305 if m:306 try:307 return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()308 except ValueError:309 return None310311 if _TODAY_RE.search(key):312 return today.isoformat()313 if _YESTERDAY_RE.search(key):314 return (today - _dt.timedelta(days=1)).isoformat()315316 m = _REL_RE.search(key)317 if m:318 n = int(m.group(1) or m.group(3))319 unit = (m.group(2) or m.group(4) or "").lower()320 days = n * (7 if unit.startswith(("sem", "week"))321 else 30 if unit.startswith(("moi", "month")) else 1)322 return (today - _dt.timedelta(days=days)).isoformat()323324 # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 »325 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)326 if m:327 mois = _MONTHS.get(m.group(2))328 if mois:329 jour = int(m.group(1) or m.group(3) or 1)330 try:331 return _dt.date(int(m.group(4)), mois, jour).isoformat()332 except ValueError:333 return None334335 m = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(\d{4})", s) # jj/mm/aaaa (usage QC)336 if m:337 d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))338 if mo > 12 and d <= 12: # en fait mm/jj/aaaa339 d, mo = mo, d340 try:341 return _dt.date(y, mo, d).isoformat()342 except ValueError:343 return None344 return None345346347# ---------------------------------------------------------------------------348# Mode de travail / type d'emploi349# ---------------------------------------------------------------------------350351_MODE_RULES = [352 ("teletravail", r"100\s*%\s*(?:teletravail|remote)|full(?:y)?[ -]remote|"353 r"teletravail|travail a distance|remote(?:\b|-first)|a distance"),354 ("hybride", r"hybrid|hybride|flexible?\s*(?:work|travail)"),355 ("presentiel", r"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)"),356]357358359def parse_work_mode(raw: str) -> str | None:360 key = _key(raw)361 if not key:362 return None363 # « hybride » prime sur les mentions accessoires de télétravail364 if re.search(_MODE_RULES[1][1], key):365 return "hybride"366 for mode, pat in _MODE_RULES:367 if re.search(pat, key):368 return mode369 return None370371372_TYPE_RULES = [373 ("stage", r"\bstage\b|stagiaire|intern(?:ship)?\b|co-?op\b|alternance"),374 ("saisonnier", r"saisonn|seasonal"),375 ("contractuel", r"contrat\b|contract(?:uel|or)?\b|temporaire|temporary|terme|"376 r"fixed[- ]term|duree determinee|pigiste|freelance|consultant"),377 ("temps_partiel", r"temps[- ]partiel|part[- ]?time|partiel\b"),378 ("temps_plein", r"temps[- ]plein|full[- ]?time|permanent|regulier|"379 r"regular\b|duree indeterminee"),380]381382383def parse_employment_type(raw: str) -> str | None:384 key = _key(raw)385 if not key:386 return None387 for typ, pat in _TYPE_RULES:388 if re.search(pat, key):389 return typ390 return None391392393# ---------------------------------------------------------------------------394# Lieu — extraction ville/région + garde-fou « offre au Québec »395# ---------------------------------------------------------------------------396397# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents).398# Sert à la fois au parsing et au filtre is_quebec_location.399_QC_CITIES = {400 "montreal": "Montréal", "quebec": "Québec", "ville de quebec": "Québec",401 "quebec city": "Québec", "laval": "Laval", "gatineau": "Gatineau",402 "longueuil": "Longueuil", "sherbrooke": "Sherbrooke",403 "saguenay": "Saguenay", "chicoutimi": "Saguenay",404 "levis": "Lévis", "trois-rivieres": "Trois-Rivières",405 "trois rivieres": "Trois-Rivières", "terrebonne": "Terrebonne",406 "brossard": "Brossard", "repentigny": "Repentigny",407 "boucherville": "Boucherville", "saint-jerome": "Saint-Jérôme",408 "drummondville": "Drummondville", "saint-jean-sur-richelieu":409 "Saint-Jean-sur-Richelieu", "granby": "Granby", "shawinigan": "Shawinigan",410 "joliette": "Joliette", "victoriaville": "Victoriaville",411 "rimouski": "Rimouski", "rouyn-noranda": "Rouyn-Noranda",412 "baie-comeau": "Baie-Comeau", "sept-iles": "Sept-Îles",413 "val-d'or": "Val-d'Or", "val d'or": "Val-d'Or", "alma": "Alma",414 "sainte-foy": "Québec", "anjou": "Montréal", "dorval": "Dorval",415 "boisbriand": "Boisbriand", "blainville": "Blainville",416 "mirabel": "Mirabel", "saint-hyacinthe": "Saint-Hyacinthe",417 "beloeil": "Belœil", "chateauguay": "Châteauguay", "magog": "Magog",418 "thetford mines": "Thetford Mines", "saint-georges": "Saint-Georges",419 "riviere-du-loup": "Rivière-du-Loup", "matane": "Matane",420 "sorel-tracy": "Sorel-Tracy", "varennes": "Varennes",421 "vaudreuil-dorion": "Vaudreuil-Dorion", "salaberry-de-valleyfield":422 "Salaberry-de-Valleyfield", "kirkland": "Kirkland",423 "pointe-claire": "Pointe-Claire", "saint-laurent": "Montréal",424 "ville saint-laurent": "Montréal", "verdun": "Montréal",425 "westmount": "Westmount", "candiac": "Candiac", "la prairie": "La Prairie",426 "mont-tremblant": "Mont-Tremblant", "saint-bruno": "Saint-Bruno-de-Montarville",427 "bromont": "Bromont", "cowansville": "Cowansville",428 "saint-augustin-de-desmaures": "Saint-Augustin-de-Desmaures",429 "l'ancienne-lorette": "L'Ancienne-Lorette",430}431432_QC_MARKERS = re.compile(433 r"\bqc\b|\bquebec\b|\bqu[e]bec, canada\b|province de quebec", re.I)434_POSTAL_RE = re.compile(r"\b([GHJ]\d[A-Z])\s*(\d[A-Z]\d)\b", re.I) # G/H/J = Québec435436# Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes437# dans les flux pancanadiens/mondiaux — jamais des villes d'offres438# québécoises : à rejeter lors du parsing de lieu (clés sans accents).439_NON_QC_CITIES = frozenset({440 "toronto", "ottawa", "vancouver", "calgary", "edmonton", "winnipeg",441 "mississauga", "brampton", "hamilton", "kitchener", "waterloo", "halifax",442 "dartmouth", "victoria", "saskatoon", "regina", "st. john's", "st johns",443 "saint john", "fredericton", "moncton", "charlottetown", "london",444 "markham", "vaughan", "richmond hill", "oakville", "burlington", "oshawa",445 "whitby", "ajax", "pickering", "guelph", "kingston", "barrie",446 "kelowna", "kamloops", "nanaimo", "abbotsford", "surrey", "burnaby",447 "coquitlam", "langley", "north york", "scarborough", "etobicoke",448 "north vancouver", "milton", "cambridge", "st. catharines",449 "niagara falls", "thunder bay", "sudbury", "greater sudbury",450 "sault ste. marie", "red deer", "lethbridge", "medicine hat",451 "grande prairie", "fort mcmurray", "yellowknife", "whitehorse", "iqaluit",452 "can", "canada", "ontario", "alberta", "british columbia", "manitoba",453 "saskatchewan", "nova scotia", "new brunswick",454 "new york", "new york city", "san francisco", "boston", "chicago",455 "seattle", "austin", "denver", "los angeles", "miami", "atlanta",456 "dallas", "houston", "philadelphia", "washington", "phoenix", "portland",457 "paris", "londres", "amsterdam", "berlin", "munich", "dublin", "madrid",458 "barcelona", "barcelone", "lisbon", "lisbonne", "sydney", "melbourne",459 "singapore", "singapour", "tokyo", "bangalore", "bengaluru", "mumbai",460 "mexico city", "sao paulo", "bogota", "buenos aires", "las vegas",461 "orlando", "nashville", "macau",462})463464465def canonical_city(raw: str) -> str:466 """Canonicalise un nom de ville (« Montreal » -> « Montréal »,467 « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé."""468 s = clean_text(raw)469 if not s:470 return ""471 key = _key(s)472 exact = _QC_CITIES.get(key)473 if exact:474 return exact475 for ck, city in _QC_CITIES.items():476 if re.fullmatch(re.escape(ck), key):477 return city478 return s479480481def parse_location(raw: str) -> dict:482 """Libellé de lieu -> {city, region, postal_code} (champs possiblement vides).483484 Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ».485 """486 s = clean_text(raw)487 out = {"city": "", "region": "", "postal_code": ""}488 if not s:489 return out490 m = _POSTAL_RE.search(s)491 if m:492 out["postal_code"] = f"{m.group(1).upper()} {m.group(2).upper()}"493 key = _key(s)494 for ck, city in _QC_CITIES.items():495 if re.search(rf"(?<![a-z]){re.escape(ck)}(?![a-z])", key):496 out["city"] = city497 break498 if _QC_MARKERS.search(key) or out["city"] or (499 out["postal_code"] and out["postal_code"][0] in "GHJ"):500 out["region"] = "Québec"501 if not out["city"]:502 # premier segment avant la virgule, si ça ressemble à un nom de ville :503 # contient des lettres, ≤ 3 mots, pas un mot générique ni un nom de504 # succursale (« Caisse Desjardins de… », « Centre de services… »)505 seg = clean_text(s.split(",")[0])506 seg_key = _key(seg)507 if (seg and len(seg) <= 40 and re.search(r"[a-z]", seg_key)508 and len(seg.split()) <= 3509 and seg_key not in _NON_QC_CITIES510 and not re.search(511 r"remote|teletravail|canada|multiple|various|partout|"512 r"\d+\s+locations?|\d+\s+lieux|caisse|succursale|"513 r"centre de services|siege social", seg_key)):514 out["city"] = seg515 return out516517518def is_quebec_location(raw: str) -> bool:519 """Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens)."""520 key = _key(clean_text(raw))521 if not key:522 return False523 if key in _NON_QC_CITIES: # libellé = ville canadienne/monde hors QC524 return False525 if _QC_MARKERS.search(key):526 return True527 m = _POSTAL_RE.search(raw)528 if m and m.group(1)[0].upper() in "GHJ":529 return True530 return any(re.search(rf"(?<![a-z]){re.escape(ck)}(?![a-z])", key)531 for ck in _QC_CITIES)532533534# ---------------------------------------------------------------------------535# Catégorisation — taxonomie interne déterministe (mots-clés FR/EN)536# ---------------------------------------------------------------------------537538_CATEGORY_RULES = [539 ("TI", r"developpe|developer|logiciel|software|devops|donnees|data\b|"540 r"informatique|programmeur|programmer|cybersecurit|cloud|fullstack|"541 r"full[- ]stack|backend|frontend|sre\b|qa\b|scrum|infrastructure ti|"542 r"intelligence artificielle|machine learning|\bia\b|\bai\b|analyste ti"),543 ("Santé", r"infirmier|infirmiere|nurse|medecin|pharmac|dentaire|sante\b|"544 r"prepose aux beneficiaires|\bpab\b|physiotherap|ergotherap|"545 r"psycholog|travailleur social|clinique|medical"),546 ("Ingénierie", r"ingenieur|engineer(?!ing manager, software)|genie\b|"547 r"mecanique du batiment|electrique|civil\b|structurel"),548 ("Construction", r"construction|charpentier|menuisier|plombier|electricien|"549 r"contremaitre|chantier|grutier|briqueteur|couvreur|paysagiste"),550 ("Finance", r"comptab|account(?:ant|ing)|finance|financier|paie\b|payroll|audit|"551 r"fiscalite|tresorerie|actuaire|actuarial|credit\b|placement"),552 ("Éducation", r"enseignant|professeur|teacher|educat|tuteur|formateur|"553 r"pedagog|garderie|cpe\b"),554 ("Juridique", r"avocat|juriste|notaire|parajuriste|paralegal|legal counsel|"555 r"conformite|compliance"),556 ("RH", r"ressources humaines|\brh\b|recrut|talent|human resources|\bhr\b|"557 r"acquisition de talents"),558 ("Marketing", r"marketing|communication|contenu|content|marque|brand|"559 r"medias sociaux|social media|seo\b|publicite|graphiste|designer"),560 ("Ventes", r"vente|ventes|sales|representant|account (?:executive|manager)|"561 r"developpement des affaires|business development|conseiller commercial"),562 ("Service à la clientèle", r"service a la clientele|customer (?:service|success|"563 r"support)|centre d'appels|call cent"),564 ("Transport", r"chauffeur|camionneur|driver|livreur|logistique|logistics|"565 r"transport|entrepot|warehouse|cariste|repartiteur"),566 ("Production", r"production|manufactur|usine|operateur|machiniste|soudeur|"567 r"welder|assembleur|journalier|emballage|maintenance industrielle"),568 ("Restauration", r"cuisinier|chef\b|serveur|barista|restauration|plongeur|"569 r"boulanger|patissier|traiteur|banquet"),570 ("Commerce de détail", r"commis|caissier|retail|magasin|boutique|"571 r"marchandiseur|gerant de magasin"),572 ("Administration", r"adjoint|administratif|secretaire|receptionniste|"573 r"coordonnat|commis de bureau|office (?:manager|clerk)"),574]575576577# ---------------------------------------------------------------------------578# Langue de l'offre — heuristique légère par mots-outils (fr | en | bilingue)579# ---------------------------------------------------------------------------580581# Mots-outils très fréquents et quasi exclusifs à chaque langue (sans accents).582_FR_STOPWORDS = re.compile(583 r"\b(?:le|la|les|des|une|et|est|sont|pour|avec|vous|nous|notre|nos|vos|"584 r"dans|chez|aux|du|au|ainsi|plus de|afin|etre|sera|poste|equipe|"585 r"exigences|taches|milieu|travail)\b")586_EN_STOPWORDS = re.compile(587 r"\b(?:the|and|for|with|you|your|we|our|is|are|will|this|that|team|"588 r"work|skills|of|to be|as well|role|responsibilities|requirements|"589 r"experience in|ability)\b")590591592def detect_language(title: str, description: str = "") -> str:593 """Langue du texte de l'offre : « fr », « en », « bilingue », ou "".594595 Comptage de mots-outils sur le début du texte — déterministe, jamais de596 valeur inventée : texte trop court ou ambigu -> "".597 """598 text = _key(f"{title}\n{description or ''}"[:4000])599 if len(text) < 30:600 return ""601 fr = len(_FR_STOPWORDS.findall(text))602 en = len(_EN_STOPWORDS.findall(text))603 total = fr + en604 if total < 5:605 return ""606 # les deux langues nettement présentes -> offre bilingue607 if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:608 return "bilingue"609 if fr > en:610 return "fr"611 if en > fr:612 return "en"613 return ""614615616# ---------------------------------------------------------------------------617# Avantages — extraction des blocs « Avantages / Benefits » des descriptions618# ---------------------------------------------------------------------------619620_BENEFITS_HEADING = re.compile(621 r"^\s*(?:les\s+|nos\s+|our\s+|tes\s+|vos\s+)?(?:avantages(?:\s+sociaux)?|"622 r"benefits(?:\s+&\s+perks)?|perks(?:\s+(?:and|&)\s+benefits)?|"623 r"ce que nous (?:t')?offrons|what we offer|why join us|"624 r"pourquoi nous rejoindre|nous offrons|we offer)\s*:?\s*$", re.I)625_BENEFIT_LINE_MAX = 120626627628def extract_benefits(description: str) -> list[str]:629 """Liste d'avantages depuis un bloc structuré de la description.630631 Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,632 puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la633 première ligne longue ou au prochain titre. Max 12 items — si le bloc ne634 ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).635 """636 if not description:637 return []638 lines = description.split("\n")639 for i, line in enumerate(lines):640 if not _BENEFITS_HEADING.match(strip_accents(line)):641 continue642 items: list[str] = []643 for nxt in lines[i + 1:]:644 t = clean_text(nxt).lstrip("•-–—*·◦▪ ").strip()645 if not t:646 if items:647 break # fin du bloc (ligne vide après les puces)648 continue649 if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(650 strip_accents(t)):651 break652 # un « titre » de section suivant (courte ligne sans ponctuation653 # finale, capitalisée) termine aussi le bloc654 if items and len(t.split()) <= 4 and t.endswith(":"):655 break656 items.append(t.rstrip(" ;,.").strip())657 if len(items) >= 12:658 break659 if len(items) >= 2:660 return items661 return []662663664# ---------------------------------------------------------------------------665# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés666# ---------------------------------------------------------------------------667668# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…669_REQ_CODE = re.compile(670 # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour671 # ne pas manger une année « 2026 - Programme d'été »)672 r"^\s*[\[(]?(?:req(?:uisition)?|jr|job)[\s#:-]*\d{3,10}[\])]?\s*[-–—:|]\s*|"673 r"^\s*[\[(]?\d{5,10}[\])]?\s*[-–—:|]\s*|"674 # en queue : « Titre - R-12345 », « Titre (JOB 004521) »675 r"\s*[-–—|(]\s*(?:req(?:uisition)?|job|poste)?\s*#?\s*(?:[A-Z]{1,4}-?)?\d{4,10}\s*[)\]]?\s*$|"676 # au milieu : « Titre [R-12345] suite »677 r"\s*[\[(](?:R|REQ|JR|JOB)[-_ ]?\d{3,10}[\])]\s*", re.I)678679# petits mots gardés en minuscules lors de la remise en casse d'un titre680# tout-majuscules ; sigles usuels gardés en majuscules681_TITLE_SMALL = {"de", "du", "des", "la", "le", "les", "et", "en", "à", "a",682 "au", "aux", "d'", "l'", "of", "the", "and", "in", "for",683 "to", "or", "ou", "un", "une", "sur", "pour"}684_TITLE_ACRONYMS = {"rh", "ti", "it", "qa", "qc", "pab", "cpa", "cfa", "erp",685 "sap", "crm", "sst", "cnc", "hvac", "rpa", "bi", "ia",686 "ai", "vp", "pdg", "dg"}687688689def _smart_case(title: str) -> str:690 """Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés)."""691 words = title.lower().split()692 out = []693 for i, w in enumerate(words):694 base = w.strip("()[],.;:/")695 if base in _TITLE_ACRONYMS:696 out.append(w.replace(base, base.upper()))697 elif i > 0 and base in _TITLE_SMALL:698 out.append(w)699 else:700 out.append(w[:1].upper() + w[1:])701 return " ".join(out)702703704def clean_title(title: str, city: str = "") -> str:705 """Titre d'affichage : codes de réquisition retirés, suffixe « - <ville> »706 redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est707 jamais modifié — ceci alimente une colonne d'affichage distincte."""708 t = clean_text(title)709 if not t:710 return ""711 prev = None712 while prev != t: # les codes peuvent être empilés713 prev = t714 t = _REQ_CODE.sub(" ", t).strip(" -–—|:")715 t = clean_text(t)716 # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de717 # l'offre : repéré sans accents, coupé sur le titre original718 if city:719 ck = re.escape(strip_accents(city.lower()))720 m = re.search(721 rf"\s*[-–—|,(]\s*(?:ville de\s+)?{ck}(?:\s*,\s*(?:qc|quebec))?\s*\)?\s*$",722 strip_accents(t.lower()))723 if m and m.start() > 0:724 t = clean_text(t[:m.start()]).strip(" -–—|,(")725 if len(t) > 8 and t == t.upper() and t != t.lower():726 t = _smart_case(t)727 return t or clean_text(title)728729730def categorize(title: str, description: str = "") -> str:731 """Titre -> catégorie interne (repli : début de description), ou "".732733 Le titre est essayé SEUL d'abord : la description commence souvent par le734 boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait735 la catégorie du poste.736 """737 for texte in (title, description[:300]):738 key = _key(texte or "")739 if not key.strip():740 continue741 for cat, pat in _CATEGORY_RULES:742 if re.search(pat, key):743 return cat744 return ""745