SPB Git

spb/job-ka

Public
30.9 KB · 745 lines python
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/normalize.py6# Rôle    : Normalisation commune des offres (salaires, dates, mode/type7#           d'emploi, lieu, catégorie) — 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
Files · 6bec01007ca45174458fd0ecc0feefebe45339f4
Menu

Theme