# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/taleo.py # Rôle : Classe de plateforme Oracle Taleo Enterprise (careersection REST) # — un employeur = une sous-classe (HOST, SECTION, EMPLOYER) # Créé : 2026-08-18 Modifié : 2026-09-09 # ============================================================================= """Plateforme Oracle Taleo Enterprise. Les sections carrières Taleo exposent une API REST publique (celle que le frontal jobsearch.ftl consomme lui-même) : - amorce : GET https://.taleo.net/careersection/
/jobsearch.ftl (cookies de session + numéro de portail « portal=NNN » dans le HTML) - liste : POST https://.taleo.net/careersection/rest/jobboard/searchjobs ?lang=fr&portal= body JSON {pageNo, sorting…} -> {requisitionList:[{jobId, contestNo, column:[...], locationsColumns:[i]}], pagingData:{totalCount, pageSize}} - détail : la page jobdetail.ftl?job= (description HTML rendue côté serveur) — visitée avec cache BD et budget. Le contenu des colonnes dépend de la configuration du portail : on repère la colonne des lieux via `locationsColumns` et on suppose la première colonne comme titre (constaté sur tous les portails testés). """ from __future__ import annotations import hashlib import json import os import re import time import requests from ..normalize import parse_date from ..schema import JobPosting, clean_html, is_quebec_location from .base import BaseConnector MAX_DETAILS = int(os.environ.get("JOBKA_TALEO_DETAIL_LIMIT", "120")) _DESC_RE = re.compile( r'requisitionDescriptionInterface[^>]*>', re.I) class TaleoConnector(BaseConnector): """Base Taleo — sous-classes : définir source_id, EMPLOYER, HOST, SECTION (et PORTAL si le numéro n'apparaît pas dans le HTML d'amorce).""" ats = "taleo" request_delay = 1.0 EMPLOYER = "" HOST = "" # .taleo.net SECTION = "2" # /careersection/
/ PORTAL = "" # numéro de portail (sinon extrait du HTML) LANG = "fr" quebec_only = True max_pages = 40 # La dernière colonne du tableau searchjobs dépend de la configuration du # portail : date d'affichage (« posted ») pour la plupart, mais DATE # LIMITE (« deadline ») chez certains (agnico_eagle). Une date future est # de toute façon toujours traitée comme date limite (une date de # publication ne peut pas être dans le futur). DATE_COL = "posted" # posted | deadline @property def _base(self) -> str: return f"https://{self.HOST}.taleo.net/careersection" def _req(self, method: str, url: str, **kwargs) -> requests.Response: # Les frontaux Taleo renvoient à l'occasion un 5xx isolé qui repart # seul (500 constaté le 2026-09-08 23h29 sur textron/searchjobs) : # raise_for_status dans base.get/post faisait échouer tout le sync. # On retente avec backoff sur 5xx ; les 4xx restent des échecs # immédiats (même correctif que la plateforme Workday, b5b4be3). last: requests.HTTPError | None = None for attempt in range(3): try: return (self.post if method == "post" else 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)) raise RuntimeError(f"Taleo 5xx après 3 essais : {last}") def _search_page(self, url: str, body: dict) -> dict: # Le frontal Taleo renvoie à l'occasion une page HTML avec statut 200 # au lieu du JSON de searchjobs (session expirée ou interstitiel # passager — « Expecting value: line 2 column 1 » constaté le # 2026-09-09 06h29 sur bayshore, reparti seul) : aucun retry existant # ne couvre ce cas (le corps n'est pas un 5xx ni un blocage). On # retente avec backoff en ré-amorçant la session (nouveaux cookies). for attempt in range(3): resp = self._req("post", url, json=body, headers={"tz": "GMT-04:00"}) try: return resp.json() except ValueError: if attempt == 2: snippet = (resp.text or "")[:120].replace("\n", " ") raise RuntimeError( f"taleo {self.HOST}: searchjobs non-JSON après 3 " f"essais (statut {resp.status_code}) : {snippet!r}") time.sleep(10 * (attempt + 1)) self._bootstrap() def _careers_url(self) -> str: return f"{self._base}/{self.SECTION}/jobsearch.ftl?lang={self.LANG}" def _bootstrap(self) -> str: """Cookies de session + numéro de portail.""" html = self._req("get", self._careers_url()).text if self.PORTAL: return str(self.PORTAL) m = re.search(r"portal=(\d+)", html) if not m: raise RuntimeError(f"taleo {self.HOST}: numéro de portail introuvable") return m.group(1) @staticmethod def _parse_locations(raw: str) -> list[str]: """La colonne des lieux est un JSON sérialisé : '["Québec-Malartic"]'.""" if not raw: return [] try: val = json.loads(raw) if isinstance(val, list): return [str(v) for v in val] except ValueError: pass return [raw] def _keep(self, locations: list[str]) -> bool: if not self.quebec_only: return True return any(is_quebec_location(l.replace("-", " ")) for l in locations) def _fetch_detail(self, contest_no: str) -> dict: html = self._req("get", f"{self._base}/{self.SECTION}/jobdetail.ftl", params={"job": contest_no, "lang": self.LANG}).text # description : contenu des blocs « requisitionDescriptionInterface » # (rendus côté serveur), sinon la zone principale de la page chunks = re.findall( r'<(?:span|div)[^>]*id="requisitionDescriptionInterface[^"]*"[^>]*>' r"(.*?)", html, re.S) text = clean_html("\n".join(c for c in chunks if len(c) > 40)) if not text: m = re.search(r']*class="[^"]*mastercontentpanel[^"]*"[^>]*>' r"(.*?)