# =============================================================================
# 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