HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/taleo.py6# Rôle : Classe de plateforme Oracle Taleo Enterprise (careersection REST)7# — un employeur = une sous-classe (HOST, SECTION, EMPLOYER)8# Créé : 2026-08-18 Modifié : 2026-09-099# =============================================================================10"""Plateforme Oracle Taleo Enterprise.1112Les sections carrières Taleo exposent une API REST publique (celle que le13frontal jobsearch.ftl consomme lui-même) :14- amorce : GET https://<host>.taleo.net/careersection/<section>/jobsearch.ftl15 (cookies de session + numéro de portail « portal=NNN » dans le HTML)16- liste : POST https://<host>.taleo.net/careersection/rest/jobboard/searchjobs17 ?lang=fr&portal=<NNN> body JSON {pageNo, sorting…}18 -> {requisitionList:[{jobId, contestNo, column:[...],19 locationsColumns:[i]}], pagingData:{totalCount, pageSize}}20- détail : la page jobdetail.ftl?job=<contestNo> (description HTML rendue21 côté serveur) — visitée avec cache BD et budget.2223Le contenu des colonnes dépend de la configuration du portail : on repère la24colonne des lieux via `locationsColumns` et on suppose la première colonne25comme titre (constaté sur tous les portails testés).26"""27from __future__ import annotations2829import hashlib30import json31import os32import re33import time3435import requests3637from ..normalize import parse_date38from ..schema import JobPosting, clean_html, is_quebec_location39from .base import BaseConnector4041MAX_DETAILS = int(os.environ.get("JOBKA_TALEO_DETAIL_LIMIT", "120"))4243_DESC_RE = re.compile(44 r'requisitionDescriptionInterface[^>]*>', re.I)454647class TaleoConnector(BaseConnector):48 """Base Taleo — sous-classes : définir source_id, EMPLOYER, HOST, SECTION49 (et PORTAL si le numéro n'apparaît pas dans le HTML d'amorce)."""5051 ats = "taleo"52 request_delay = 1.05354 EMPLOYER = ""55 HOST = "" # <host>.taleo.net56 SECTION = "2" # /careersection/<section>/57 PORTAL = "" # numéro de portail (sinon extrait du HTML)58 LANG = "fr"59 quebec_only = True60 max_pages = 4061 # La dernière colonne du tableau searchjobs dépend de la configuration du62 # portail : date d'affichage (« posted ») pour la plupart, mais DATE63 # LIMITE (« deadline ») chez certains (agnico_eagle). Une date future est64 # de toute façon toujours traitée comme date limite (une date de65 # publication ne peut pas être dans le futur).66 DATE_COL = "posted" # posted | deadline6768 @property69 def _base(self) -> str:70 return f"https://{self.HOST}.taleo.net/careersection"7172 def _req(self, method: str, url: str, **kwargs) -> requests.Response:73 # Les frontaux Taleo renvoient à l'occasion un 5xx isolé qui repart74 # seul (500 constaté le 2026-09-08 23h29 sur textron/searchjobs) :75 # raise_for_status dans base.get/post faisait échouer tout le sync.76 # On retente avec backoff sur 5xx ; les 4xx restent des échecs77 # immédiats (même correctif que la plateforme Workday, b5b4be3).78 last: requests.HTTPError | None = None79 for attempt in range(3):80 try:81 return (self.post if method == "post" else self.get)(82 url, **kwargs)83 except requests.HTTPError as exc:84 if exc.response is None or exc.response.status_code < 500:85 raise86 last = exc87 time.sleep(10 * (attempt + 1))88 raise RuntimeError(f"Taleo 5xx après 3 essais : {last}")8990 def _search_page(self, url: str, body: dict) -> dict:91 # Le frontal Taleo renvoie à l'occasion une page HTML avec statut 20092 # au lieu du JSON de searchjobs (session expirée ou interstitiel93 # passager — « Expecting value: line 2 column 1 » constaté le94 # 2026-09-09 06h29 sur bayshore, reparti seul) : aucun retry existant95 # ne couvre ce cas (le corps n'est pas un 5xx ni un blocage). On96 # retente avec backoff en ré-amorçant la session (nouveaux cookies).97 for attempt in range(3):98 resp = self._req("post", url, json=body,99 headers={"tz": "GMT-04:00"})100 try:101 return resp.json()102 except ValueError:103 if attempt == 2:104 snippet = (resp.text or "")[:120].replace("\n", " ")105 raise RuntimeError(106 f"taleo {self.HOST}: searchjobs non-JSON après 3 "107 f"essais (statut {resp.status_code}) : {snippet!r}")108 time.sleep(10 * (attempt + 1))109 self._bootstrap()110111 def _careers_url(self) -> str:112 return f"{self._base}/{self.SECTION}/jobsearch.ftl?lang={self.LANG}"113114 def _bootstrap(self) -> str:115 """Cookies de session + numéro de portail."""116 html = self._req("get", self._careers_url()).text117 if self.PORTAL:118 return str(self.PORTAL)119 m = re.search(r"portal=(\d+)", html)120 if not m:121 raise RuntimeError(f"taleo {self.HOST}: numéro de portail introuvable")122 return m.group(1)123124 @staticmethod125 def _parse_locations(raw: str) -> list[str]:126 """La colonne des lieux est un JSON sérialisé : '["Québec-Malartic"]'."""127 if not raw:128 return []129 try:130 val = json.loads(raw)131 if isinstance(val, list):132 return [str(v) for v in val]133 except ValueError:134 pass135 return [raw]136137 def _keep(self, locations: list[str]) -> bool:138 if not self.quebec_only:139 return True140 return any(is_quebec_location(l.replace("-", " ")) for l in locations)141142 def _fetch_detail(self, contest_no: str) -> dict:143 html = self._req("get", f"{self._base}/{self.SECTION}/jobdetail.ftl",144 params={"job": contest_no, "lang": self.LANG}).text145 # description : contenu des blocs « requisitionDescriptionInterface »146 # (rendus côté serveur), sinon la zone principale de la page147 chunks = re.findall(148 r'<(?:span|div)[^>]*id="requisitionDescriptionInterface[^"]*"[^>]*>'149 r"(.*?)</(?:span|div)>", html, re.S)150 text = clean_html("\n".join(c for c in chunks if len(c) > 40))151 if not text:152 m = re.search(r'<div[^>]*class="[^"]*mastercontentpanel[^"]*"[^>]*>'153 r"(.*?)<!--", html, re.S)154 text = clean_html(m.group(1)) if m else ""155 return {"description": text}156157 def fetch(self) -> list[JobPosting]:158 portal = self._bootstrap()159 url = (f"{self._base}/rest/jobboard/searchjobs"160 f"?lang={self.LANG}&portal={portal}")161 body_base = {162 "multilineEnabled": False,163 "sortingSelection": {"sortBySelectionParam": "3",164 "ascendingSortingOrder": "false"},165 "fieldData": {"fields": {"KEYWORD": "", "LOCATION": ""},166 "valid": True},167 "filterSelectionParam": {"searchFilterSelections": [168 {"id": "POSTING_DATE", "selectedValues": []},169 {"id": "LOCATION", "selectedValues": []}]},170 "advancedSearchFiltersSelectionParam":171 {"searchFilterSelections": []},172 }173 out: list[JobPosting] = []174 details_used = 0175 page = 1176 seen: set[str] = set()177 while page <= self.max_pages:178 data = self._search_page(url, {**body_base, "pageNo": page})179 reqs = data.get("requisitionList") or []180 if not reqs and page == 1 and self.LANG != "en":181 # section carrières anglophone seulement : rebasculer en « en »182 self.LANG = "en"183 portal = self._bootstrap()184 url = (f"{self._base}/rest/jobboard/searchjobs"185 f"?lang={self.LANG}&portal={portal}")186 continue187 if not reqs:188 break189 for r in reqs:190 cols = r.get("column") or []191 loc_idx = (r.get("locationsColumns") or [1])[0]192 title = str(cols[0]) if cols else ""193 locations = self._parse_locations(194 str(cols[loc_idx])) if len(cols) > loc_idx else []195 date_raw = str(cols[-1]) if len(cols) > 2 else ""196 contest = str(r.get("contestNo") or r.get("jobId") or "")197 if not contest or contest in seen or not title:198 continue199 seen.add(contest)200 if not self._keep(locations):201 continue202 # colonne date : publication ou date limite selon le portail ;203 # une date FUTURE est toujours une date limite (jamais une204 # date de publication) — correctif du bogue agnico_eagle205 date_posted = date_deadline = None206 iso = parse_date(date_raw) if date_raw else None207 if iso:208 import datetime as _dt209 if (self.DATE_COL == "deadline"210 or iso > _dt.date.today().isoformat()):211 date_deadline = iso212 else:213 date_posted = iso214 job = JobPosting(215 source=self.source_id, external_id=contest,216 url=(f"{self._base}/{self.SECTION}/jobdetail.ftl"217 f"?job={contest}&lang={self.LANG}"),218 employer=self.EMPLOYER,219 title=title,220 location_label=locations[0].replace("-", ", ")221 if locations else "",222 date_posted=date_posted, date_deadline=date_deadline,223 ats=self.ats,224 )225 key = hashlib.sha1(226 f"{title}|{date_raw}".encode()).hexdigest()[:12]227 if details_used < MAX_DETAILS:228 fresh = [False]229230 def _fn(c=contest, fresh=fresh):231 fresh[0] = True232 return self._fetch_detail(c)233234 d = self.detail(contest, key, _fn)235 if fresh[0]:236 details_used += 1237 job.description = d.get("description", "")238 out.append(job)239 paging = data.get("pagingData") or {}240 total = int(paging.get("totalCount") or 0)241 size = int(paging.get("pageSize") or len(reqs) or 25)242 if total and page * size >= total:243 break244 page += 1245 return out246