SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
7 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
7.2 KB · 160 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/lassonde.py6# Rôle    : Connecteur Industries Lassonde (jus et boissons — Rougemont) —7#           site carrières maison lassonde.com/fr/emplois (rendu serveur,8#           JSON-LD sur les pages détail). Remplace l'ancien connecteur ADP9#           Workforce Now : le centre de carrières ADP de Lassonde ne sert10#           plus que les postes américains (constaté 2026-08-19, found=0 QC),11#           les postes québécois sont affichés sur le site maison.12#           2026-08-31 : lassonde.com est passé derrière Sucuri Cloudproxy13#           (challenge JS servi en 307 sans Location → 0 offre en direct) —14#           fetch via get_resilient(render_js=True) : direct d'abord,15#           escalade Scrapfly ASP+rendu JS si challenge.16#           2026-09-05 : garde anti faux-zéro sur la page liste — le site sert17#           parfois un 200 sans la section emplois ni marqueur Sucuri connu18#           (sync à 0 le 2026-09-05 21h09 alors que 32 offres réelles) ; le19#           conteneur `job-result-default` est maintenant exigé, retry+backoff20#           sinon erreur explicite dans sync_log.21#           2026-09-14 : repli Bright Data sur la page liste — pendant un22#           épisode Sucuri, la chaîne principale (direct→oxylabs→scrapfly)23#           sert la même page tronquée à chaque essai (3× « HTTP 200 via24#           scrapfly, 4515 octets » à 08:17) ; la liste étant rendue serveur,25#           un vendeur de déblocage différent suffit à passer l'épisode.26# Créé    : 2026-08-18   Modifié : 2026-09-1427# =============================================================================28from __future__ import annotations2930import html as _html31import os32import re33import time3435from ..schema import JobPosting36from . import _jsonld37from ._resilient import _try_brightdata38from .base import BaseConnector3940MAX_DETAILS = int(os.environ.get("JOBKA_LASSONDE_DETAIL_LIMIT", "40"))4142_CARD_RE = re.compile(43    r'<a\b(?=[^>]*class="job-item-container)(?=[^>]*href="([^"]+)")[^>]*>'44    r"(.*?)</a>", re.S | re.I)45_TITLE_RE = re.compile(r'<span class="job-title[^"]*">\s*(.*?)\s*</span>',46                       re.S | re.I)47_DATE_RE = re.compile(r'<span class="job-post-date">\s*(.*?)\s*</span>',48                      re.S | re.I)49_CITY_RE = re.compile(r'<div class="text-right">\s*(.*?)\s*</div>',50                      re.S | re.I)515253class LassondeConnector(BaseConnector):54    source_id = "lassonde"55    ats = "custom"56    request_delay = 1.05758    EMPLOYER = "Industries Lassonde"59    LIST_URL = "https://www.lassonde.com/fr/emplois/"6061    def _fetch_detail(self, url: str) -> dict:62        """Le site n'expose pas de JSON-LD JobPosting (seulement un @graph63        WebPage) : la description vient du corps de la page (après le h1)."""64        html = self.get_resilient(url, render_js=True).text65        node = _jsonld.extract_jobposting(html)66        if node:67            return _jsonld.jobposting_fields(node)68        m = re.search(r"</h1>(.*?)<(?:footer|form|nav)\b", html, re.S | re.I)69        if not m:70            return {}71        from ..schema import clean_html72        text = clean_html(m.group(1))73        if len(text) < 100:74            return {}75        return {"description_html": m.group(1)[:60000]}7677    def _fetch_list(self) -> str:78        # Le site sert parfois un 200 sans la section emplois et sans aucun79        # marqueur Sucuri connu (constaté le 2026-09-05 21h09 : sync à 0 offre80        # alors que la page réelle en listait 32). Une vraie page liste porte81        # toujours le conteneur `job-result-default` (avec le compteur82        # filters-list-count), même si l'employeur n'affichait aucune offre :83        # son absence = challenge/page tronquée, pas une liste vide — on84        # retente avec backoff puis on lève une erreur explicite plutôt que85        # de rapporter un faux « 0 offre ».86        last = ""87        for attempt in range(3):88            resp = self.get_resilient(self.LIST_URL, render_js=True)89            html = resp.text90            if "job-result-default" in html:91                return html92            last = (f"HTTP {resp.status_code} via "93                    f"{getattr(resp, 'via', 'direct')}, {len(html)} octets")94            # Pendant un épisode Sucuri, toute la chaîne principale peut95            # servir la même page tronquée « propre en apparence » (200 sans96            # marqueur connu) à chaque essai — vu le 2026-09-14 08:17 : 3×97            # « HTTP 200 via scrapfly, 4515 octets » malgré le backoff. La98            # liste est rendue serveur (render_js ne sert qu'au challenge) :99            # un vendeur de déblocage différent livre la vraie page pendant100            # l'épisode (Bright Data vérifié live : 200, 110 ko, 35 cartes,101            # au moment même où le direct sert le challenge Sucuri).102            alt = _try_brightdata(self.LIST_URL, 60, None)103            if alt is not None and "job-result-default" in (alt.text or ""):104                return alt.text105            if attempt < 2:106                time.sleep(10 * (attempt + 1))107        raise RuntimeError(108            "lassonde: page liste sans la section emplois "109            f"(job-result-default) après 3 essais ({last})")110111    def fetch(self) -> list[JobPosting]:112        # Sucuri Cloudproxy depuis 2026-08 : rendu JS obligatoire (challenge113        # cookie), le direct est retenté d'abord à chaque cycle (gratuit)114        page = self._fetch_list()115        out: list[JobPosting] = []116        details_used = 0117        seen: set[str] = set()118        for url, body in _CARD_RE.findall(page):119            m_slug = re.search(r"/fr/emplois/([^/]+)/?$", url)120            if not m_slug:121                continue122            slug = m_slug.group(1)123            if slug in seen:124                continue125            seen.add(slug)126            m_t = _TITLE_RE.search(body)127            m_d = _DATE_RE.search(body)128            m_c = _CITY_RE.search(body)129            city = ""130            if m_c:131                city = _html.unescape(re.sub(r"<[^>]+>", " ", m_c.group(1)))132                city = re.sub(r"\s+", " ", city).split(",")[0].strip()133            job = JobPosting(134                source=self.source_id, external_id=slug, url=url,135                employer=self.EMPLOYER,136                title=_html.unescape(m_t.group(1)).strip() if m_t137                else slug.replace("-", " ").capitalize(),138                city=city,139                date_posted=_html.unescape(m_d.group(1)).strip()140                if m_d else None,141                ats=self.ats,142            )143            key = f"{job.title}|{job.date_posted or ''}"144            if details_used < MAX_DETAILS:145                fresh = [False]146147                def _fn(u=url, fresh=fresh):148                    fresh[0] = True149                    return self._fetch_detail(u)150151                fields = self.detail(slug, key, _fn)152                if fresh[0]:153                    details_used += 1154            else:155                fields = self.stale_detail(slug)156            if fields:157                _jsonld.apply_fields(job, fields)158            out.append(job)159        return out160