# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/guichet_emplois.py # Rôle : Connecteur portail — Guichet-Emplois / Job Bank Canada (offres # Québec, fenêtre glissante des N derniers jours). AGRÉGATEUR : # moins autoritaire qu'une page carrière (voir dedup.AGGREGATORS). # Créé : 2026-08-18 Modifié : 2026-08-30 # ============================================================================= """Portail Guichet-Emplois (guichetemplois.gc.ca — gouvernement du Canada). Recherche HTML rendue côté serveur (27 offres/page) filtrée province=QC et publication récente (fage). Chaque résultat inclut titre, employeur, ville, salaire et date — la description est lue sur la page de l'offre (cache BD + budget). Chaque fiche renvoie vers la page Guichet-Emplois, qui référence l'offre originale — conformité aux conditions d'utilisation du site. robots.txt impose « Crawl-delay: 5 » : request_delay = 5 s, d'où une fenêtre volontairement bornée (pages et détails limités par synchronisation). """ from __future__ import annotations import html as _html import os import re import time import requests from ..schema import JobPosting, clean_html from .base import BaseConnector BASE = "https://www.guichetemplois.gc.ca" DAYS = os.environ.get("JOBKA_GUICHET_DAYS", "7") MAX_PAGES = int(os.environ.get("JOBKA_GUICHET_PAGES", "20")) MAX_DETAILS = int(os.environ.get("JOBKA_GUICHET_DETAIL_LIMIT", "50")) # Page « Erreur HTTP 500 » servie avec un statut 200 (vu 2026-08-30, aléatoire # requête par requête) : sans détection, 0 item → fin de pagination prématurée. _SOFT500_RE = re.compile(r"\s*(?:Erreur HTTP|HTTP Error)\s*5\d\d", re.I) _ITEM_RE = re.compile( r'href="/rechercheemplois/offredemploi/(\d+)[^"]*"[^>]*class="resultJobItem"' r"(.*?)</ul>", re.S) _TITLE_RE = re.compile(r'<span class="noctitle">\s*([^<]+)', re.S) _LI_RE = {k: re.compile(rf'<li class="{k}"[^>]*>(.*?)</li>', re.S) for k in ("date", "business", "location", "salary")} def _txt(s: str) -> str: return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s or ""))).strip() class GuichetEmploisConnector(BaseConnector): """Portail fédéral — volume élevé, fenêtre glissante des offres récentes.""" source_id = "guichet_emplois" ats = "portail" request_delay = 5.0 # robots.txt : Crawl-delay 5 EMPLOYER = "" # l'employeur vient de chaque offre def _fetch_detail(self, jid: str) -> dict: html = self.get(f"{BASE}/rechercheemplois/offredemploi/{jid}").text if _SOFT500_RE.search(html): return {} # erreur 200 : rien à cacher, repris au prochain cycle d: dict = {} m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S | re.I) body = m.group(1) if m else "" # couper l'entête (titre/meta déjà connus) et le pied « Signaler » body = re.split(r"Comment postuler|How to apply|Signaler un problème", body)[0] if body: d["description"] = clean_html(body)[:20000] m = re.search(r'property="validThrough"[^>]*content="([^"]+)"', html) if m: d["date_deadline"] = m.group(1) # avantages sociaux : bloc structuré property="jobBenefits" (RDFa) m = re.search(r'property="jobBenefits"[^>]*>(.*?)</div>', html, re.S) if m: items = [_txt(li) for li in re.findall(r"<li[^>]*>(.*?)</li>", m.group(1), re.S)] items = [i for i in items if i and len(i) <= 120] if items: d["benefits"] = items[:12] # langue de travail exigée : <h4>Langues</h4><p property="qualification"> m = re.search(r"<h4>\s*Langues?\s*</h4>\s*<p[^>]*>([^<]+)</p>", html) if m: lang = _txt(m.group(1)).lower() d["work_language"] = ("bilingue" if "biling" in lang else "fr" if "fran" in lang else "en" if "angl" in lang else "") return d def fetch(self) -> list[JobPosting]: out: list[JobPosting] = [] details_used = 0 seen: set[str] = set() for page in range(1, MAX_PAGES + 1): html = "" for attempt in (1, 2, 3): try: html = self.get(f"{BASE}/jobsearch/rechercheemplois", params={"fprov": "QC", "sort": "D", "fage": DAYS, "page": str(page)}).text except (requests.ConnectionError, requests.Timeout): # le WAF du Guichet coupe parfois la connexion : une # reprise après pause, sinon on garde l'acquis partiel if attempt == 3: if out: return out raise time.sleep(25) continue if _SOFT500_RE.search(html): # « Erreur HTTP 500 » servie en 200 : retenter la page html = "" if attempt < 3: time.sleep(15) continue break if not html: continue # erreur persistante sur CETTE page : suivante items = _ITEM_RE.findall(html) if not items: break for jid, block in items: if jid in seen: continue seen.add(jid) title_m = _TITLE_RE.search(block) fields = {} for k, rx in _LI_RE.items(): m = rx.search(block) fields[k] = _txt(m.group(1)) if m else "" location = re.sub(r"\bEmplacement\b", "", fields["location"]) location = location.replace("(QC)", "").strip(" ,") salary = re.sub(r"^.*?Salaire\s*:?", "", fields["salary"]).strip() job = JobPosting( source=self.source_id, external_id=jid, url=f"{BASE}/rechercheemplois/offredemploi/{jid}", employer=fields["business"], title=_txt(title_m.group(1)) if title_m else "", city=location.split(";")[0].split(",")[0], region="Québec", location_label=location, salary_label=salary, date_posted=fields["date"] or None, ats=self.ats, ) if not job.title or not job.employer: continue # clé « v2| » : re-visite progressive (avantages + langue) key = f"v2|{fields['date']}|{job.title[:40]}" if details_used < MAX_DETAILS: fresh = [False] def _fn(j=jid, fresh=fresh): fresh[0] = True return self._fetch_detail(j) try: d = self.detail(jid, key, _fn) except (requests.ConnectionError, requests.Timeout): d = self.stale_detail(jid) # repris au prochain cycle if fresh[0]: details_used += 1 else: # budget épuisé : détail périmé plutôt que fiche vidée d = self.stale_detail(jid) job.description = d.get("description", "") job.date_deadline = d.get("date_deadline") if d.get("benefits"): job.benefits = d["benefits"] if d.get("work_language"): job.language = d["work_language"] out.append(job) return out