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/adp.py6# Rôle : Classe de plateforme ADP Workforce Now (centre de carrières) —7# API JSON publique job-requisitions, un employeur = une sous-classe8# Créé : 2026-08-18 Modifié : 2026-08-309# =============================================================================10"""Plateforme ADP Workforce Now (workforcenow.adp.com).1112API JSON publique du centre de carrières :13- liste : GET /mascsr/default/careercenter/public/events/staffing/v1/14 job-requisitions?cid=<CID>&ccId=<CCID>&lang=<fr_CA>&$top=&$skip=15 -> {jobRequisitions:[{itemID, requisitionTitle, postDate,16 payGradeRange, workLevelCode, requisitionLocations,17 customFieldGroup}]}18- détail : GET .../job-requisitions/<itemID>?cid=… -> requisitionDescription19 (HTML). Visité avec cache BD + budget.20"""21from __future__ import annotations2223import os24import time2526from ..schema import JobPosting, clean_html, is_quebec_location27from .base import BaseConnector2829PAGE_SIZE = 10030MAX_DETAILS = int(os.environ.get("JOBKA_ADP_DETAIL_LIMIT", "80"))3132_API = ("https://workforcenow.adp.com/mascsr/default/careercenter/public/"33 "events/staffing/v1/job-requisitions")3435_SALARY_UNIT = {"AN": "year", "ANNUEL": "year", "YR": "year", "YEAR": "year",36 "HR": "hour", "HO": "hour", "HORAIRE": "hour", "HOUR": "hour"}373839class ADPWorkforceNowConnector(BaseConnector):40 """Base ADP WFN — sous-classes : définir source_id, EMPLOYER, CID, CCID."""4142 ats = "adp"43 request_delay = 1.04445 EMPLOYER = ""46 CID = "" # GUID du centre de carrières47 CCID = "19000101_000001" # identifiant du « career center »48 LANG = "fr_CA"49 quebec_only = True50 max_pages = 1051 FORCE_CITY = "" # employeurs mono-site QC dont le babillard n'expose52 # aucune requisitionLocation : ville imposée5354 def _get_json(self, url: str, params: dict) -> dict:55 # ADP renvoie parfois 200 avec un corps vide/HTML (fenêtres de56 # maintenance nocturnes constatées les 29-30 août 2026) : on retente57 # avec backoff avant d'abandonner, et l'erreur finale nomme le statut58 # HTTP + content-type pour que sync_log soit diagnostiquable.59 last: ValueError | None = None60 for attempt in range(3):61 resp = self.get(url, params=params,62 headers={"Accept": "application/json"})63 try:64 return resp.json()65 except ValueError as exc:66 last = exc67 time.sleep(10 * (attempt + 1))68 raise RuntimeError(69 f"ADP non-JSON après 3 essais (HTTP {resp.status_code}, "70 f"{resp.headers.get('content-type')}) : {last}")7172 def _params(self, extra: dict | None = None) -> dict:73 p = {"cid": self.CID, "ccId": self.CCID, "lang": self.LANG,74 "locale": self.LANG}75 p.update(extra or {})76 return p7778 @staticmethod79 def _locations(req: dict) -> list[dict]:80 out = []81 for loc in req.get("requisitionLocations") or []:82 addr = loc.get("address") or {}83 out.append({84 "city": addr.get("cityName") or "",85 "prov": ((addr.get("countrySubdivisionLevel1") or {})86 .get("codeValue") or ""),87 "postal": addr.get("postalCode") or "",88 "label": ((loc.get("nameCode") or {}).get("shortName")89 or "").strip(),90 })91 return out9293 def _keep(self, locations: list[dict]) -> bool:94 if not self.quebec_only:95 return True96 if not locations and self.FORCE_CITY:97 return True98 return any(l["prov"].upper() == "QC"99 or is_quebec_location(f"{l['label']} {l['city']}")100 for l in locations)101102 def _fetch_detail(self, item_id: str) -> dict:103 data = self._get_json(f"{_API}/{item_id}", params=self._params())104 return {"description": clean_html(105 data.get("requisitionDescription") or "")}106107 def fetch(self) -> list[JobPosting]:108 out: list[JobPosting] = []109 details_used = 0110 skip = 0111 for _ in range(self.max_pages):112 data = self._get_json(_API, params=self._params(113 {"$top": str(PAGE_SIZE), "$skip": str(skip)}))114 reqs = data.get("jobRequisitions") or []115 if not reqs and skip == 0 and self.LANG != "en_US":116 # certains centres de carrières ne répondent qu'en anglais117 self.LANG = "en_US"118 continue119 if not reqs:120 break121 for r in reqs:122 locations = self._locations(r)123 if not self._keep(locations):124 continue125 iid = str(r.get("itemID") or "")126 if not iid:127 continue128 qc = next((l for l in locations129 if l["prov"].upper() == "QC"130 or is_quebec_location(f"{l['label']} {l['city']}")),131 locations[0] if locations else132 {"city": self.FORCE_CITY, "postal": "",133 "label": self.FORCE_CITY})134 job = JobPosting(135 source=self.source_id, external_id=iid,136 url=("https://workforcenow.adp.com/mascsr/default/mdf/"137 f"recruitment/recruitment.html?cid={self.CID}"138 f"&ccId={self.CCID}&lang={self.LANG}&jobId={iid}"),139 employer=self.EMPLOYER,140 title=r.get("requisitionTitle") or "",141 city=qc["city"], postal_code=qc["postal"],142 location_label=qc["label"],143 date_posted=r.get("postDate") or None,144 ats=self.ats,145 )146 pay = r.get("payGradeRange") or {}147 lo = ((pay.get("minimumRate") or {}).get("amountValue"))148 hi = ((pay.get("maximumRate") or {}).get("amountValue"))149 if lo:150 unit = None151 for c in ((r.get("customFieldGroup") or {})152 .get("codeFields") or []):153 if ((c.get("nameCode") or {})154 .get("codeValue")) == "SalaryType":155 unit = _SALARY_UNIT.get(156 str(c.get("codeValue") or "").upper()) \157 or _SALARY_UNIT.get(158 str(c.get("shortName") or "").upper())159 job.salary_min = float(lo)160 job.salary_max = float(hi) if hi else None161 job.salary_unit = unit or ("year" if float(lo) > 5000162 else "hour")163 wl = (r.get("workLevelCode") or {}).get("shortName")164 if wl:165 job.details["employment_label"] = wl166 key = (r.get("postDate") or "") + (job.title or "")[:40]167 if details_used < MAX_DETAILS:168 fresh = [False]169170 def _fn(i=iid, fresh=fresh):171 fresh[0] = True172 return self._fetch_detail(i)173174 d = self.detail(iid, key, _fn)175 if fresh[0]:176 details_used += 1177 job.description = d.get("description", "")178 out.append(job)179 if len(reqs) < PAGE_SIZE:180 break181 skip += PAGE_SIZE182 return out183