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/guichet_emplois.py6# Rôle : Connecteur portail — Guichet-Emplois / Job Bank Canada (offres7# Québec, fenêtre glissante des N derniers jours). AGRÉGATEUR :8# moins autoritaire qu'une page carrière (voir dedup.AGGREGATORS).9# Créé : 2026-08-18 Modifié : 2026-08-3010# =============================================================================11"""Portail Guichet-Emplois (guichetemplois.gc.ca — gouvernement du Canada).1213Recherche HTML rendue côté serveur (27 offres/page) filtrée province=QC et14publication récente (fage). Chaque résultat inclut titre, employeur, ville,15salaire et date — la description est lue sur la page de l'offre (cache BD +16budget). Chaque fiche renvoie vers la page Guichet-Emplois, qui référence17l'offre originale — conformité aux conditions d'utilisation du site.1819robots.txt impose « Crawl-delay: 5 » : request_delay = 5 s, d'où une fenêtre20volontairement bornée (pages et détails limités par synchronisation).21"""22from __future__ import annotations2324import html as _html25import os26import re27import time2829import requests3031from ..schema import JobPosting, clean_html32from .base import BaseConnector3334BASE = "https://www.guichetemplois.gc.ca"35DAYS = os.environ.get("JOBKA_GUICHET_DAYS", "7")36MAX_PAGES = int(os.environ.get("JOBKA_GUICHET_PAGES", "20"))37MAX_DETAILS = int(os.environ.get("JOBKA_GUICHET_DETAIL_LIMIT", "50"))3839# Page « Erreur HTTP 500 » servie avec un statut 200 (vu 2026-08-30, aléatoire40# requête par requête) : sans détection, 0 item → fin de pagination prématurée.41_SOFT500_RE = re.compile(r"<title>\s*(?:Erreur HTTP|HTTP Error)\s*5\d\d", re.I)42_ITEM_RE = re.compile(43 r'href="/rechercheemplois/offredemploi/(\d+)[^"]*"[^>]*class="resultJobItem"'44 r"(.*?)</ul>", re.S)45_TITLE_RE = re.compile(r'<span class="noctitle">\s*([^<]+)', re.S)46_LI_RE = {k: re.compile(rf'<li class="{k}"[^>]*>(.*?)</li>', re.S)47 for k in ("date", "business", "location", "salary")}484950def _txt(s: str) -> str:51 return re.sub(r"\s+", " ",52 _html.unescape(re.sub(r"<[^>]+>", " ", s or ""))).strip()535455class GuichetEmploisConnector(BaseConnector):56 """Portail fédéral — volume élevé, fenêtre glissante des offres récentes."""5758 source_id = "guichet_emplois"59 ats = "portail"60 request_delay = 5.0 # robots.txt : Crawl-delay 56162 EMPLOYER = "" # l'employeur vient de chaque offre6364 def _fetch_detail(self, jid: str) -> dict:65 html = self.get(f"{BASE}/rechercheemplois/offredemploi/{jid}").text66 if _SOFT500_RE.search(html):67 return {} # erreur 200 : rien à cacher, repris au prochain cycle68 d: dict = {}69 m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S | re.I)70 body = m.group(1) if m else ""71 # couper l'entête (titre/meta déjà connus) et le pied « Signaler »72 body = re.split(r"Comment postuler|How to apply|Signaler un problème",73 body)[0]74 if body:75 d["description"] = clean_html(body)[:20000]76 m = re.search(r'property="validThrough"[^>]*content="([^"]+)"', html)77 if m:78 d["date_deadline"] = m.group(1)79 # avantages sociaux : bloc structuré property="jobBenefits" (RDFa)80 m = re.search(r'property="jobBenefits"[^>]*>(.*?)</div>', html, re.S)81 if m:82 items = [_txt(li) for li in re.findall(r"<li[^>]*>(.*?)</li>",83 m.group(1), re.S)]84 items = [i for i in items if i and len(i) <= 120]85 if items:86 d["benefits"] = items[:12]87 # langue de travail exigée : <h4>Langues</h4><p property="qualification">88 m = re.search(r"<h4>\s*Langues?\s*</h4>\s*<p[^>]*>([^<]+)</p>", html)89 if m:90 lang = _txt(m.group(1)).lower()91 d["work_language"] = ("bilingue" if "biling" in lang92 else "fr" if "fran" in lang93 else "en" if "angl" in lang else "")94 return d9596 def fetch(self) -> list[JobPosting]:97 out: list[JobPosting] = []98 details_used = 099 seen: set[str] = set()100 for page in range(1, MAX_PAGES + 1):101 html = ""102 for attempt in (1, 2, 3):103 try:104 html = self.get(f"{BASE}/jobsearch/rechercheemplois",105 params={"fprov": "QC", "sort": "D",106 "fage": DAYS,107 "page": str(page)}).text108 except (requests.ConnectionError, requests.Timeout):109 # le WAF du Guichet coupe parfois la connexion : une110 # reprise après pause, sinon on garde l'acquis partiel111 if attempt == 3:112 if out:113 return out114 raise115 time.sleep(25)116 continue117 if _SOFT500_RE.search(html):118 # « Erreur HTTP 500 » servie en 200 : retenter la page119 html = ""120 if attempt < 3:121 time.sleep(15)122 continue123 break124 if not html:125 continue # erreur persistante sur CETTE page : suivante126 items = _ITEM_RE.findall(html)127 if not items:128 break129 for jid, block in items:130 if jid in seen:131 continue132 seen.add(jid)133 title_m = _TITLE_RE.search(block)134 fields = {}135 for k, rx in _LI_RE.items():136 m = rx.search(block)137 fields[k] = _txt(m.group(1)) if m else ""138 location = re.sub(r"\bEmplacement\b", "", fields["location"])139 location = location.replace("(QC)", "").strip(" ,")140 salary = re.sub(r"^.*?Salaire\s*:?", "", fields["salary"]).strip()141 job = JobPosting(142 source=self.source_id, external_id=jid,143 url=f"{BASE}/rechercheemplois/offredemploi/{jid}",144 employer=fields["business"],145 title=_txt(title_m.group(1)) if title_m else "",146 city=location.split(";")[0].split(",")[0],147 region="Québec",148 location_label=location,149 salary_label=salary,150 date_posted=fields["date"] or None,151 ats=self.ats,152 )153 if not job.title or not job.employer:154 continue155 # clé « v2| » : re-visite progressive (avantages + langue)156 key = f"v2|{fields['date']}|{job.title[:40]}"157 if details_used < MAX_DETAILS:158 fresh = [False]159160 def _fn(j=jid, fresh=fresh):161 fresh[0] = True162 return self._fetch_detail(j)163164 try:165 d = self.detail(jid, key, _fn)166 except (requests.ConnectionError, requests.Timeout):167 d = self.stale_detail(jid) # repris au prochain cycle168 if fresh[0]:169 details_used += 1170 else:171 # budget épuisé : détail périmé plutôt que fiche vidée172 d = self.stale_detail(jid)173 job.description = d.get("description", "")174 job.date_deadline = d.get("date_deadline")175 if d.get("benefits"):176 job.benefits = d["benefits"]177 if d.get("work_language"):178 job.language = d["work_language"]179 out.append(job)180 return out181