# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/lassonde.py # Rôle : Connecteur Industries Lassonde (jus et boissons — Rougemont) — # site carrières maison lassonde.com/fr/emplois (rendu serveur, # JSON-LD sur les pages détail). Remplace l'ancien connecteur ADP # Workforce Now : le centre de carrières ADP de Lassonde ne sert # plus que les postes américains (constaté 2026-08-19, found=0 QC), # les postes québécois sont affichés sur le site maison. # 2026-08-31 : lassonde.com est passé derrière Sucuri Cloudproxy # (challenge JS servi en 307 sans Location → 0 offre en direct) — # fetch via get_resilient(render_js=True) : direct d'abord, # escalade Scrapfly ASP+rendu JS si challenge. # 2026-09-05 : garde anti faux-zéro sur la page liste — le site sert # parfois un 200 sans la section emplois ni marqueur Sucuri connu # (sync à 0 le 2026-09-05 21h09 alors que 32 offres réelles) ; le # conteneur `job-result-default` est maintenant exigé, retry+backoff # sinon erreur explicite dans sync_log. # 2026-09-14 : repli Bright Data sur la page liste — pendant un # épisode Sucuri, la chaîne principale (direct→oxylabs→scrapfly) # sert la même page tronquée à chaque essai (3× « HTTP 200 via # scrapfly, 4515 octets » à 08:17) ; la liste étant rendue serveur, # un vendeur de déblocage différent suffit à passer l'épisode. # Créé : 2026-08-18 Modifié : 2026-09-14 # ============================================================================= from __future__ import annotations import html as _html import os import re import time from ..schema import JobPosting from . import _jsonld from ._resilient import _try_brightdata from .base import BaseConnector MAX_DETAILS = int(os.environ.get("JOBKA_LASSONDE_DETAIL_LIMIT", "40")) _CARD_RE = re.compile( r']*class="job-item-container)(?=[^>]*href="([^"]+)")[^>]*>' r"(.*?)", re.S | re.I) _TITLE_RE = re.compile(r'\s*(.*?)\s*', re.S | re.I) _DATE_RE = re.compile(r'\s*(.*?)\s*', re.S | re.I) _CITY_RE = re.compile(r'
\s*(.*?)\s*
', re.S | re.I) class LassondeConnector(BaseConnector): source_id = "lassonde" ats = "custom" request_delay = 1.0 EMPLOYER = "Industries Lassonde" LIST_URL = "https://www.lassonde.com/fr/emplois/" def _fetch_detail(self, url: str) -> dict: """Le site n'expose pas de JSON-LD JobPosting (seulement un @graph WebPage) : la description vient du corps de la page (après le h1).""" html = self.get_resilient(url, render_js=True).text node = _jsonld.extract_jobposting(html) if node: return _jsonld.jobposting_fields(node) m = re.search(r"(.*?)<(?:footer|form|nav)\b", html, re.S | re.I) if not m: return {} from ..schema import clean_html text = clean_html(m.group(1)) if len(text) < 100: return {} return {"description_html": m.group(1)[:60000]} def _fetch_list(self) -> str: # Le site sert parfois un 200 sans la section emplois et sans aucun # marqueur Sucuri connu (constaté le 2026-09-05 21h09 : sync à 0 offre # alors que la page réelle en listait 32). Une vraie page liste porte # toujours le conteneur `job-result-default` (avec le compteur # filters-list-count), même si l'employeur n'affichait aucune offre : # son absence = challenge/page tronquée, pas une liste vide — on # retente avec backoff puis on lève une erreur explicite plutôt que # de rapporter un faux « 0 offre ». last = "" for attempt in range(3): resp = self.get_resilient(self.LIST_URL, render_js=True) html = resp.text if "job-result-default" in html: return html last = (f"HTTP {resp.status_code} via " f"{getattr(resp, 'via', 'direct')}, {len(html)} octets") # Pendant un épisode Sucuri, toute la chaîne principale peut # servir la même page tronquée « propre en apparence » (200 sans # marqueur connu) à chaque essai — vu le 2026-09-14 08:17 : 3× # « HTTP 200 via scrapfly, 4515 octets » malgré le backoff. La # liste est rendue serveur (render_js ne sert qu'au challenge) : # un vendeur de déblocage différent livre la vraie page pendant # l'épisode (Bright Data vérifié live : 200, 110 ko, 35 cartes, # au moment même où le direct sert le challenge Sucuri). alt = _try_brightdata(self.LIST_URL, 60, None) if alt is not None and "job-result-default" in (alt.text or ""): return alt.text if attempt < 2: time.sleep(10 * (attempt + 1)) raise RuntimeError( "lassonde: page liste sans la section emplois " f"(job-result-default) après 3 essais ({last})") def fetch(self) -> list[JobPosting]: # Sucuri Cloudproxy depuis 2026-08 : rendu JS obligatoire (challenge # cookie), le direct est retenté d'abord à chaque cycle (gratuit) page = self._fetch_list() out: list[JobPosting] = [] details_used = 0 seen: set[str] = set() for url, body in _CARD_RE.findall(page): m_slug = re.search(r"/fr/emplois/([^/]+)/?$", url) if not m_slug: continue slug = m_slug.group(1) if slug in seen: continue seen.add(slug) m_t = _TITLE_RE.search(body) m_d = _DATE_RE.search(body) m_c = _CITY_RE.search(body) city = "" if m_c: city = _html.unescape(re.sub(r"<[^>]+>", " ", m_c.group(1))) city = re.sub(r"\s+", " ", city).split(",")[0].strip() job = JobPosting( source=self.source_id, external_id=slug, url=url, employer=self.EMPLOYER, title=_html.unescape(m_t.group(1)).strip() if m_t else slug.replace("-", " ").capitalize(), city=city, date_posted=_html.unescape(m_d.group(1)).strip() if m_d else None, ats=self.ats, ) key = f"{job.title}|{job.date_posted or ''}" if details_used < MAX_DETAILS: fresh = [False] def _fn(u=url, fresh=fresh): fresh[0] = True return self._fetch_detail(u) fields = self.detail(slug, key, _fn) if fresh[0]: details_used += 1 else: fields = self.stale_detail(slug) if fields: _jsonld.apply_fields(job, fields) out.append(job) return out