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/successfactors.py6# Rôle : Classe de plateforme SAP SuccessFactors (sites carrières « Career7# Site Builder » / jobs2web) — sitemap RSS ou XML + microdonnées8# Créé : 2026-08-18 Modifié : 2026-09-139# =============================================================================10"""Plateforme SAP SuccessFactors (Career Site Builder, ex-jobs2web).1112Deux formats de « sitemap.xml » selon la génération du site :13- RSS 2.0 : <item> avec titre « Poste (VILLE, PROV, PAYS[, CP]) » ET la14 description HTML complète — tout en une requête ;15- urlset : URLs /job/<Ville>-<titre>-<Prov>-<CP>/<id>/ + lastmod ; la16 description est lue sur la page détail (microdonnées itemprop, avec cache17 BD + budget). Le filtre Québec s'appuie sur la province/le code postal18 présents dans le slug — aucun détail hors Québec n'est visité.19"""20from __future__ import annotations2122import html as _html23import os24import re25import time26import urllib.parse2728import requests2930from ..schema import JobPosting, clean_html, is_quebec_location31from .base import BaseConnector3233MAX_DETAILS = int(os.environ.get("JOBKA_SF_DETAIL_LIMIT", "150"))3435_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.S | re.I)36_TAG_RE = {t: re.compile(rf"<{t}>(.*?)</{t}>", re.S | re.I)37 for t in ("title", "link", "description", "pubDate")}38_LOC_RE = re.compile(r"<loc>([^<]+)</loc>\s*(?:<lastmod>([^<]+)</lastmod>)?",39 re.I)40_RSS_TITLE_RE = re.compile(r"^(.*)\(([^,()]+),\s*([^,()]+?),\s*([A-Z]{2})"41 r"(?:,\s*([^()]+))?\)\s*$", re.S)42_QC_PROV = {"QC", "QUÉBEC", "QUEBEC"}43# « -qc- » au milieu OU « -queb/ » en fin de slug (marqueur de district44# Kiewit) OU code postal G/H/J. Le garde-fou final reste la ville parsée.45_QC_SLUG_RE = re.compile(r"-(qu[ée]b|qc)[-/]|-[ghj]\d[a-z][- ]?\d[a-z]\d/",46 re.I)47_ITEMPROP_RE = re.compile(48 r'<(?:span|div|p)[^>]*itemprop="description"[^>]*>(.*?)'49 r"</(?:span|div|p)>", re.S | re.I)50_META_RE = re.compile(51 r'itemprop="(datePosted|addressLocality|postalCode|employmentType)"'52 r'[^>]*content="([^"]*)"', re.I)535455class SuccessFactorsConnector(BaseConnector):56 """Base SuccessFactors CSB — sous-classes : définir source_id, EMPLOYER,57 BASE (URL du site carrières, ex. https://jobs.bombardier.com)."""5859 ats = "successfactors"60 request_delay = 1.06162 EMPLOYER = ""63 BASE = ""64 quebec_only = True6566 def _cdata(self, s: str) -> str:67 s = (s or "").strip()68 if s.startswith("<![CDATA["):69 s = s[9:]70 if s.endswith("]]>"):71 s = s[:-3]72 return _html.unescape(s.strip())7374 # -- format RSS ------------------------------------------------------------75 def _fetch_rss(self, xml: str) -> list[JobPosting]:76 out = []77 for raw in _ITEM_RE.findall(xml):78 def g(tag: str, raw=raw) -> str:79 m = _TAG_RE[tag].search(raw)80 return self._cdata(m.group(1)) if m else ""81 vals = {t: g(t) for t in _TAG_RE}82 link = vals["link"]83 m_id = re.search(r"/(\d+)/?$", link)84 if not link or not m_id:85 continue86 title, city, prov = vals["title"], "", ""87 m = _RSS_TITLE_RE.match(vals["title"])88 if m:89 title = m.group(1).strip()90 city, prov = m.group(2).strip(), m.group(3).strip()91 if self.quebec_only and not (92 prov.upper() in _QC_PROV93 or (city and is_quebec_location(city))):94 continue95 job = JobPosting(96 source=self.source_id, external_id=m_id.group(1),97 url=link, employer=self.EMPLOYER, title=title,98 description=clean_html(self._cdata(vals["description"])),99 city=city.title() if city.isupper() else city,100 date_posted=vals["pubDate"] or None,101 ats=self.ats,102 )103 out.append(job)104 return out105106 # -- format urlset ---------------------------------------------------------107 def _fetch_detail(self, url: str) -> dict:108 html = self.get(url).text109 d: dict = {}110 chunks = _ITEMPROP_RE.findall(html)111 if chunks:112 d["description"] = clean_html("\n".join(chunks))[:20000]113 for prop, content in _META_RE.findall(html):114 d[prop] = content115 return d116117 def _fetch_urlset(self, xml: str) -> list[JobPosting]:118 out = []119 details_used = 0120 for loc, lastmod in _LOC_RE.findall(xml):121 loc = _html.unescape(loc.strip())122 m = re.search(r"/job/([^/]+)/(\d+)/?$", loc)123 if not m:124 continue125 slug_raw, eid = m.group(1), m.group(2)126 slug = urllib.parse.unquote(slug_raw)127 if self.quebec_only and not _QC_SLUG_RE.search(slug + "/"):128 continue129 # ⚠ Villes à trait d'union (« St-Laurent », « Pointe-Claire ») :130 # le slug utilise « - » à la fois DANS la ville et comme131 # séparateur ville/titre. On découpe avant le décodage (certains132 # tenants encodent %2D) puis on reconnaît le plus long préfixe133 # correspondant à une municipalité connue (répertoire MAMH).134 # Bogue « city='St' » corrigé 2026-08-19.135 parts = [urllib.parse.unquote(p) for p in slug_raw.split("-")]136 from ..regions import city_from_slug_tokens137 city = city_from_slug_tokens(parts) or parts[0]138 # garde-fou : « QC » peut vouloir dire quality control dans un139 # titre (« QC Inspectors », Abilene TX) — la ville tranche140 if self.quebec_only and not is_quebec_location(city):141 continue142 n_city = max(1, len(city.split("-")))143 postal = ""144 m_cp = re.search(r"([GHJ]\d[A-Z])[- ]?(\d[A-Z]\d)", slug, re.I)145 if m_cp:146 postal = f"{m_cp.group(1)}{m_cp.group(2)}".upper()147 job = JobPosting(148 source=self.source_id, external_id=eid, url=loc,149 employer=self.EMPLOYER,150 title=" ".join(parts[n_city:-3]) or slug,151 city=city, postal_code=postal,152 date_posted=lastmod or None,153 ats=self.ats,154 )155 if details_used < MAX_DETAILS:156 fresh = [False]157158 def _fn(u=loc, fresh=fresh):159 fresh[0] = True160 return self._fetch_detail(u)161162 d = self.detail(eid, lastmod or eid, _fn)163 if fresh[0]:164 details_used += 1165 else:166 # budget épuisé : détail périmé plutôt que fiche vidée167 d = self.stale_detail(eid)168 if d:169 if d.get("description"):170 job.description = d["description"]171 if d.get("datePosted"):172 job.date_posted = d["datePosted"]173 if d.get("addressLocality"):174 job.city = d["addressLocality"]175 if d.get("employmentType"):176 job.details["employment_label"] = d["employmentType"]177 out.append(job)178 return out179180 def _fetch_sitemap(self) -> str:181 """sitemap.xml avec garde-fou : une réponse 200 sans racine182 ``<rss>``/``<urlset>`` (page de maintenance ou anti-bot en HTML, corps183 vide) ne doit JAMAIS passer pour un site carrières sans offre — sinon184 le sync enregistre un faux « 0 trouvé, ok » (constaté 2026-09-13 06:14185 sur alstom : found=0 unique contre une médiane de 224, sitemap intact186 au rejeu). Même famille de retry que njoyn._fetch_listing : 3 essais,187 backoff 10-20 s, les pannes réseau transitoires (RequestException)188 sont retentées au lieu d'avorter au premier essai. Un sitemap valide189 mais sans aucune entrée brute (fenêtre de régénération CSB) est aussi190 retenté ; s'il reste vide après 3 essais on l'accepte (tenant191 légitimement sans offre — le garde-fou de dérive suspend de toute192 façon les retraits)."""193 xml = ""194 for attempt in range(3):195 if attempt:196 time.sleep(10 * attempt)197 try:198 xml = self.get(f"{self.BASE}/sitemap.xml").text199 except requests.RequestException:200 if attempt == 2:201 raise202 continue203 head = xml[:300].lower()204 if "<rss" in head:205 if _ITEM_RE.search(xml):206 return xml207 elif "<urlset" in head:208 if _LOC_RE.search(xml):209 return xml210 else:211 # ni RSS ni urlset : réponse illisible, on retente212 continue213 # racine valide mais 0 entrée : régénération probable, on retente214 head = xml[:300].lower()215 if "<rss" in head or "<urlset" in head:216 return xml217 raise RuntimeError(218 f"successfactors {self.source_id}: sitemap illisible après "219 f"3 essais (ni <rss> ni <urlset>, {len(xml)} octets) — sync "220 f"avorté, offres conservées")221222 def fetch(self) -> list[JobPosting]:223 xml = self._fetch_sitemap()224 if "<rss" in xml[:300].lower():225 return self._fetch_rss(xml)226 return self._fetch_urlset(xml)227