# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/taleo_tbe.py # Rôle : Classe de plateforme Oracle Taleo Business Edition (TBE) — flux # RSS public + JSON-LD des pages requisition.jsp (détail) # Créé : 2026-08-19 Modifié : 2026-08-25 # ============================================================================= """Plateforme Oracle Taleo Business Edition (tbe.taleo.net). À ne pas confondre avec Taleo Enterprise (careersection, jobka.connectors.taleo) : TBE est l'édition PME/parapublic (Sépaq, exo, HEC, Kruger…). - liste : GET https://.tbe.taleo.net//ats/servlet/Rss ?org=&cws= -> RSS 2.0 : title / link (requisition.jsp?…&rid=NNN) / description (texte résumé, sans lieu ni date) - détail : GET .../ats/careers/requisition.jsp?org=&cws=&rid= -> JSON-LD schema.org/JobPosting complet (description HTML, lieu, datePosted, validThrough, employmentType) — cache BD + budget. Le lieu n'apparaissant que dans le détail, le filtre Québec est appliqué après lecture du JSON-LD ; tant que le détail d'une offre n'a pas été visité (budget épuisé), l'offre d'un connecteur `quebec_only` est différée au cycle suivant plutôt que publiée sans lieu vérifié. """ from __future__ import annotations import html as _html import os import re from ..schema import JobPosting, clean_html, is_quebec_location from . import _jsonld from .base import BaseConnector MAX_DETAILS = int(os.environ.get("JOBKA_TBE_DETAIL_LIMIT", "60")) _ITEM_RE = re.compile(r"(.*?)", re.S | re.I) _TAG_RE = {t: re.compile(rf"<{t}>(.*?)", re.S | re.I) for t in ("title", "link", "description")} _RID_RE = re.compile(r"[?&]rid=(\d+)", re.I) _QC_REGION = {"QC", "QUÉBEC", "QUEBEC"} _OG_TITLE_RE = re.compile( r'.tbe.taleo.net INST = "" # //ats/... ORG = "" CWS = "1" quebec_only = True FORCE_CITY = "" # employeurs mono-campus dont le JSON-LD détourne # addressLocality (ex. HEC : nom du service) @property def _base(self) -> str: return f"https://{self.HOST}.tbe.taleo.net/{self.INST}/ats" def _detail_url(self, rid: str) -> str: return (f"{self._base}/careers/requisition.jsp" f"?org={self.ORG}&cws={self.CWS}&rid={rid}") def _fetch_detail(self, rid: str) -> dict: html = self.get(self._detail_url(rid)).text node = _jsonld.extract_jobposting(html) if node: return _jsonld.jobposting_fields(node) return self._fields_from_og(html) @staticmethod def _fields_from_og(html: str) -> dict: """Secours quand le JSON-LD JobPosting est absent — retrait constaté sur toute la plateforme TBE le 2026-08-23 (les connecteurs TBE publiaient 0 offre, ex. Kruger : 72 items RSS, 0 retenu). Le lieu reste exposé dans og:title : « … – Ville, RÉGION, PAYS ».""" m = _OG_TITLE_RE.search(html or "") if not m: return {} title = _html.unescape(m.group(1)) if "–" not in title: # variante « pays - région - ville » à trait d'union simple, # constatée chez Graymont : « Hiring X # - Canada - QC - Bedford » m2 = re.search(r"-\s*(?:Canada|USA|United States|Mexico)\s*-\s*" r"([A-Za-z]{2})\s*-\s*([^-]+?)\s*$", title) if m2: return {"city": m2.group(2).strip(), "region_code": m2.group(1).upper()} return {} place = title.rsplit("–", 1)[1].strip() parts = [p.strip() for p in place.split(",") if p.strip()] if not parts: return {} fields: dict = {"city": parts[0]} if len(parts) >= 2: fields["region_code"] = parts[1].upper() return fields def _is_qc(self, fields: dict) -> bool: region = (fields.get("region_code") or "").upper() if region in _QC_REGION: return True place = f"{fields.get('city') or ''} {fields.get('region_code') or ''}" return bool(place.strip()) and is_quebec_location(place) def fetch(self) -> list[JobPosting]: xml = self.get(f"{self._base}/servlet/Rss", params={"org": self.ORG, "cws": self.CWS}).text out: list[JobPosting] = [] details_used = 0 seen: set[str] = set() for raw in _ITEM_RE.findall(xml): def g(tag: str, raw=raw) -> str: m = _TAG_RE[tag].search(raw) return _html.unescape(m.group(1).strip()) if m else "" link = g("link") m_rid = _RID_RE.search(link) title = g("title") if not m_rid or not title: continue rid = m_rid.group(1) if rid in seen: continue seen.add(rid) job = JobPosting( source=self.source_id, external_id=rid, url=self._detail_url(rid), employer=self.EMPLOYER, title=title, ats=self.ats, ) fields: dict = {} if details_used < MAX_DETAILS: fresh = [False] def _fn(r=rid, fresh=fresh): fresh[0] = True return self._fetch_detail(r) fields = self.detail(rid, rid, _fn) if fresh[0]: details_used += 1 else: fields = self.stale_detail(rid) or {} if fields: if self.quebec_only and not self._is_qc(fields): continue _jsonld.apply_fields(job, fields) elif self.quebec_only: # lieu inconnu tant que le détail n'a pas été visité : # différer au prochain cycle plutôt que publier sans vérif. continue if not job.description: job.description = clean_html(g("description")) if self.FORCE_CITY: job.city = self.FORCE_CITY out.append(job) return out