# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/workday.py # Rôle : Classe de plateforme Workday (API JSON publique wday/cxs) — un # employeur = une sous-classe de ~10 lignes (TENANT, HOST, SITE) # Créé : 2026-08-17 Modifié : 2026-09-12 # ============================================================================= """Plateforme Workday. L'API publique des sites carrières Workday est stable et non authentifiée : - liste : POST https://..myworkdayjobs.com/wday/cxs///jobs body {"appliedFacets":{},"limit":20,"offset":N,"searchText":""} -> {total, jobPostings:[{title, externalPath, locationsText, postedOn, bulletFields:[req_id]}]} - détail : GET .../wday/cxs// -> {jobPostingInfo:{jobDescription(HTML), location, timeType, jobReqId, startDate, …}} Les sites listent souvent des postes hors Québec : on filtre côté client par `is_quebec_location`, puis on ne visite la page détail QUE pour les postes québécois (avec cache BD — le détail n'est re-téléchargé que si la ligne de liste change). """ from __future__ import annotations import hashlib import os import re import time import requests from ..schema import JobPosting, clean_html, is_quebec_location from .base import BaseConnector PAGE_SIZE = 20 MAX_DETAILS = int(os.environ.get("JOBKA_WORKDAY_DETAIL_LIMIT", "300")) # Échecs de pages détail tolérés par sync : une erreur passagère sur UNE page # (504 Gateway Timeout constaté le 2026-09-04 sur walmart, ~1090 offres # perdues pour un seul GET) ne doit pas avorter toute la source ; au-delà du # budget, la panne est réelle et on la remonte dans sync_log. DETAIL_MISS_BUDGET = 8 class WorkdayConnector(BaseConnector): """Base Workday — sous-classes : définir source_id, EMPLOYER, TENANT, HOST (wd1/wd3/wd10…), SITE, et au besoin surcharger `_keep()`.""" ats = "workday" request_delay = 0.7 EMPLOYER = "" TENANT = "" HOST = "wd3" SITE = "" max_pages = 60 # garde-fou (60 × 20 = 1200 postes) quebec_only = True # filtrer les lieux hors Québec @property def _base(self) -> str: return f"https://{self.TENANT}.{self.HOST}.myworkdayjobs.com" def _req_json(self, method: str, url: str, **kwargs) -> dict: # Workday renvoie parfois 200 avec un corps vide/HTML (fenêtres de # maintenance nocturnes ~03 h EDT, constatées les 29 et 31 août 2026 # sur de nombreux tenants) : on retente avec backoff avant # d'abandonner, et l'erreur finale nomme le statut HTTP + # content-type pour que sync_log soit diagnostiquable. # Même traitement pour les 5xx transitoires (500 isolé constaté le # 2026-09-07 sur firststudent.wd1, reparti seul) : raise_for_status # dans base.get/post court-circuitait le retry et un seul 500 faisait # échouer tout le sync. last: Exception | None = None resp = None for attempt in range(3): try: resp = (self.post if method == "post" else self.get)(url, **kwargs) except requests.HTTPError as exc: if exc.response is None or exc.response.status_code < 500: raise last = exc time.sleep(10 * (attempt + 1)) continue try: return resp.json() except ValueError as exc: last = exc time.sleep(10 * (attempt + 1)) if resp is None: raise RuntimeError(f"Workday 5xx après 3 essais : {last}") raise RuntimeError( f"Workday non-JSON après 3 essais (HTTP {resp.status_code}, " f"{resp.headers.get('content-type')}) : {last}") def _keep(self, item: dict) -> bool: """Crochet de filtrage d'une ligne de liste (défaut : lieu québécois).""" if not self.quebec_only: return True return is_quebec_location(item.get("locationsText") or "") @staticmethod def _find_qc_facet(facets: list) -> dict | None: """Cherche une facette « Québec » (Region, State/Province…) dans la réponse liste. Workday la fournit sur la plupart des tenants — filtrer côté serveur est plus fiable que deviner depuis « Acton Vale » ou « 2 Locations », et évite de paginer les postes hors province. """ def walk(param: str | None, values: list) -> dict | None: for v in values or []: p = v.get("facetParameter") or param desc = (v.get("descriptor") or "").strip().lower() if desc in ("quebec", "québec", "qc", "quebec, canada") and \ v.get("id") and p: return {p: [v["id"]]} found = walk(p, v.get("values")) if found: return found return None for f in facets or []: found = walk(f.get("facetParameter"), f.get("values")) if found: return found # Repli (tenant jj, constaté 2026-09-10) : certains tenants n'exposent # AUCUN niveau province — seulement une facette de lieux plate au # niveau ville (« Montréal, Quebec, Canada »). Sans filtre serveur, le # scan client plafonné par max_pages rate les offres QC dès que le # tenant dépasse le plafond (jj : 1692 postes > 60 × 20). On agrège # alors toutes les valeurs de lieu québécoises (mêmes règles que le # filtre client is_quebec_location) en un seul filtre serveur. by_param: dict[str, list] = {} def collect(param: str | None, values: list) -> None: for v in values or []: p = v.get("facetParameter") or param desc = (v.get("descriptor") or "").strip() if p and v.get("id") and is_quebec_location(desc): by_param.setdefault(p, []).append(v["id"]) collect(p, v.get("values")) for f in facets or []: collect(f.get("facetParameter"), f.get("values")) if by_param: param = max(by_param, key=lambda k: len(by_param[k])) return {param: by_param[param]} return None # facette « Remote Type » (présente sur beaucoup de tenants) : libellé # de valeur -> work_mode standard _REMOTE_FACET_VALUES = { "remote": "teletravail", "fully remote": "teletravail", "teletravail": "teletravail", "télétravail": "teletravail", "hybrid": "hybride", "hybride": "hybride", "on-site": "presentiel", "onsite": "presentiel", "on site": "presentiel", "in office": "presentiel", "field-based": "presentiel", "presentiel": "presentiel", "présentiel": "presentiel", } @classmethod def _find_remote_facet(cls, facets: list) -> list[tuple[str, dict]] | None: """Repère une facette de type « Remote Type » et retourne [(work_mode, {facetParameter: [id]}), …] pour requêtes filtrées.""" for f in facets or []: param = f.get("facetParameter") or "" if "remote" not in param.lower(): continue out = [] for v in f.get("values") or []: mode = cls._REMOTE_FACET_VALUES.get( (v.get("descriptor") or "").strip().lower()) if mode and v.get("id"): out.append((mode, {param: [v["id"]]})) if out: return out return None def _list_pages(self, extra_facets: dict | None = None): url = f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}/jobs" applied: dict = dict(extra_facets or {}) offset = 0 total = 0 unfiltered_total = 0 first = True qc_retried = False for _ in range(self.max_pages): data = self._req_json("post", url, json={"appliedFacets": applied, "limit": PAGE_SIZE, "offset": offset, "searchText": ""}) if first: first = False self._first_facets = data.get("facets") or [] unfiltered_total = int(data.get("total") or 0) if self.quebec_only: qc = self._find_qc_facet(data.get("facets")) if qc: # filtre serveur trouvé : rejouer la pagination avec, et # neutraliser le filtre client (libellés de villes nues) applied = {**qc, **(extra_facets or {})} self._qc_facet = qc self._server_filtered = True continue items = data.get("jobPostings") or [] if not items: # Facette factice (tenant dxctechnology, constaté 2026-09-10) : # la facette « locations » annonce des postes QC (count > 0) # mais le filtre serveur renvoie 0 résultat quelle que soit la # valeur. Plutôt que de conclure à 0 offre, abandonner le # filtre serveur et revenir au scan client complet. if (self._server_filtered and offset == 0 and not qc_retried and unfiltered_total): qc_retried = True applied = dict(extra_facets or {}) self._qc_facet = None self._server_filtered = False continue return yield from items offset += PAGE_SIZE # Workday ne renvoie `total` que sur la première page quand une # facette est appliquée (0 ensuite) : mémoriser la valeur connue # et s'arrêter aussi sur page incomplète. page_total = int(data.get("total") or 0) if page_total: total = page_total if len(items) < PAGE_SIZE or (total and offset >= total): return def _tag_remote_modes(self, by_path: dict) -> None: """Enrichit work_mode via la facette « Remote Type » quand le tenant l'expose : une pagination filtrée (QC + valeur de facette) par mode, les offres retrouvées héritent du mode. Tolérant aux échecs réseau — l'enrichissement est optionnel, jamais bloquant.""" modes = self._find_remote_facet(getattr(self, "_first_facets", [])) if not modes or not by_path: return qc = getattr(self, "_qc_facet", None) or {} for mode, facet in modes: try: for item in self._list_pages({**facet, **qc}): job = by_path.get(item.get("externalPath") or "") if job is not None and job.work_mode is None: job.work_mode = mode except Exception: continue # facette indisponible : on n'invente rien def _external_id(self, item: dict, path: str) -> str: """Identifiant externe d'une ligne de liste — par défaut le premier `bulletFields` (= req_id chez la plupart des tenants), sinon le suffixe du chemin. Hook surchargeable : certains tenants (ex. altusgroup) préfixent les bullets par la région, ce qui provoque des collisions d'ID si on prend bullets[0] aveuglément.""" bullets = item.get("bulletFields") or [] return str((bullets[0] if bullets else "") or path.rsplit("_", 1)[-1]) def _fetch_detail(self, external_path: str) -> dict: data = self._req_json("get", f"{self._base}/wday/cxs/{self.TENANT}" f"/{self.SITE}{external_path}") info = data.get("jobPostingInfo") or {} return { "description_html": info.get("jobDescription") or "", "location": info.get("location") or "", "time_type": info.get("timeType") or "", "posted_on": info.get("postedOn") or "", "req_id": info.get("jobReqId") or "", # lien de candidature canonique (jobPostingInfo.externalUrl) "apply_url": info.get("externalUrl") or "", } def fetch(self) -> list[JobPosting]: out: list[JobPosting] = [] by_path: dict[str, JobPosting] = {} details_used = 0 detail_misses = 0 self._server_filtered = False self._first_facets: list = [] self._qc_facet = None for item in self._list_pages(): if not self._server_filtered and not self._keep(item): continue path = item.get("externalPath") or "" eid = self._external_id(item, path) if not eid or not path: continue job = JobPosting( source=self.source_id, external_id=str(eid), url=f"{self._base}/{self.SITE}{path}", employer=self.EMPLOYER, title=item.get("title") or "", location_label=item.get("locationsText") or "", date_posted=item.get("postedOn") or None, ats=self.ats, ) # page détail (description, type d'emploi) avec cache + budget — # clé « v2| » : re-visite progressive pour capter apply_url key = hashlib.sha1( f"v2|{path}|{item.get('postedOn','')}".encode()).hexdigest()[:12] if details_used < MAX_DETAILS: fresh = [False] def _fn(p=path, fresh=fresh): fresh[0] = True return self._fetch_detail(p) # page détail en erreur (5xx passager, coupure, non-JSON) : # repli sur le cache périmé et on continue — l'échec n'étant # pas mis en cache, la page sera re-tentée au prochain cycle try: d = self.detail(str(eid), key, _fn) except (requests.RequestException, RuntimeError): detail_misses += 1 if detail_misses > DETAIL_MISS_BUDGET: raise d = self.stale_detail(str(eid)) if fresh[0]: details_used += 1 else: # budget épuisé : détail périmé plutôt que fiche vidée d = self.stale_detail(str(eid)) if d: job.description = clean_html(d.get("description_html", "")) job.details["employment_label"] = d.get("time_type", "") job.apply_url = d.get("apply_url", "") or "" # « 2 Locations » : le vrai lieu principal est sur la page détail if d.get("location") and (not job.location_label or re.match( r"^\d+\s+locations?$", job.location_label, re.I)): job.location_label = d["location"] out.append(job) by_path[path] = job # work_mode via la facette « Remote Type » (si le tenant l'expose) if self.quebec_only: self._tag_remote_modes(by_path) return out