SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
6 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
7.8 KB · 183 lines python
Raw Blame History
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