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%
9.6 KB · 227 lines python
Raw Blame History
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