SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
5 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
6.6 KB · 167 lines python
Raw Blame History
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