SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
3 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
9.8 KB · 210 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/schema.py6# Rôle    : Modèle de données standardisé (JobPosting) + normalisation centrale7# Créé    : 2026-08-17   Modifié : 2026-08-258# =============================================================================9"""Schéma standard d'une offre d'emploi (JobPosting) et normalisation.1011Chaque connecteur, peu importe l'employeur ou l'ATS source, doit produire des12objets `JobPosting` conformes à ce schéma. La méthode `finalize()` applique13ensuite la couche de normalisation commune (jobka/normalize.py) : dates ISO,14salaires numériques + conversions $/h et $/an, mode de travail, type d'emploi,15catégorie… — les connecteurs restent simples et remplissent les champs bruts.16"""17from __future__ import annotations1819import hashlib20import json21from dataclasses import dataclass, field, asdict2223from .normalize import (  # ré-exportés pour les connecteurs24    canonical_city,25    categorize,26    clean_html,27    clean_text,28    detect_language,29    extract_benefits,30    extract_requirements,31    is_quebec_location,32    parse_date,33    parse_employment_type,34    parse_location,35    parse_salary,36    parse_seniority,37    parse_work_mode,38    salary_from_text,39    salary_to_hourly,40    salary_to_yearly,41    sanitize_salary,42    strip_accents,43)4445__all__ = [46    "JobPosting", "categorize", "clean_html", "clean_text",47    "is_quebec_location", "parse_date", "parse_employment_type",48    "parse_location", "parse_salary", "parse_work_mode", "strip_accents",49]505152@dataclass53class JobPosting:54    """Offre d'emploi standardisée Job·Ka."""5556    source: str                      # id du connecteur (voir data/sources.json)57    external_id: str                 # identifiant chez l'employeur/ATS58    url: str                         # lien direct vers l'offre — zéro boîte noire59    employer: str = ""               # nom de l'entreprise60    title: str = ""                  # titre du poste61    description: str = ""            # texte complet (HTML nettoyé)62    address: str = ""                # adresse du lieu de travail si disponible63    city: str = ""                   # Montréal, Québec, Sherbrooke…64    region: str = ""                 # région administrative / « Québec »65    postal_code: str = ""66    location_label: str = ""         # libellé de lieu original (brut)67    work_mode: str | None = None     # presentiel | hybride | teletravail68    employment_type: str | None = None  # temps_plein | temps_partiel |69                                        # contractuel | stage | saisonnier70    salary_min: float | None = None  # nombres, jamais des strings71    salary_max: float | None = None72    salary_unit: str | None = None   # hour | day | week | biweek | month | year73    salary_label: str = ""           # texte original (ex. « 25 $ à 30 $/h »)74    benefits: list[str] = field(default_factory=list)75    requirements: dict = field(default_factory=dict)  # scolarité, expérience, langues76    date_posted: str | None = None   # ISO 860177    date_deadline: str | None = None # ISO 8601 (date limite pour postuler)78    category: str = ""               # taxonomie interne (TI, Santé, …)79    ats: str = ""                    # workday | lever | greenhouse | custom…80    details: dict = field(default_factory=dict)   # champs structurés (sparse)81    lat: float | None = None82    lng: float | None = None83    company_logo: str = ""           # URL du logo employeur (si exposé)84    language: str = ""               # fr | en | bilingue ("" = indéterminée)85    apply_url: str = ""              # lien de candidature directe (≠ url fiche)86    seniority: str | None = None     # stage | junior | intermediaire |87                                     # senior | direction (None = indéterminée)8889    @property90    def uid(self) -> str:91        return f"{self.source}:{self.external_id}"9293    def content_hash(self) -> str:94        """Hash du contenu pour la détection de changements (pseudo-webhook)."""95        payload = asdict(self)96        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)97        return hashlib.sha256(blob.encode("utf-8")).hexdigest()9899    # Conversions dérivées — calculées à la volée, PAS dans content_hash100    # (elles découlent de salary_min/max/unit, les stocker dans le hash ne101    # ferait que dupliquer l'information).102    def salary_year_min(self) -> float | None:103        return salary_to_yearly(self.salary_min, self.salary_unit)104105    def salary_year_max(self) -> float | None:106        return salary_to_yearly(self.salary_max, self.salary_unit)107108    def salary_hour_min(self) -> float | None:109        return salary_to_hourly(self.salary_min, self.salary_unit)110111    def salary_hour_max(self) -> float | None:112        return salary_to_hourly(self.salary_max, self.salary_unit)113114    def finalize(self) -> "JobPosting":115        """Applique la normalisation commune. Appelé par le pipeline d'ingestion.116117        Idempotent ; ne remplace jamais une valeur explicite du connecteur.118        """119        self.title = clean_text(self.title)120        self.employer = clean_text(self.employer)121        self.description = clean_text(self.description) if "\n" not in self.description \122            else self.description.strip()123124        # lieu : compléter ville/région/code postal depuis le libellé brut,125        # puis canonicaliser la ville (« Montreal » -> « Montréal »)126        if self.location_label and not (self.city and self.region):127            loc = parse_location(self.location_label)128            self.city = self.city or loc["city"]129            self.region = self.region or loc["region"]130            self.postal_code = self.postal_code or loc["postal_code"]131        self.city = canonical_city(self.city)132        # région administrative (17 régions, répertoire MAMH embarqué) ;133        # repli : « Québec » (province) quand seule l'appartenance QC est sûre134        if self.city and (not self.region or self.region == "Québec"):135            from .regions import region_for_city136            self.region = region_for_city(self.city) or self.region137        if self.city and not self.region and is_quebec_location(self.city):138            self.region = "Québec"139140        # salaire : parser le libellé si le connecteur n'a pas de valeurs numériques141        if self.salary_min is None and self.salary_label:142            lo, hi, unit = parse_salary(self.salary_label)143            self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit144        if self.salary_min is None and self.description:145            # mention salariale dans la description (fréquent au QC) — avec146            # garde-fou de contexte (financement, remboursements ≠ salaire)147            lo, hi, unit, label = salary_from_text(self.description)148            if lo is not None:149                self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit150                self.salary_label = self.salary_label or label151        # bornes de plausibilité aussi sur les valeurs STRUCTURÉES des ATS152        # (unités mal étiquetées : « 75 000 $/mois » qui est un annuel…)153        self.salary_min, self.salary_max, self.salary_unit = sanitize_salary(154            self.salary_min, self.salary_max, self.salary_unit)155        if self.salary_min is None:156            self.salary_unit = None157158        # dates : accepter epoch/textuel/relatif -> ISO159        if self.date_posted is not None:160            self.date_posted = parse_date(self.date_posted)161        if self.date_deadline is not None:162            self.date_deadline = parse_date(self.date_deadline)163164        # mode de travail / type d'emploi : depuis le libellé de lieu, le titre165        # puis la description — première réponse trouvée, jamais de défaut166        if self.work_mode is None:167            for texte in (self.location_label, self.title, self.description[:500]):168                self.work_mode = parse_work_mode(texte)169                if self.work_mode:170                    break171        if self.employment_type is None:172            for texte in (self.details.get("employment_label", ""), self.title,173                          self.description[:500]):174                self.employment_type = parse_employment_type(str(texte))175                if self.employment_type:176                    break177178        if not self.category:179            self.category = categorize(self.title, self.description)180181        # langue de l'offre : valeur source si fournie, sinon détection légère182        if not self.language:183            self.language = detect_language(self.title, self.description)184        elif self.language not in ("fr", "en", "bilingue"):185            lang = str(self.language).lower()[:2]186            self.language = {"fr": "fr", "en": "en"}.get(lang, "")187188        # avantages : blocs structurés « Avantages / Benefits » des descriptions189        if not self.benefits and self.description:190            self.benefits = extract_benefits(self.description)191192        # exigences structurées (expérience, scolarité, langues) : extraites de193        # la description, sans jamais écraser une valeur fournie par l'ATS194        if self.description:195            for k, v in extract_requirements(self.title, self.description).items():196                self.requirements.setdefault(k, v)197198        # séniorité du poste : titre, libellé ATS, puis années d'expérience199        if self.seniority is None:200            self.seniority = parse_seniority(201                self.title, self.requirements, self.employment_type)202203        # coordonnées fournies par la source : rejeter tout point hors de la204        # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra205        # le relais sur l'adresse/la ville206        if self.lat is not None and self.lng is not None:207            if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):208                self.lat = self.lng = None209        return self210