# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/eightfold.py # Rôle : Classe de plateforme Eightfold AI (sites carrières PCSX, # api/pcsx/search + position_details) — un employeur = une # sous-classe de ~8 lignes (DOMAIN, BASE, EMPLOYER) # Créé : 2026-09-25 Modifié : 2026-09-25 # ============================================================================= """Plateforme Eightfold AI (sites carrières « PCSX »). API JSON publique, non authentifiée (constatée sur jobs.autodesk.com, 2026-09-25 — migration d'Autodesk depuis Workday) : - liste : GET https:///api/pcsx/search?domain=&query= &start=N&num=10&sort_by=timestamp -> {data:{count, positions:[{id, atsJobId, displayJobId, name, locations:[…], standardizedLocations:[…] (parallèles), creationTs(epoch), workLocationOption, positionUrl}]}} NB: la pagination est instable (le `count` fluctue d'un appel à l'autre et des lignes reviennent d'une page à l'autre) — on déduplique par `id` et on borne par max_pages. NB: `num` est plafonné à 10 par le serveur. - détail : GET https:///careers/job/ — la page publique est rendue serveur avec un bloc JSON-LD schema.org/JobPosting COMPLET (description avec la section transparence salariale, datePosted, validThrough, employmentType, jobLocation structurés). L'API api/pcsx/position_details existe aussi mais sa `jobDescription` est TRONQUÉE (sans le paragraphe salaire, constaté 2026-09-25 sur autodesk : 9 830 car. vs 11 683 dans le JSON-LD) — elle ne sert que de repli si le JSON-LD disparaît. L'ancien endpoint Eightfold classique (/api/apply/v2/jobs) répond 403 « Not authorized for PCSX » sur ces tenants — ne pas l'utiliser. Les boards listent le monde entier : filtrage client par `is_quebec_location` sur TOUTES les valeurs de `locations` + `standardizedLocations` (les postes télétravail pancanadiens listent le Québec parmi leurs lieux), puis page détail seulement pour les postes québécois (avec cache BD). """ from __future__ import annotations import hashlib import time from datetime import datetime, timezone import requests from ..schema import JobPosting, clean_html, is_quebec_location from ._jsonld import apply_fields, extract_jobposting, jobposting_fields from .base import BaseConnector PAGE_SIZE = 10 # plafond serveur constaté (num>10 renvoie 10) class EightfoldConnector(BaseConnector): """Base Eightfold PCSX — sous-classes : définir source_id, EMPLOYER, BASE (hôte du site carrières), DOMAIN (paramètre `domain` de l'API).""" ats = "eightfold" request_delay = 0.7 EMPLOYER = "" BASE = "" # ex. https://jobs.autodesk.com DOMAIN = "" # ex. autodesk.com max_pages = 200 # garde-fou (200 × 10 = 2000 postes) quebec_only = True def _req_json(self, url: str, **kwargs) -> dict: # même politique que Workday : un 200 non-JSON transitoire ne doit pas # avorter le sync — retries avec backoff avant d'abandonner last: Exception | None = None resp = None for attempt in range(3): try: resp = self.get(url, **kwargs) except requests.HTTPError as exc: if exc.response is None or exc.response.status_code < 500: raise last = exc time.sleep(10 * (attempt + 1)) continue try: return resp.json() except ValueError as exc: last = exc time.sleep(10 * (attempt + 1)) if resp is None: raise RuntimeError(f"Eightfold 5xx après 3 essais : {last}") raise RuntimeError( f"Eightfold non-JSON après 3 essais (HTTP {resp.status_code}, " f"{resp.headers.get('content-type')}) : {last}") @staticmethod def _qc_index(item: dict) -> int | None: """Index du premier lieu québécois du poste (listes `locations` et `standardizedLocations` parallèles), None si aucun.""" locs = item.get("locations") or [] std = item.get("standardizedLocations") or [] for i, raw in enumerate(locs): s = std[i] if i < len(std) else "" if is_quebec_location(raw) or is_quebec_location(s): return i return None def _keep(self, item: dict) -> bool: if not self.quebec_only: return True return self._qc_index(item) is not None def _list_positions(self) -> list[dict]: """Toutes les lignes du board, dédupliquées par id (voir docstring module : la pagination PCSX est instable).""" seen: dict = {} start = 0 count = 0 for _ in range(self.max_pages): data = self._req_json( f"{self.BASE}/api/pcsx/search", params={"domain": self.DOMAIN, "query": "", "start": start, "num": PAGE_SIZE, "sort_by": "timestamp"}) d = data.get("data") or {} positions = d.get("positions") or [] if not positions: break for p in positions: if p.get("id") is not None: seen.setdefault(p["id"], p) count = max(count, int(d.get("count") or 0)) start += PAGE_SIZE if start >= count: break return list(seen.values()) @staticmethod def _pick_qc_address(node: dict, fields: dict) -> None: """Multi-lieux : `jobposting_fields` prend jobLocation[0], qui peut être hors Québec (postes pancanadiens) — re-choisir l'adresse QC et purger ville/adresse si aucune ne l'est (le libellé de liste QC fait alors foi, on n'étiquette pas une offre QC avec une ville d'ailleurs). """ locs = node.get("jobLocation") if not isinstance(locs, list): locs = [locs] if locs else [] for loc in locs: addr = loc.get("address") if isinstance(loc, dict) else None if not isinstance(addr, dict): continue label = f"{addr.get('addressLocality') or ''} " \ f"{addr.get('addressRegion') or ''}" if is_quebec_location(label): fields["city"] = addr.get("addressLocality") or "" fields["postal_code"] = addr.get("postalCode") or "" fields["address"] = addr.get("streetAddress") or "" return for k in ("city", "postal_code", "address", "lat", "lng"): fields.pop(k, None) def _fetch_detail(self, position_id) -> dict: # page publique (JSON-LD complet) ; repli sur l'API position_details # si le balisage disparaît resp = self.get(f"{self.BASE}/careers/job/{position_id}") node = extract_jobposting(resp.text) if node: fields = jobposting_fields(node) self._pick_qc_address(node, fields) # normalize attend « FULL TIME », pas « FULL_TIME » if fields.get("employment_label"): fields["employment_label"] = \ fields["employment_label"].replace("_", " ") return fields data = self._req_json( f"{self.BASE}/api/pcsx/position_details", params={"position_id": position_id, "domain": self.DOMAIN, "hl": "en"}) d = data.get("data") or {} return {"description_html": d.get("jobDescription") or ""} @staticmethod def _location_label(item: dict, qc_idx: int) -> str: """Libellé de lieu : la forme standardisée si elle contient une ville (« Montreal, QC, CA »), sinon la forme brute du board (« Quebec, CAN - Remote » — plus lisible que « QC,CA » nu).""" locs = item.get("locations") or [] std = item.get("standardizedLocations") or [] s = std[qc_idx] if qc_idx < len(std) else "" if s.count(",") >= 2: return s.strip() return (locs[qc_idx] if qc_idx < len(locs) else s).strip() def fetch(self) -> list[JobPosting]: out: list[JobPosting] = [] for item in self._list_positions(): qc_idx = self._qc_index(item) if self.quebec_only else 0 if self.quebec_only and qc_idx is None: continue pid = item.get("id") eid = str(item.get("atsJobId") or item.get("displayJobId") or pid) if not eid or pid is None: continue raw_loc = (item.get("locations") or [""])[qc_idx or 0] created = item.get("creationTs") or 0 job = JobPosting( source=self.source_id, external_id=eid, url=f"{self.BASE}/careers/job/{pid}", employer=self.EMPLOYER, title=item.get("name") or "", location_label=self._location_label(item, qc_idx or 0), ats=self.ats, ) if "remote" in raw_loc.lower(): job.work_mode = "teletravail" elif "hybrid" in raw_loc.lower(): job.work_mode = "hybride" if item.get("department"): job.details["team"] = item["department"] # page détail (JSON-LD complet) avec cache BD — re-visite si la # ligne de liste change (titre/lieux/date de création) key = hashlib.sha1( f"v2|{pid}|{item.get('name', '')}|{created}|{raw_loc}" .encode()).hexdigest()[:12] try: d = self.detail(eid, key, lambda p=pid: self._fetch_detail(p)) except (requests.RequestException, RuntimeError): d = self.stale_detail(eid) if d: apply_fields(job, d) if not job.date_posted and created: job.date_posted = datetime.fromtimestamp( created, tz=timezone.utc).date().isoformat() out.append(job) return out