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/connectors/workday.py6# Rôle : Classe de plateforme Workday (API JSON publique wday/cxs) — un7# employeur = une sous-classe de ~10 lignes (TENANT, HOST, SITE)8# Créé : 2026-08-17 Modifié : 2026-09-129# =============================================================================10"""Plateforme Workday.1112L'API publique des sites carrières Workday est stable et non authentifiée :13- liste : POST https://<tenant>.<host>.myworkdayjobs.com/wday/cxs/<tenant>/<site>/jobs14 body {"appliedFacets":{},"limit":20,"offset":N,"searchText":""}15 -> {total, jobPostings:[{title, externalPath, locationsText,16 postedOn, bulletFields:[req_id]}]}17- détail : GET .../wday/cxs/<tenant>/<site><externalPath>18 -> {jobPostingInfo:{jobDescription(HTML), location, timeType,19 jobReqId, startDate, …}}2021Les sites listent souvent des postes hors Québec : on filtre côté client par22`is_quebec_location`, puis on ne visite la page détail QUE pour les postes23québécois (avec cache BD — le détail n'est re-téléchargé que si la ligne de24liste change).25"""26from __future__ import annotations2728import hashlib29import os30import re31import time3233import requests3435from ..schema import JobPosting, clean_html, is_quebec_location36from .base import BaseConnector3738PAGE_SIZE = 2039MAX_DETAILS = int(os.environ.get("JOBKA_WORKDAY_DETAIL_LIMIT", "300"))40# Échecs de pages détail tolérés par sync : une erreur passagère sur UNE page41# (504 Gateway Timeout constaté le 2026-09-04 sur walmart, ~1090 offres42# perdues pour un seul GET) ne doit pas avorter toute la source ; au-delà du43# budget, la panne est réelle et on la remonte dans sync_log.44DETAIL_MISS_BUDGET = 8454647class WorkdayConnector(BaseConnector):48 """Base Workday — sous-classes : définir source_id, EMPLOYER, TENANT,49 HOST (wd1/wd3/wd10…), SITE, et au besoin surcharger `_keep()`."""5051 ats = "workday"52 request_delay = 0.75354 EMPLOYER = ""55 TENANT = ""56 HOST = "wd3"57 SITE = ""58 max_pages = 60 # garde-fou (60 × 20 = 1200 postes)59 quebec_only = True # filtrer les lieux hors Québec6061 @property62 def _base(self) -> str:63 return f"https://{self.TENANT}.{self.HOST}.myworkdayjobs.com"6465 def _req_json(self, method: str, url: str, **kwargs) -> dict:66 # Workday renvoie parfois 200 avec un corps vide/HTML (fenêtres de67 # maintenance nocturnes ~03 h EDT, constatées les 29 et 31 août 202668 # sur de nombreux tenants) : on retente avec backoff avant69 # d'abandonner, et l'erreur finale nomme le statut HTTP +70 # content-type pour que sync_log soit diagnostiquable.71 # Même traitement pour les 5xx transitoires (500 isolé constaté le72 # 2026-09-07 sur firststudent.wd1, reparti seul) : raise_for_status73 # dans base.get/post court-circuitait le retry et un seul 500 faisait74 # échouer tout le sync.75 last: Exception | None = None76 resp = None77 for attempt in range(3):78 try:79 resp = (self.post if method == "post" else self.get)(url, **kwargs)80 except requests.HTTPError as exc:81 if exc.response is None or exc.response.status_code < 500:82 raise83 last = exc84 time.sleep(10 * (attempt + 1))85 continue86 try:87 return resp.json()88 except ValueError as exc:89 last = exc90 time.sleep(10 * (attempt + 1))91 if resp is None:92 raise RuntimeError(f"Workday 5xx après 3 essais : {last}")93 raise RuntimeError(94 f"Workday non-JSON après 3 essais (HTTP {resp.status_code}, "95 f"{resp.headers.get('content-type')}) : {last}")9697 def _keep(self, item: dict) -> bool:98 """Crochet de filtrage d'une ligne de liste (défaut : lieu québécois)."""99 if not self.quebec_only:100 return True101 return is_quebec_location(item.get("locationsText") or "")102103 @staticmethod104 def _find_qc_facet(facets: list) -> dict | None:105 """Cherche une facette « Québec » (Region, State/Province…) dans la106 réponse liste. Workday la fournit sur la plupart des tenants — filtrer107 côté serveur est plus fiable que deviner depuis « Acton Vale » ou108 « 2 Locations », et évite de paginer les postes hors province.109 """110 def walk(param: str | None, values: list) -> dict | None:111 for v in values or []:112 p = v.get("facetParameter") or param113 desc = (v.get("descriptor") or "").strip().lower()114 if desc in ("quebec", "québec", "qc", "quebec, canada") and \115 v.get("id") and p:116 return {p: [v["id"]]}117 found = walk(p, v.get("values"))118 if found:119 return found120 return None121 for f in facets or []:122 found = walk(f.get("facetParameter"), f.get("values"))123 if found:124 return found125 # Repli (tenant jj, constaté 2026-09-10) : certains tenants n'exposent126 # AUCUN niveau province — seulement une facette de lieux plate au127 # niveau ville (« Montréal, Quebec, Canada »). Sans filtre serveur, le128 # scan client plafonné par max_pages rate les offres QC dès que le129 # tenant dépasse le plafond (jj : 1692 postes > 60 × 20). On agrège130 # alors toutes les valeurs de lieu québécoises (mêmes règles que le131 # filtre client is_quebec_location) en un seul filtre serveur.132 by_param: dict[str, list] = {}133134 def collect(param: str | None, values: list) -> None:135 for v in values or []:136 p = v.get("facetParameter") or param137 desc = (v.get("descriptor") or "").strip()138 if p and v.get("id") and is_quebec_location(desc):139 by_param.setdefault(p, []).append(v["id"])140 collect(p, v.get("values"))141 for f in facets or []:142 collect(f.get("facetParameter"), f.get("values"))143 if by_param:144 param = max(by_param, key=lambda k: len(by_param[k]))145 return {param: by_param[param]}146 return None147148 # facette « Remote Type » (présente sur beaucoup de tenants) : libellé149 # de valeur -> work_mode standard150 _REMOTE_FACET_VALUES = {151 "remote": "teletravail", "fully remote": "teletravail",152 "teletravail": "teletravail", "télétravail": "teletravail",153 "hybrid": "hybride", "hybride": "hybride",154 "on-site": "presentiel", "onsite": "presentiel",155 "on site": "presentiel", "in office": "presentiel",156 "field-based": "presentiel", "presentiel": "presentiel",157 "présentiel": "presentiel",158 }159160 @classmethod161 def _find_remote_facet(cls, facets: list) -> list[tuple[str, dict]] | None:162 """Repère une facette de type « Remote Type » et retourne163 [(work_mode, {facetParameter: [id]}), …] pour requêtes filtrées."""164 for f in facets or []:165 param = f.get("facetParameter") or ""166 if "remote" not in param.lower():167 continue168 out = []169 for v in f.get("values") or []:170 mode = cls._REMOTE_FACET_VALUES.get(171 (v.get("descriptor") or "").strip().lower())172 if mode and v.get("id"):173 out.append((mode, {param: [v["id"]]}))174 if out:175 return out176 return None177178 def _list_pages(self, extra_facets: dict | None = None):179 url = f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}/jobs"180 applied: dict = dict(extra_facets or {})181 offset = 0182 total = 0183 unfiltered_total = 0184 first = True185 qc_retried = False186 for _ in range(self.max_pages):187 data = self._req_json("post", url,188 json={"appliedFacets": applied,189 "limit": PAGE_SIZE,190 "offset": offset, "searchText": ""})191 if first:192 first = False193 self._first_facets = data.get("facets") or []194 unfiltered_total = int(data.get("total") or 0)195 if self.quebec_only:196 qc = self._find_qc_facet(data.get("facets"))197 if qc:198 # filtre serveur trouvé : rejouer la pagination avec, et199 # neutraliser le filtre client (libellés de villes nues)200 applied = {**qc, **(extra_facets or {})}201 self._qc_facet = qc202 self._server_filtered = True203 continue204 items = data.get("jobPostings") or []205 if not items:206 # Facette factice (tenant dxctechnology, constaté 2026-09-10) :207 # la facette « locations » annonce des postes QC (count > 0)208 # mais le filtre serveur renvoie 0 résultat quelle que soit la209 # valeur. Plutôt que de conclure à 0 offre, abandonner le210 # filtre serveur et revenir au scan client complet.211 if (self._server_filtered and offset == 0 and not qc_retried212 and unfiltered_total):213 qc_retried = True214 applied = dict(extra_facets or {})215 self._qc_facet = None216 self._server_filtered = False217 continue218 return219 yield from items220 offset += PAGE_SIZE221 # Workday ne renvoie `total` que sur la première page quand une222 # facette est appliquée (0 ensuite) : mémoriser la valeur connue223 # et s'arrêter aussi sur page incomplète.224 page_total = int(data.get("total") or 0)225 if page_total:226 total = page_total227 if len(items) < PAGE_SIZE or (total and offset >= total):228 return229230 def _tag_remote_modes(self, by_path: dict) -> None:231 """Enrichit work_mode via la facette « Remote Type » quand le tenant232 l'expose : une pagination filtrée (QC + valeur de facette) par mode,233 les offres retrouvées héritent du mode. Tolérant aux échecs réseau —234 l'enrichissement est optionnel, jamais bloquant."""235 modes = self._find_remote_facet(getattr(self, "_first_facets", []))236 if not modes or not by_path:237 return238 qc = getattr(self, "_qc_facet", None) or {}239 for mode, facet in modes:240 try:241 for item in self._list_pages({**facet, **qc}):242 job = by_path.get(item.get("externalPath") or "")243 if job is not None and job.work_mode is None:244 job.work_mode = mode245 except Exception:246 continue # facette indisponible : on n'invente rien247248 def _external_id(self, item: dict, path: str) -> str:249 """Identifiant externe d'une ligne de liste — par défaut le premier250 `bulletFields` (= req_id chez la plupart des tenants), sinon le251 suffixe du chemin. Hook surchargeable : certains tenants (ex.252 altusgroup) préfixent les bullets par la région, ce qui provoque des253 collisions d'ID si on prend bullets[0] aveuglément."""254 bullets = item.get("bulletFields") or []255 return str((bullets[0] if bullets else "") or path.rsplit("_", 1)[-1])256257 def _fetch_detail(self, external_path: str) -> dict:258 data = self._req_json("get",259 f"{self._base}/wday/cxs/{self.TENANT}"260 f"/{self.SITE}{external_path}")261 info = data.get("jobPostingInfo") or {}262 return {263 "description_html": info.get("jobDescription") or "",264 "location": info.get("location") or "",265 "time_type": info.get("timeType") or "",266 "posted_on": info.get("postedOn") or "",267 "req_id": info.get("jobReqId") or "",268 # lien de candidature canonique (jobPostingInfo.externalUrl)269 "apply_url": info.get("externalUrl") or "",270 }271272 def fetch(self) -> list[JobPosting]:273 out: list[JobPosting] = []274 by_path: dict[str, JobPosting] = {}275 details_used = 0276 detail_misses = 0277 self._server_filtered = False278 self._first_facets: list = []279 self._qc_facet = None280 for item in self._list_pages():281 if not self._server_filtered and not self._keep(item):282 continue283 path = item.get("externalPath") or ""284 eid = self._external_id(item, path)285 if not eid or not path:286 continue287 job = JobPosting(288 source=self.source_id, external_id=str(eid),289 url=f"{self._base}/{self.SITE}{path}",290 employer=self.EMPLOYER,291 title=item.get("title") or "",292 location_label=item.get("locationsText") or "",293 date_posted=item.get("postedOn") or None,294 ats=self.ats,295 )296 # page détail (description, type d'emploi) avec cache + budget —297 # clé « v2| » : re-visite progressive pour capter apply_url298 key = hashlib.sha1(299 f"v2|{path}|{item.get('postedOn','')}".encode()).hexdigest()[:12]300 if details_used < MAX_DETAILS:301 fresh = [False]302303 def _fn(p=path, fresh=fresh):304 fresh[0] = True305 return self._fetch_detail(p)306307 # page détail en erreur (5xx passager, coupure, non-JSON) :308 # repli sur le cache périmé et on continue — l'échec n'étant309 # pas mis en cache, la page sera re-tentée au prochain cycle310 try:311 d = self.detail(str(eid), key, _fn)312 except (requests.RequestException, RuntimeError):313 detail_misses += 1314 if detail_misses > DETAIL_MISS_BUDGET:315 raise316 d = self.stale_detail(str(eid))317 if fresh[0]:318 details_used += 1319 else:320 # budget épuisé : détail périmé plutôt que fiche vidée321 d = self.stale_detail(str(eid))322 if d:323 job.description = clean_html(d.get("description_html", ""))324 job.details["employment_label"] = d.get("time_type", "")325 job.apply_url = d.get("apply_url", "") or ""326 # « 2 Locations » : le vrai lieu principal est sur la page détail327 if d.get("location") and (not job.location_label or re.match(328 r"^\d+\s+locations?$", job.location_label, re.I)):329 job.location_label = d["location"]330 out.append(job)331 by_path[path] = job332 # work_mode via la facette « Remote Type » (si le tenant l'expose)333 if self.quebec_only:334 self._tag_remote_modes(by_path)335 return out336