# ============================================================================= # 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) — jamais de valeur inventée # Créé : 2026-08-17 Modifié : 2026-08-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", ] # --------------------------------------------------------------------------- # 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", "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", "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 # 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 = _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 = _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 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"benefits(?:\s+&\s+perks)?|perks(?:\s+(?:and|&)\s+benefits)?|" r"ce que nous (?:t')?offrons|what we offer|why join us|" r"pourquoi nous rejoindre|nous offrons|we offer)\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 [] # --------------------------------------------------------------------------- # 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 ""