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/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