SPB Git forge

spb/job-ka

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