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