HTML 82%
Python 14.7%
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/dayforce.py6# Rôle : Classe de plateforme Dayforce HCM (jobs.dayforcehcm.com) — API JSON7# publique du frontal Next.js, un employeur = une sous-classe8# Créé : 2026-08-22 Modifié : 2026-08-269# =============================================================================10"""Plateforme Dayforce HCM (ex-Ceridian) — jobs.dayforcehcm.com.1112API JSON publique du frontal (celle que le navigateur appelle) :13- jeton : GET /api/auth/csrf -> {csrfToken} (+ cookie de session next-auth) ;14 toute requête POST doit porter l'en-tête X-CSRF-TOKEN, sinon 403.15- liste : POST /api/geo/<NS>/jobposting/search16 body {clientNamespace, jobBoardCode, cultureCode,17 paginationStart} (pages FIXES de 25, champ découvert dans le18 chunk Next.js : paginationStart=(page-1)*25)19 -> {jobPostings:[{jobPostingId, jobTitle, jobDescription (HTML20 COMPLET dès la liste), jobBoardId, postingStartTimestampUTC,21 postingExpiryTimestampUTC, isEvergreen,22 postingLocations:[{formattedAddress, isoCountryCode, stateCode,23 cityName, coordinates "lat:…;lng:…"}]}], maxCount}24- détail : GET /api/geo/<NS>/jobposting/<NS>/<culture>/<jobBoardId>/<id>25 -> jobPostingAttributes [{name: "PayType"/"PayClass", value}] +26 jobPostingContent (header/description/footer). Visité avec cache BD27 + budget par cycle (n'apporte que les attributs type/paie).2829cultureCode « fr-CA » : Dayforce sert la traduction française quand elle30existe (public québécois), sinon le texte original.31"""32from __future__ import annotations3334import os35import re36import time3738import requests3940from ..schema import JobPosting, clean_html, is_quebec_location41from .base import BaseConnector4243BASE = "https://jobs.dayforcehcm.com"44PAGE_SIZE = 25 # taille fixe côté serveur (paginationStart)45MAX_DETAILS = int(os.environ.get("JOBKA_DAYFORCE_DETAIL_LIMIT", "80"))4647_COORD_RE = re.compile(r"lat:(-?\d+(?:\.\d+)?);lng:(-?\d+(?:\.\d+)?)")484950class DayforceConnector(BaseConnector):51 """Base Dayforce — sous-classes : définir source_id, EMPLOYER, NS52 (clientNamespace) et au besoin BOARD (jobBoardCode, défaut53 CANDIDATEPORTAL)."""5455 ats = "dayforce"56 request_delay = 0.85758 EMPLOYER = ""59 NS = "" # clientNamespace (ex. « lavieenrose »)60 BOARD = "CANDIDATEPORTAL" # jobBoardCode du site carrières public61 CULTURE = "fr-CA"62 quebec_only = True63 max_pages = 4064 fetch_details = True # attributs PayClass/PayType (cache + budget)6566 # -- lieux ------------------------------------------------------------67 @staticmethod68 def _is_qc(loc: dict) -> bool:69 if (loc.get("isoCountryCode") or "").upper() not in ("CA", ""):70 return False71 if (loc.get("stateCode") or "").upper() == "QC":72 return True73 return is_quebec_location(74 f"{loc.get('cityName') or ''}, {loc.get('formattedAddress') or ''}")7576 def _keep(self, locations: list[dict], virtual: bool) -> bool:77 if not self.quebec_only:78 return True79 return any(self._is_qc(l) for l in locations)8081 # -- détail (attributs type d'emploi / paie) ---------------------------82 def _fetch_detail(self, board_id: int, jid: str) -> dict:83 data = self.get(84 f"{BASE}/api/geo/{self.NS}/jobposting/{self.NS}/{self.CULTURE}/"85 f"{board_id}/{jid}").json()86 attrs = {a.get("name"): a.get("value")87 for a in (data.get("jobPostingAttributes") or [])88 if a.get("name")}89 content = data.get("jobPostingContent") or {}90 desc = "".join(content.get(k) or "" for k in91 ("jobDescriptionHeader", "jobDescription",92 "jobDescriptionFooter"))93 return {"attributes": attrs, "description": clean_html(desc)}9495 # -- recherche (garde-fou 5xx transitoire) ------------------------------96 def _search_page(self, search_url: str, csrf: str, start: int) -> dict:97 """POST de recherche avec garde-fou : le frontal Dayforce renvoie98 parfois un 5xx passager (502 Bad Gateway observé le 2026-08-26 sur99 groupemaurice) qui avortait tout le sync — on retente une fois après100 pause, puis on lève (sync en échec, jamais un faux 0)."""101 for attempt in range(2):102 try:103 return self.post(104 search_url,105 json={"clientNamespace": self.NS,106 "jobBoardCode": self.BOARD,107 "cultureCode": self.CULTURE,108 "paginationStart": start},109 headers={"X-CSRF-TOKEN": csrf,110 "Accept": "application/json"}).json()111 except requests.HTTPError as e:112 status = getattr(e.response, "status_code", 0) or 0113 if attempt == 0 and status >= 500:114 time.sleep(10)115 continue116 raise117 raise RuntimeError("unreachable")118119 # -- contrat ------------------------------------------------------------120 def fetch(self) -> list[JobPosting]:121 csrf = (self.get(f"{BASE}/api/auth/csrf").json() or {}).get(122 "csrfToken") or ""123 search_url = f"{BASE}/api/geo/{self.NS}/jobposting/search"124 out: list[JobPosting] = []125 seen: set[str] = set()126 details_used = 0127 start = 0128 for _ in range(self.max_pages):129 data = self._search_page(search_url, csrf, start)130 postings = data.get("jobPostings") or []131 if not postings:132 break133 for p in postings:134 jid = str(p.get("jobPostingId") or "")135 if not jid or jid in seen:136 continue137 seen.add(jid)138 locations = p.get("postingLocations") or []139 if not self._keep(locations, p.get("hasVirtualLocation")):140 continue141 loc = next((l for l in locations if self._is_qc(l)),142 locations[0] if locations else {})143 lat = lng = None144 if m := _COORD_RE.search(loc.get("coordinates") or ""):145 lat, lng = float(m.group(1)), float(m.group(2))146 job = JobPosting(147 source=self.source_id, external_id=jid,148 url=f"{BASE}/{self.CULTURE}/{self.NS}/{self.BOARD}"149 f"/jobs/{jid}",150 employer=self.EMPLOYER,151 title=p.get("jobTitle") or "",152 description=clean_html(p.get("jobDescription") or ""),153 address=loc.get("formattedAddress") or "",154 city=loc.get("cityName") or "",155 location_label=loc.get("formattedAddress")156 or (loc.get("cityName") or ""),157 date_posted=p.get("postingStartTimestampUTC"),158 date_deadline=p.get("postingExpiryTimestampUTC"),159 lat=lat, lng=lng,160 ats=self.ats,161 )162 if p.get("isEvergreen"):163 job.details["evergreen"] = True164 board_id = p.get("jobBoardId") or 1165 if self.fetch_details:166 key = ((p.get("postingStartTimestampUTC") or "")167 + (p.get("jobTitle") or "")[:40])168 if details_used < MAX_DETAILS:169 fresh = [False]170171 def _fn(b=board_id, i=jid, fresh=fresh):172 fresh[0] = True173 return self._fetch_detail(b, i)174175 d = self.detail(jid, key, _fn)176 if fresh[0]:177 details_used += 1178 else:179 d = self.stale_detail(jid)180 attrs = d.get("attributes") or {}181 if attrs.get("PayClass"):182 job.details["employment_label"] = attrs["PayClass"]183 if attrs.get("PayType"):184 job.details["pay_type_label"] = attrs["PayType"]185 if len(d.get("description") or "") > len(job.description):186 job.description = d["description"]187 out.append(job)188 total = int(data.get("maxCount") or 0)189 start += PAGE_SIZE190 if total and start >= total:191 break192 return out193