# =============================================================================
# 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