SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
5 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
5.6 KB · 133 lines python
Raw Blame History
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