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/jobillico.py6# Rôle : Connecteur portail — Jobillico (grand portail d'emploi québécois).7# AGRÉGATEUR : moins autoritaire qu'une page carrière (dedup).8# Créé : 2026-08-18 Modifié : 2026-09-109# =============================================================================10"""Portail Jobillico (jobillico.com).1112- liste : sitemaps publics sitemap_job_postings_{1..4}.xml (~30 000 offres,13 loc + lastmod) — on suit les N plus récentes (fenêtre glissante) ;14- détail : chaque page offre embarque un JSON-LD schema.org/JobPosting complet15 (employeur, description, lieu, salaire) — cache BD + budget de16 nouvelles visites par synchronisation.1718Le filtre Québec s'applique après lecture du détail (le portail publie aussi19quelques offres hors province).20"""21from __future__ import annotations2223import os24import re2526import requests2728from ..schema import JobPosting, is_quebec_location29from .base import BaseConnector30from . import _jsonld3132BASE = "https://www.jobillico.com"33SITEMAPS = [f"{BASE}/sitemap_job_postings_{i}.xml" for i in range(1, 5)]34MAX_TRACK = int(os.environ.get("JOBKA_JOBILLICO_TRACK", "2500"))35MAX_DETAILS = int(os.environ.get("JOBKA_JOBILLICO_DETAIL_LIMIT", "250"))3637_ENTRY_RE = re.compile(38 r"<loc>([^<]+/(\d+))</loc>\s*(?:<lastmod>([^<]+)</lastmod>)?", re.I)394041class JobillicoConnector(BaseConnector):42 """Portail québécois — fenêtre glissante des offres les plus récentes."""4344 source_id = "jobillico"45 ats = "portail"46 request_delay = 0.64748 def _fetch_detail(self, url: str) -> dict:49 html = self.get(url).text50 node = _jsonld.extract_jobposting(html)51 return _jsonld.jobposting_fields(node) if node else {}5253 def fetch(self) -> list[JobPosting]:54 entries: list[tuple[str, str, str]] = [] # (lastmod, url, id)55 for sm in SITEMAPS:56 try:57 xml = self.get(sm).text58 except Exception:59 continue60 for url, eid, lastmod in _ENTRY_RE.findall(xml):61 entries.append((lastmod or "", url, eid))62 entries.sort(reverse=True) # plus récentes d'abord63 entries = entries[:MAX_TRACK]6465 out: list[JobPosting] = []66 details_used = 067 seen: set[str] = set()68 for lastmod, url, eid in entries:69 if eid in seen:70 continue71 seen.add(eid)72 fields: dict = {}73 # clé « v2| » : re-visite progressive pour capter logo employeur,74 # industrie, scolarité et compétences du JSON-LD (2026-08-19)75 key = f"v2|{lastmod or eid}"76 if details_used < MAX_DETAILS:77 fresh = [False]7879 def _fn(u=url, fresh=fresh):80 fresh[0] = True81 return self._fetch_detail(u)8283 try:84 fields = self.detail(eid, key, _fn)85 except requests.HTTPError as exc:86 code = getattr(exc.response, "status_code", 0)87 if code in (404, 410):88 # offre retirée entre le sitemap et la visite —89 # normal et non bloquant : on saute cette offre90 if fresh[0]:91 details_used += 192 continue93 # erreur serveur ponctuelle (520…) : cache périmé,94 # l'offre sera re-tentée au prochain cycle95 fields = self.stale_detail(eid)96 except requests.TooManyRedirects:97 # URL d'offre en boucle de redirections (canonique morte,98 # constaté 2026-09-10 : « Exceeded 30 redirects » sur une99 # page détail avortait le sync complet) — même traitement100 # que 404/410 : offre sautée, retrait via miss_count101 if fresh[0]:102 details_used += 1103 continue104 except requests.RequestException:105 # toute autre erreur réseau/protocole sur UNE page détail106 # (connexion, timeout, chunked…) : cache périmé, l'offre107 # sera re-tentée au prochain cycle — jamais d'abandon du108 # sync entier pour une seule offre109 fields = self.stale_detail(eid)110 if fresh[0]:111 details_used += 1112 else:113 # budget épuisé : cache même périmé (mieux qu'une offre ratée)114 fields = self.stale_detail(eid)115 if not fields or not fields.get("title"):116 continue117 region = (fields.get("region_code") or "").upper()118 if region and region not in ("QC", "QUÉBEC", "QUEBEC"):119 continue120 if not region and fields.get("city") and \121 not is_quebec_location(fields["city"]):122 continue123 job = JobPosting(source=self.source_id, external_id=eid, url=url,124 title="", ats=self.ats)125 _jsonld.apply_fields(job, fields, override_employer=True)126 if not job.employer:127 # l'employeur figure dans l'URL /fr/offre-d-emploi/<employeur>/…128 m = re.search(r"/offre-d-emploi/([^/]+)/", url)129 job.employer = m.group(1).replace("-", " ").title() if m else ""130 if job.title and job.employer:131 out.append(job)132 return out133