HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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