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