SPB Git forge

spb/job-ka

Public
233commits 1branches 0releases
38.8 MBsize
maindefault branch
5 h agolast push
HTML 82% Python 14.7% TypeScript 1.9% CSS 1% JavaScript 0.5%
8.6 KB · 193 lines python
Raw Blame History
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