# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/jobillico.py # Rôle : Connecteur portail — Jobillico (grand portail d'emploi québécois). # AGRÉGATEUR : moins autoritaire qu'une page carrière (dedup). # Créé : 2026-08-18 Modifié : 2026-09-10 # ============================================================================= """Portail Jobillico (jobillico.com). - liste : sitemaps publics sitemap_job_postings_{1..4}.xml (~30 000 offres, loc + lastmod) — on suit les N plus récentes (fenêtre glissante) ; - détail : chaque page offre embarque un JSON-LD schema.org/JobPosting complet (employeur, description, lieu, salaire) — cache BD + budget de nouvelles visites par synchronisation. Le filtre Québec s'applique après lecture du détail (le portail publie aussi quelques offres hors province). """ from __future__ import annotations import os import re import requests from ..schema import JobPosting, is_quebec_location from .base import BaseConnector from . import _jsonld BASE = "https://www.jobillico.com" SITEMAPS = [f"{BASE}/sitemap_job_postings_{i}.xml" for i in range(1, 5)] MAX_TRACK = int(os.environ.get("JOBKA_JOBILLICO_TRACK", "2500")) MAX_DETAILS = int(os.environ.get("JOBKA_JOBILLICO_DETAIL_LIMIT", "250")) _ENTRY_RE = re.compile( r"([^<]+/(\d+))\s*(?:([^<]+))?", re.I) class JobillicoConnector(BaseConnector): """Portail québécois — fenêtre glissante des offres les plus récentes.""" source_id = "jobillico" ats = "portail" request_delay = 0.6 def _fetch_detail(self, url: str) -> dict: html = self.get(url).text node = _jsonld.extract_jobposting(html) return _jsonld.jobposting_fields(node) if node else {} def fetch(self) -> list[JobPosting]: entries: list[tuple[str, str, str]] = [] # (lastmod, url, id) for sm in SITEMAPS: try: xml = self.get(sm).text except Exception: continue for url, eid, lastmod in _ENTRY_RE.findall(xml): entries.append((lastmod or "", url, eid)) entries.sort(reverse=True) # plus récentes d'abord entries = entries[:MAX_TRACK] out: list[JobPosting] = [] details_used = 0 seen: set[str] = set() for lastmod, url, eid in entries: if eid in seen: continue seen.add(eid) fields: dict = {} # clé « v2| » : re-visite progressive pour capter logo employeur, # industrie, scolarité et compétences du JSON-LD (2026-08-19) key = f"v2|{lastmod or eid}" if details_used < MAX_DETAILS: fresh = [False] def _fn(u=url, fresh=fresh): fresh[0] = True return self._fetch_detail(u) try: fields = self.detail(eid, key, _fn) except requests.HTTPError as exc: code = getattr(exc.response, "status_code", 0) if code in (404, 410): # offre retirée entre le sitemap et la visite — # normal et non bloquant : on saute cette offre if fresh[0]: details_used += 1 continue # erreur serveur ponctuelle (520…) : cache périmé, # l'offre sera re-tentée au prochain cycle fields = self.stale_detail(eid) except requests.TooManyRedirects: # URL d'offre en boucle de redirections (canonique morte, # constaté 2026-09-10 : « Exceeded 30 redirects » sur une # page détail avortait le sync complet) — même traitement # que 404/410 : offre sautée, retrait via miss_count if fresh[0]: details_used += 1 continue except requests.RequestException: # toute autre erreur réseau/protocole sur UNE page détail # (connexion, timeout, chunked…) : cache périmé, l'offre # sera re-tentée au prochain cycle — jamais d'abandon du # sync entier pour une seule offre fields = self.stale_detail(eid) if fresh[0]: details_used += 1 else: # budget épuisé : cache même périmé (mieux qu'une offre ratée) fields = self.stale_detail(eid) if not fields or not fields.get("title"): continue region = (fields.get("region_code") or "").upper() if region and region not in ("QC", "QUÉBEC", "QUEBEC"): continue if not region and fields.get("city") and \ not is_quebec_location(fields["city"]): continue job = JobPosting(source=self.source_id, external_id=eid, url=url, title="", ats=self.ats) _jsonld.apply_fields(job, fields, override_employer=True) if not job.employer: # l'employeur figure dans l'URL /fr/offre-d-emploi//… m = re.search(r"/offre-d-emploi/([^/]+)/", url) job.employer = m.group(1).replace("-", " ").title() if m else "" if job.title and job.employer: out.append(job) return out