SPB Git

spb/job-ka

Public
10.2 KB · 234 lines python
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/eightfold.py6# Rôle    : Classe de plateforme Eightfold AI (sites carrières PCSX,7#           api/pcsx/search + position_details) — un employeur = une8#           sous-classe de ~8 lignes (DOMAIN, BASE, EMPLOYER)9# Créé    : 2026-09-25   Modifié : 2026-09-2510# =============================================================================11"""Plateforme Eightfold AI (sites carrières « PCSX »).1213API JSON publique, non authentifiée (constatée sur jobs.autodesk.com,142026-09-25 — migration d'Autodesk depuis Workday) :1516- liste  : GET https://<base>/api/pcsx/search?domain=<domain>&query=17               &start=N&num=10&sort_by=timestamp18           -> {data:{count, positions:[{id, atsJobId, displayJobId, name,19               locations:[…], standardizedLocations:[…] (parallèles),20               creationTs(epoch), workLocationOption, positionUrl}]}}21           NB: la pagination est instable (le `count` fluctue d'un appel à22           l'autre et des lignes reviennent d'une page à l'autre) — on23           déduplique par `id` et on borne par max_pages.24           NB: `num` est plafonné à 10 par le serveur.25- détail : GET https://<base>/careers/job/<id> — la page publique est rendue26           serveur avec un bloc JSON-LD schema.org/JobPosting COMPLET27           (description avec la section transparence salariale, datePosted,28           validThrough, employmentType, jobLocation structurés). L'API29           api/pcsx/position_details existe aussi mais sa `jobDescription`30           est TRONQUÉE (sans le paragraphe salaire, constaté 2026-09-25 sur31           autodesk : 9 830 car. vs 11 683 dans le JSON-LD) — elle ne sert32           que de repli si le JSON-LD disparaît.3334L'ancien endpoint Eightfold classique (/api/apply/v2/jobs) répond 40335« Not authorized for PCSX » sur ces tenants — ne pas l'utiliser.3637Les boards listent le monde entier : filtrage client par `is_quebec_location`38sur TOUTES les valeurs de `locations` + `standardizedLocations` (les postes39télétravail pancanadiens listent le Québec parmi leurs lieux), puis page40détail seulement pour les postes québécois (avec cache BD).41"""42from __future__ import annotations4344import hashlib45import time46from datetime import datetime, timezone4748import requests4950from ..schema import JobPosting, clean_html, is_quebec_location51from ._jsonld import apply_fields, extract_jobposting, jobposting_fields52from .base import BaseConnector5354PAGE_SIZE = 10          # plafond serveur constaté (num>10 renvoie 10)555657class EightfoldConnector(BaseConnector):58    """Base Eightfold PCSX — sous-classes : définir source_id, EMPLOYER,59    BASE (hôte du site carrières), DOMAIN (paramètre `domain` de l'API)."""6061    ats = "eightfold"62    request_delay = 0.76364    EMPLOYER = ""65    BASE = ""            # ex. https://jobs.autodesk.com66    DOMAIN = ""          # ex. autodesk.com67    max_pages = 200      # garde-fou (200 × 10 = 2000 postes)68    quebec_only = True6970    def _req_json(self, url: str, **kwargs) -> dict:71        # même politique que Workday : un 200 non-JSON transitoire ne doit pas72        # avorter le sync — retries avec backoff avant d'abandonner73        last: Exception | None = None74        resp = None75        for attempt in range(3):76            try:77                resp = self.get(url, **kwargs)78            except requests.HTTPError as exc:79                if exc.response is None or exc.response.status_code < 500:80                    raise81                last = exc82                time.sleep(10 * (attempt + 1))83                continue84            try:85                return resp.json()86            except ValueError as exc:87                last = exc88                time.sleep(10 * (attempt + 1))89        if resp is None:90            raise RuntimeError(f"Eightfold 5xx après 3 essais : {last}")91        raise RuntimeError(92            f"Eightfold non-JSON après 3 essais (HTTP {resp.status_code}, "93            f"{resp.headers.get('content-type')}) : {last}")9495    @staticmethod96    def _qc_index(item: dict) -> int | None:97        """Index du premier lieu québécois du poste (listes `locations` et98        `standardizedLocations` parallèles), None si aucun."""99        locs = item.get("locations") or []100        std = item.get("standardizedLocations") or []101        for i, raw in enumerate(locs):102            s = std[i] if i < len(std) else ""103            if is_quebec_location(raw) or is_quebec_location(s):104                return i105        return None106107    def _keep(self, item: dict) -> bool:108        if not self.quebec_only:109            return True110        return self._qc_index(item) is not None111112    def _list_positions(self) -> list[dict]:113        """Toutes les lignes du board, dédupliquées par id (voir docstring114        module : la pagination PCSX est instable)."""115        seen: dict = {}116        start = 0117        count = 0118        for _ in range(self.max_pages):119            data = self._req_json(120                f"{self.BASE}/api/pcsx/search",121                params={"domain": self.DOMAIN, "query": "", "start": start,122                        "num": PAGE_SIZE, "sort_by": "timestamp"})123            d = data.get("data") or {}124            positions = d.get("positions") or []125            if not positions:126                break127            for p in positions:128                if p.get("id") is not None:129                    seen.setdefault(p["id"], p)130            count = max(count, int(d.get("count") or 0))131            start += PAGE_SIZE132            if start >= count:133                break134        return list(seen.values())135136    @staticmethod137    def _pick_qc_address(node: dict, fields: dict) -> None:138        """Multi-lieux : `jobposting_fields` prend jobLocation[0], qui peut139        être hors Québec (postes pancanadiens) — re-choisir l'adresse QC et140        purger ville/adresse si aucune ne l'est (le libellé de liste QC fait141        alors foi, on n'étiquette pas une offre QC avec une ville d'ailleurs).142        """143        locs = node.get("jobLocation")144        if not isinstance(locs, list):145            locs = [locs] if locs else []146        for loc in locs:147            addr = loc.get("address") if isinstance(loc, dict) else None148            if not isinstance(addr, dict):149                continue150            label = f"{addr.get('addressLocality') or ''} " \151                    f"{addr.get('addressRegion') or ''}"152            if is_quebec_location(label):153                fields["city"] = addr.get("addressLocality") or ""154                fields["postal_code"] = addr.get("postalCode") or ""155                fields["address"] = addr.get("streetAddress") or ""156                return157        for k in ("city", "postal_code", "address", "lat", "lng"):158            fields.pop(k, None)159160    def _fetch_detail(self, position_id) -> dict:161        # page publique (JSON-LD complet) ; repli sur l'API position_details162        # si le balisage disparaît163        resp = self.get(f"{self.BASE}/careers/job/{position_id}")164        node = extract_jobposting(resp.text)165        if node:166            fields = jobposting_fields(node)167            self._pick_qc_address(node, fields)168            # normalize attend « FULL TIME », pas « FULL_TIME »169            if fields.get("employment_label"):170                fields["employment_label"] = \171                    fields["employment_label"].replace("_", " ")172            return fields173        data = self._req_json(174            f"{self.BASE}/api/pcsx/position_details",175            params={"position_id": position_id, "domain": self.DOMAIN,176                    "hl": "en"})177        d = data.get("data") or {}178        return {"description_html": d.get("jobDescription") or ""}179180    @staticmethod181    def _location_label(item: dict, qc_idx: int) -> str:182        """Libellé de lieu : la forme standardisée si elle contient une ville183        (« Montreal, QC, CA »), sinon la forme brute du board (« Quebec,184        CAN - Remote » — plus lisible que « QC,CA » nu)."""185        locs = item.get("locations") or []186        std = item.get("standardizedLocations") or []187        s = std[qc_idx] if qc_idx < len(std) else ""188        if s.count(",") >= 2:189            return s.strip()190        return (locs[qc_idx] if qc_idx < len(locs) else s).strip()191192    def fetch(self) -> list[JobPosting]:193        out: list[JobPosting] = []194        for item in self._list_positions():195            qc_idx = self._qc_index(item) if self.quebec_only else 0196            if self.quebec_only and qc_idx is None:197                continue198            pid = item.get("id")199            eid = str(item.get("atsJobId") or item.get("displayJobId") or pid)200            if not eid or pid is None:201                continue202            raw_loc = (item.get("locations") or [""])[qc_idx or 0]203            created = item.get("creationTs") or 0204            job = JobPosting(205                source=self.source_id, external_id=eid,206                url=f"{self.BASE}/careers/job/{pid}",207                employer=self.EMPLOYER,208                title=item.get("name") or "",209                location_label=self._location_label(item, qc_idx or 0),210                ats=self.ats,211            )212            if "remote" in raw_loc.lower():213                job.work_mode = "teletravail"214            elif "hybrid" in raw_loc.lower():215                job.work_mode = "hybride"216            if item.get("department"):217                job.details["team"] = item["department"]218            # page détail (JSON-LD complet) avec cache BD — re-visite si la219            # ligne de liste change (titre/lieux/date de création)220            key = hashlib.sha1(221                f"v2|{pid}|{item.get('name', '')}|{created}|{raw_loc}"222                .encode()).hexdigest()[:12]223            try:224                d = self.detail(eid, key, lambda p=pid: self._fetch_detail(p))225            except (requests.RequestException, RuntimeError):226                d = self.stale_detail(eid)227            if d:228                apply_fields(job, d)229            if not job.date_posted and created:230                job.date_posted = datetime.fromtimestamp(231                    created, tz=timezone.utc).date().isoformat()232            out.append(job)233        return out234
Files · main
Menu

Theme