# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/successfactors.py # Rôle : Classe de plateforme SAP SuccessFactors (sites carrières « Career # Site Builder » / jobs2web) — sitemap RSS ou XML + microdonnées # Créé : 2026-08-18 Modifié : 2026-09-13 # ============================================================================= """Plateforme SAP SuccessFactors (Career Site Builder, ex-jobs2web). Deux formats de « sitemap.xml » selon la génération du site : - RSS 2.0 : avec titre « Poste (VILLE, PROV, PAYS[, CP]) » ET la description HTML complète — tout en une requête ; - urlset : URLs /job/---// + lastmod ; la description est lue sur la page détail (microdonnées itemprop, avec cache BD + budget). Le filtre Québec s'appuie sur la province/le code postal présents dans le slug — aucun détail hors Québec n'est visité. """ from __future__ import annotations import html as _html import os import re import time import urllib.parse import requests from ..schema import JobPosting, clean_html, is_quebec_location from .base import BaseConnector MAX_DETAILS = int(os.environ.get("JOBKA_SF_DETAIL_LIMIT", "150")) _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", "pubDate")} _LOC_RE = re.compile(r"([^<]+)\s*(?:([^<]+))?", re.I) _RSS_TITLE_RE = re.compile(r"^(.*)\(([^,()]+),\s*([^,()]+?),\s*([A-Z]{2})" r"(?:,\s*([^()]+))?\)\s*$", re.S) _QC_PROV = {"QC", "QUÉBEC", "QUEBEC"} # « -qc- » au milieu OU « -queb/ » en fin de slug (marqueur de district # Kiewit) OU code postal G/H/J. Le garde-fou final reste la ville parsée. _QC_SLUG_RE = re.compile(r"-(qu[ée]b|qc)[-/]|-[ghj]\d[a-z][- ]?\d[a-z]\d/", re.I) _ITEMPROP_RE = re.compile( r'<(?:span|div|p)[^>]*itemprop="description"[^>]*>(.*?)' r"", re.S | re.I) _META_RE = re.compile( r'itemprop="(datePosted|addressLocality|postalCode|employmentType)"' r'[^>]*content="([^"]*)"', re.I) class SuccessFactorsConnector(BaseConnector): """Base SuccessFactors CSB — sous-classes : définir source_id, EMPLOYER, BASE (URL du site carrières, ex. https://jobs.bombardier.com).""" ats = "successfactors" request_delay = 1.0 EMPLOYER = "" BASE = "" quebec_only = True def _cdata(self, s: str) -> str: s = (s or "").strip() if s.startswith(""): s = s[:-3] return _html.unescape(s.strip()) # -- format RSS ------------------------------------------------------------ def _fetch_rss(self, xml: str) -> list[JobPosting]: out = [] for raw in _ITEM_RE.findall(xml): def g(tag: str, raw=raw) -> str: m = _TAG_RE[tag].search(raw) return self._cdata(m.group(1)) if m else "" vals = {t: g(t) for t in _TAG_RE} link = vals["link"] m_id = re.search(r"/(\d+)/?$", link) if not link or not m_id: continue title, city, prov = vals["title"], "", "" m = _RSS_TITLE_RE.match(vals["title"]) if m: title = m.group(1).strip() city, prov = m.group(2).strip(), m.group(3).strip() if self.quebec_only and not ( prov.upper() in _QC_PROV or (city and is_quebec_location(city))): continue job = JobPosting( source=self.source_id, external_id=m_id.group(1), url=link, employer=self.EMPLOYER, title=title, description=clean_html(self._cdata(vals["description"])), city=city.title() if city.isupper() else city, date_posted=vals["pubDate"] or None, ats=self.ats, ) out.append(job) return out # -- format urlset --------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: html = self.get(url).text d: dict = {} chunks = _ITEMPROP_RE.findall(html) if chunks: d["description"] = clean_html("\n".join(chunks))[:20000] for prop, content in _META_RE.findall(html): d[prop] = content return d def _fetch_urlset(self, xml: str) -> list[JobPosting]: out = [] details_used = 0 for loc, lastmod in _LOC_RE.findall(xml): loc = _html.unescape(loc.strip()) m = re.search(r"/job/([^/]+)/(\d+)/?$", loc) if not m: continue slug_raw, eid = m.group(1), m.group(2) slug = urllib.parse.unquote(slug_raw) if self.quebec_only and not _QC_SLUG_RE.search(slug + "/"): continue # ⚠ Villes à trait d'union (« St-Laurent », « Pointe-Claire ») : # le slug utilise « - » à la fois DANS la ville et comme # séparateur ville/titre. On découpe avant le décodage (certains # tenants encodent %2D) puis on reconnaît le plus long préfixe # correspondant à une municipalité connue (répertoire MAMH). # Bogue « city='St' » corrigé 2026-08-19. parts = [urllib.parse.unquote(p) for p in slug_raw.split("-")] from ..regions import city_from_slug_tokens city = city_from_slug_tokens(parts) or parts[0] # garde-fou : « QC » peut vouloir dire quality control dans un # titre (« QC Inspectors », Abilene TX) — la ville tranche if self.quebec_only and not is_quebec_location(city): continue n_city = max(1, len(city.split("-"))) postal = "" m_cp = re.search(r"([GHJ]\d[A-Z])[- ]?(\d[A-Z]\d)", slug, re.I) if m_cp: postal = f"{m_cp.group(1)}{m_cp.group(2)}".upper() job = JobPosting( source=self.source_id, external_id=eid, url=loc, employer=self.EMPLOYER, title=" ".join(parts[n_city:-3]) or slug, city=city, postal_code=postal, date_posted=lastmod or None, ats=self.ats, ) if details_used < MAX_DETAILS: fresh = [False] def _fn(u=loc, fresh=fresh): fresh[0] = True return self._fetch_detail(u) d = self.detail(eid, lastmod or eid, _fn) if fresh[0]: details_used += 1 else: # budget épuisé : détail périmé plutôt que fiche vidée d = self.stale_detail(eid) if d: if d.get("description"): job.description = d["description"] if d.get("datePosted"): job.date_posted = d["datePosted"] if d.get("addressLocality"): job.city = d["addressLocality"] if d.get("employmentType"): job.details["employment_label"] = d["employmentType"] out.append(job) return out def _fetch_sitemap(self) -> str: """sitemap.xml avec garde-fou : une réponse 200 sans racine ````/```` (page de maintenance ou anti-bot en HTML, corps vide) ne doit JAMAIS passer pour un site carrières sans offre — sinon le sync enregistre un faux « 0 trouvé, ok » (constaté 2026-09-13 06:14 sur alstom : found=0 unique contre une médiane de 224, sitemap intact au rejeu). Même famille de retry que njoyn._fetch_listing : 3 essais, backoff 10-20 s, les pannes réseau transitoires (RequestException) sont retentées au lieu d'avorter au premier essai. Un sitemap valide mais sans aucune entrée brute (fenêtre de régénération CSB) est aussi retenté ; s'il reste vide après 3 essais on l'accepte (tenant légitimement sans offre — le garde-fou de dérive suspend de toute façon les retraits).""" xml = "" for attempt in range(3): if attempt: time.sleep(10 * attempt) try: xml = self.get(f"{self.BASE}/sitemap.xml").text except requests.RequestException: if attempt == 2: raise continue head = xml[:300].lower() if " ni , {len(xml)} octets) — sync " f"avorté, offres conservées") def fetch(self) -> list[JobPosting]: xml = self._fetch_sitemap() if "