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%
8.0 KB · 181 lines python
Raw Blame History
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