SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
5 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
15.2 KB · 336 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/workday.py6# Rôle    : Classe de plateforme Workday (API JSON publique wday/cxs) — un7#           employeur = une sous-classe de ~10 lignes (TENANT, HOST, SITE)8# Créé    : 2026-08-17   Modifié : 2026-09-129# =============================================================================10"""Plateforme Workday.1112L'API publique des sites carrières Workday est stable et non authentifiée :13- liste  : POST https://<tenant>.<host>.myworkdayjobs.com/wday/cxs/<tenant>/<site>/jobs14           body {"appliedFacets":{},"limit":20,"offset":N,"searchText":""}15           -> {total, jobPostings:[{title, externalPath, locationsText,16               postedOn, bulletFields:[req_id]}]}17- détail : GET  .../wday/cxs/<tenant>/<site><externalPath>18           -> {jobPostingInfo:{jobDescription(HTML), location, timeType,19               jobReqId, startDate, …}}2021Les sites listent souvent des postes hors Québec : on filtre côté client par22`is_quebec_location`, puis on ne visite la page détail QUE pour les postes23québécois (avec cache BD — le détail n'est re-téléchargé que si la ligne de24liste change).25"""26from __future__ import annotations2728import hashlib29import os30import re31import time3233import requests3435from ..schema import JobPosting, clean_html, is_quebec_location36from .base import BaseConnector3738PAGE_SIZE = 2039MAX_DETAILS = int(os.environ.get("JOBKA_WORKDAY_DETAIL_LIMIT", "300"))40# Échecs de pages détail tolérés par sync : une erreur passagère sur UNE page41# (504 Gateway Timeout constaté le 2026-09-04 sur walmart, ~1090 offres42# perdues pour un seul GET) ne doit pas avorter toute la source ; au-delà du43# budget, la panne est réelle et on la remonte dans sync_log.44DETAIL_MISS_BUDGET = 8454647class WorkdayConnector(BaseConnector):48    """Base Workday — sous-classes : définir source_id, EMPLOYER, TENANT,49    HOST (wd1/wd3/wd10…), SITE, et au besoin surcharger `_keep()`."""5051    ats = "workday"52    request_delay = 0.75354    EMPLOYER = ""55    TENANT = ""56    HOST = "wd3"57    SITE = ""58    max_pages = 60           # garde-fou (60 × 20 = 1200 postes)59    quebec_only = True       # filtrer les lieux hors Québec6061    @property62    def _base(self) -> str:63        return f"https://{self.TENANT}.{self.HOST}.myworkdayjobs.com"6465    def _req_json(self, method: str, url: str, **kwargs) -> dict:66        # Workday renvoie parfois 200 avec un corps vide/HTML (fenêtres de67        # maintenance nocturnes ~03 h EDT, constatées les 29 et 31 août 202668        # sur de nombreux tenants) : on retente avec backoff avant69        # d'abandonner, et l'erreur finale nomme le statut HTTP +70        # content-type pour que sync_log soit diagnostiquable.71        # Même traitement pour les 5xx transitoires (500 isolé constaté le72        # 2026-09-07 sur firststudent.wd1, reparti seul) : raise_for_status73        # dans base.get/post court-circuitait le retry et un seul 500 faisait74        # échouer tout le sync.75        last: Exception | None = None76        resp = None77        for attempt in range(3):78            try:79                resp = (self.post if method == "post" else self.get)(url, **kwargs)80            except requests.HTTPError as exc:81                if exc.response is None or exc.response.status_code < 500:82                    raise83                last = exc84                time.sleep(10 * (attempt + 1))85                continue86            try:87                return resp.json()88            except ValueError as exc:89                last = exc90                time.sleep(10 * (attempt + 1))91        if resp is None:92            raise RuntimeError(f"Workday 5xx après 3 essais : {last}")93        raise RuntimeError(94            f"Workday non-JSON après 3 essais (HTTP {resp.status_code}, "95            f"{resp.headers.get('content-type')}) : {last}")9697    def _keep(self, item: dict) -> bool:98        """Crochet de filtrage d'une ligne de liste (défaut : lieu québécois)."""99        if not self.quebec_only:100            return True101        return is_quebec_location(item.get("locationsText") or "")102103    @staticmethod104    def _find_qc_facet(facets: list) -> dict | None:105        """Cherche une facette « Québec » (Region, State/Province…) dans la106        réponse liste. Workday la fournit sur la plupart des tenants — filtrer107        côté serveur est plus fiable que deviner depuis « Acton Vale » ou108        « 2 Locations », et évite de paginer les postes hors province.109        """110        def walk(param: str | None, values: list) -> dict | None:111            for v in values or []:112                p = v.get("facetParameter") or param113                desc = (v.get("descriptor") or "").strip().lower()114                if desc in ("quebec", "québec", "qc", "quebec, canada") and \115                        v.get("id") and p:116                    return {p: [v["id"]]}117                found = walk(p, v.get("values"))118                if found:119                    return found120            return None121        for f in facets or []:122            found = walk(f.get("facetParameter"), f.get("values"))123            if found:124                return found125        # Repli (tenant jj, constaté 2026-09-10) : certains tenants n'exposent126        # AUCUN niveau province — seulement une facette de lieux plate au127        # niveau ville (« Montréal, Quebec, Canada »). Sans filtre serveur, le128        # scan client plafonné par max_pages rate les offres QC dès que le129        # tenant dépasse le plafond (jj : 1692 postes > 60 × 20). On agrège130        # alors toutes les valeurs de lieu québécoises (mêmes règles que le131        # filtre client is_quebec_location) en un seul filtre serveur.132        by_param: dict[str, list] = {}133134        def collect(param: str | None, values: list) -> None:135            for v in values or []:136                p = v.get("facetParameter") or param137                desc = (v.get("descriptor") or "").strip()138                if p and v.get("id") and is_quebec_location(desc):139                    by_param.setdefault(p, []).append(v["id"])140                collect(p, v.get("values"))141        for f in facets or []:142            collect(f.get("facetParameter"), f.get("values"))143        if by_param:144            param = max(by_param, key=lambda k: len(by_param[k]))145            return {param: by_param[param]}146        return None147148    # facette « Remote Type » (présente sur beaucoup de tenants) : libellé149    # de valeur -> work_mode standard150    _REMOTE_FACET_VALUES = {151        "remote": "teletravail", "fully remote": "teletravail",152        "teletravail": "teletravail", "télétravail": "teletravail",153        "hybrid": "hybride", "hybride": "hybride",154        "on-site": "presentiel", "onsite": "presentiel",155        "on site": "presentiel", "in office": "presentiel",156        "field-based": "presentiel", "presentiel": "presentiel",157        "présentiel": "presentiel",158    }159160    @classmethod161    def _find_remote_facet(cls, facets: list) -> list[tuple[str, dict]] | None:162        """Repère une facette de type « Remote Type » et retourne163        [(work_mode, {facetParameter: [id]}), …] pour requêtes filtrées."""164        for f in facets or []:165            param = f.get("facetParameter") or ""166            if "remote" not in param.lower():167                continue168            out = []169            for v in f.get("values") or []:170                mode = cls._REMOTE_FACET_VALUES.get(171                    (v.get("descriptor") or "").strip().lower())172                if mode and v.get("id"):173                    out.append((mode, {param: [v["id"]]}))174            if out:175                return out176        return None177178    def _list_pages(self, extra_facets: dict | None = None):179        url = f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}/jobs"180        applied: dict = dict(extra_facets or {})181        offset = 0182        total = 0183        unfiltered_total = 0184        first = True185        qc_retried = False186        for _ in range(self.max_pages):187            data = self._req_json("post", url,188                                  json={"appliedFacets": applied,189                                        "limit": PAGE_SIZE,190                                        "offset": offset, "searchText": ""})191            if first:192                first = False193                self._first_facets = data.get("facets") or []194                unfiltered_total = int(data.get("total") or 0)195                if self.quebec_only:196                    qc = self._find_qc_facet(data.get("facets"))197                    if qc:198                        # filtre serveur trouvé : rejouer la pagination avec, et199                        # neutraliser le filtre client (libellés de villes nues)200                        applied = {**qc, **(extra_facets or {})}201                        self._qc_facet = qc202                        self._server_filtered = True203                        continue204            items = data.get("jobPostings") or []205            if not items:206                # Facette factice (tenant dxctechnology, constaté 2026-09-10) :207                # la facette « locations » annonce des postes QC (count > 0)208                # mais le filtre serveur renvoie 0 résultat quelle que soit la209                # valeur. Plutôt que de conclure à 0 offre, abandonner le210                # filtre serveur et revenir au scan client complet.211                if (self._server_filtered and offset == 0 and not qc_retried212                        and unfiltered_total):213                    qc_retried = True214                    applied = dict(extra_facets or {})215                    self._qc_facet = None216                    self._server_filtered = False217                    continue218                return219            yield from items220            offset += PAGE_SIZE221            # Workday ne renvoie `total` que sur la première page quand une222            # facette est appliquée (0 ensuite) : mémoriser la valeur connue223            # et s'arrêter aussi sur page incomplète.224            page_total = int(data.get("total") or 0)225            if page_total:226                total = page_total227            if len(items) < PAGE_SIZE or (total and offset >= total):228                return229230    def _tag_remote_modes(self, by_path: dict) -> None:231        """Enrichit work_mode via la facette « Remote Type » quand le tenant232        l'expose : une pagination filtrée (QC + valeur de facette) par mode,233        les offres retrouvées héritent du mode. Tolérant aux échecs réseau —234        l'enrichissement est optionnel, jamais bloquant."""235        modes = self._find_remote_facet(getattr(self, "_first_facets", []))236        if not modes or not by_path:237            return238        qc = getattr(self, "_qc_facet", None) or {}239        for mode, facet in modes:240            try:241                for item in self._list_pages({**facet, **qc}):242                    job = by_path.get(item.get("externalPath") or "")243                    if job is not None and job.work_mode is None:244                        job.work_mode = mode245            except Exception:246                continue           # facette indisponible : on n'invente rien247248    def _external_id(self, item: dict, path: str) -> str:249        """Identifiant externe d'une ligne de liste — par défaut le premier250        `bulletFields` (= req_id chez la plupart des tenants), sinon le251        suffixe du chemin. Hook surchargeable : certains tenants (ex.252        altusgroup) préfixent les bullets par la région, ce qui provoque des253        collisions d'ID si on prend bullets[0] aveuglément."""254        bullets = item.get("bulletFields") or []255        return str((bullets[0] if bullets else "") or path.rsplit("_", 1)[-1])256257    def _fetch_detail(self, external_path: str) -> dict:258        data = self._req_json("get",259                              f"{self._base}/wday/cxs/{self.TENANT}"260                              f"/{self.SITE}{external_path}")261        info = data.get("jobPostingInfo") or {}262        return {263            "description_html": info.get("jobDescription") or "",264            "location": info.get("location") or "",265            "time_type": info.get("timeType") or "",266            "posted_on": info.get("postedOn") or "",267            "req_id": info.get("jobReqId") or "",268            # lien de candidature canonique (jobPostingInfo.externalUrl)269            "apply_url": info.get("externalUrl") or "",270        }271272    def fetch(self) -> list[JobPosting]:273        out: list[JobPosting] = []274        by_path: dict[str, JobPosting] = {}275        details_used = 0276        detail_misses = 0277        self._server_filtered = False278        self._first_facets: list = []279        self._qc_facet = None280        for item in self._list_pages():281            if not self._server_filtered and not self._keep(item):282                continue283            path = item.get("externalPath") or ""284            eid = self._external_id(item, path)285            if not eid or not path:286                continue287            job = JobPosting(288                source=self.source_id, external_id=str(eid),289                url=f"{self._base}/{self.SITE}{path}",290                employer=self.EMPLOYER,291                title=item.get("title") or "",292                location_label=item.get("locationsText") or "",293                date_posted=item.get("postedOn") or None,294                ats=self.ats,295            )296            # page détail (description, type d'emploi) avec cache + budget —297            # clé « v2| » : re-visite progressive pour capter apply_url298            key = hashlib.sha1(299                f"v2|{path}|{item.get('postedOn','')}".encode()).hexdigest()[:12]300            if details_used < MAX_DETAILS:301                fresh = [False]302303                def _fn(p=path, fresh=fresh):304                    fresh[0] = True305                    return self._fetch_detail(p)306307                # page détail en erreur (5xx passager, coupure, non-JSON) :308                # repli sur le cache périmé et on continue — l'échec n'étant309                # pas mis en cache, la page sera re-tentée au prochain cycle310                try:311                    d = self.detail(str(eid), key, _fn)312                except (requests.RequestException, RuntimeError):313                    detail_misses += 1314                    if detail_misses > DETAIL_MISS_BUDGET:315                        raise316                    d = self.stale_detail(str(eid))317                if fresh[0]:318                    details_used += 1319            else:320                # budget épuisé : détail périmé plutôt que fiche vidée321                d = self.stale_detail(str(eid))322            if d:323                job.description = clean_html(d.get("description_html", ""))324                job.details["employment_label"] = d.get("time_type", "")325                job.apply_url = d.get("apply_url", "") or ""326                # « 2 Locations » : le vrai lieu principal est sur la page détail327                if d.get("location") and (not job.location_label or re.match(328                        r"^\d+\s+locations?$", job.location_label, re.I)):329                    job.location_label = d["location"]330            out.append(job)331            by_path[path] = job332        # work_mode via la facette « Remote Type » (si le tenant l'expose)333        if self.quebec_only:334            self._tag_remote_modes(by_path)335        return out336