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_tbe.py6# Rôle : Classe de plateforme Oracle Taleo Business Edition (TBE) — flux7# RSS public + JSON-LD des pages requisition.jsp (détail)8# Créé : 2026-08-19 Modifié : 2026-08-259# =============================================================================10"""Plateforme Oracle Taleo Business Edition (tbe.taleo.net).1112À ne pas confondre avec Taleo Enterprise (careersection, jobka.connectors.taleo) :13TBE est l'édition PME/parapublic (Sépaq, exo, HEC, Kruger…).1415- liste : GET https://<HOST>.tbe.taleo.net/<INST>/ats/servlet/Rss16 ?org=<ORG>&cws=<CWS>17 -> RSS 2.0 : <item> title / link (requisition.jsp?…&rid=NNN) /18 description (texte résumé, sans lieu ni date)19- détail : GET .../ats/careers/requisition.jsp?org=&cws=&rid=20 -> JSON-LD schema.org/JobPosting complet (description HTML, lieu,21 datePosted, validThrough, employmentType) — cache BD + budget.2223Le lieu n'apparaissant que dans le détail, le filtre Québec est appliqué24après lecture du JSON-LD ; tant que le détail d'une offre n'a pas été visité25(budget épuisé), l'offre d'un connecteur `quebec_only` est différée au cycle26suivant plutôt que publiée sans lieu vérifié.27"""28from __future__ import annotations2930import html as _html31import os32import re3334from ..schema import JobPosting, clean_html, is_quebec_location35from . import _jsonld36from .base import BaseConnector3738MAX_DETAILS = int(os.environ.get("JOBKA_TBE_DETAIL_LIMIT", "60"))3940_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.S | re.I)41_TAG_RE = {t: re.compile(rf"<{t}>(.*?)</{t}>", re.S | re.I)42 for t in ("title", "link", "description")}43_RID_RE = re.compile(r"[?&]rid=(\d+)", re.I)44_QC_REGION = {"QC", "QUÉBEC", "QUEBEC"}45_OG_TITLE_RE = re.compile(46 r'<meta\s+property="og:title"\s+content="([^"]*)"', re.I)474849class TaleoTBEConnector(BaseConnector):50 """Base Taleo Business Edition — sous-classes : définir source_id,51 EMPLOYER, HOST (ex. « tre »), INST (ex. « tre01 »), ORG, CWS."""5253 ats = "taleo_tbe"54 request_delay = 1.05556 EMPLOYER = ""57 HOST = "" # <HOST>.tbe.taleo.net58 INST = "" # /<INST>/ats/...59 ORG = ""60 CWS = "1"61 quebec_only = True62 FORCE_CITY = "" # employeurs mono-campus dont le JSON-LD détourne63 # addressLocality (ex. HEC : nom du service)6465 @property66 def _base(self) -> str:67 return f"https://{self.HOST}.tbe.taleo.net/{self.INST}/ats"6869 def _detail_url(self, rid: str) -> str:70 return (f"{self._base}/careers/requisition.jsp"71 f"?org={self.ORG}&cws={self.CWS}&rid={rid}")7273 def _fetch_detail(self, rid: str) -> dict:74 html = self.get(self._detail_url(rid)).text75 node = _jsonld.extract_jobposting(html)76 if node:77 return _jsonld.jobposting_fields(node)78 return self._fields_from_og(html)7980 @staticmethod81 def _fields_from_og(html: str) -> dict:82 """Secours quand le JSON-LD JobPosting est absent — retrait constaté83 sur toute la plateforme TBE le 2026-08-23 (les connecteurs TBE84 publiaient 0 offre, ex. Kruger : 72 items RSS, 0 retenu). Le lieu85 reste exposé dans og:title : « … – Ville, RÉGION, PAYS »."""86 m = _OG_TITLE_RE.search(html or "")87 if not m:88 return {}89 title = _html.unescape(m.group(1))90 if "–" not in title:91 # variante « pays - région - ville » à trait d'union simple,92 # constatée chez Graymont : « Hiring X # - Canada - QC - Bedford »93 m2 = re.search(r"-\s*(?:Canada|USA|United States|Mexico)\s*-\s*"94 r"([A-Za-z]{2})\s*-\s*([^-]+?)\s*$", title)95 if m2:96 return {"city": m2.group(2).strip(),97 "region_code": m2.group(1).upper()}98 return {}99 place = title.rsplit("–", 1)[1].strip()100 parts = [p.strip() for p in place.split(",") if p.strip()]101 if not parts:102 return {}103 fields: dict = {"city": parts[0]}104 if len(parts) >= 2:105 fields["region_code"] = parts[1].upper()106 return fields107108 def _is_qc(self, fields: dict) -> bool:109 region = (fields.get("region_code") or "").upper()110 if region in _QC_REGION:111 return True112 place = f"{fields.get('city') or ''} {fields.get('region_code') or ''}"113 return bool(place.strip()) and is_quebec_location(place)114115 def fetch(self) -> list[JobPosting]:116 xml = self.get(f"{self._base}/servlet/Rss",117 params={"org": self.ORG, "cws": self.CWS}).text118 out: list[JobPosting] = []119 details_used = 0120 seen: set[str] = set()121 for raw in _ITEM_RE.findall(xml):122 def g(tag: str, raw=raw) -> str:123 m = _TAG_RE[tag].search(raw)124 return _html.unescape(m.group(1).strip()) if m else ""125 link = g("link")126 m_rid = _RID_RE.search(link)127 title = g("title")128 if not m_rid or not title:129 continue130 rid = m_rid.group(1)131 if rid in seen:132 continue133 seen.add(rid)134 job = JobPosting(135 source=self.source_id, external_id=rid,136 url=self._detail_url(rid),137 employer=self.EMPLOYER, title=title,138 ats=self.ats,139 )140 fields: dict = {}141 if details_used < MAX_DETAILS:142 fresh = [False]143144 def _fn(r=rid, fresh=fresh):145 fresh[0] = True146 return self._fetch_detail(r)147148 fields = self.detail(rid, rid, _fn)149 if fresh[0]:150 details_used += 1151 else:152 fields = self.stale_detail(rid) or {}153 if fields:154 if self.quebec_only and not self._is_qc(fields):155 continue156 _jsonld.apply_fields(job, fields)157 elif self.quebec_only:158 # lieu inconnu tant que le détail n'a pas été visité :159 # différer au prochain cycle plutôt que publier sans vérif.160 continue161 if not job.description:162 job.description = clean_html(g("description"))163 if self.FORCE_CITY:164 job.city = self.FORCE_CITY165 out.append(job)166 return out167