Portails agrégateurs : Guichet-Emplois, Jobillico, Espresso-Jobs — l offre directe gagne toujours la canonique
- guichet_emplois : recherche HTML fprov=QC fenêtre glissante (fage),
Crawl-delay 5 s respecté, description via page offre (cache + budget)
- jobillico : sitemaps job_postings (~30 k), fenêtre des plus récentes,
JSON-LD JobPosting sur page détail (cache + budget)
- espresso_jobs : ItemList JSON-LD paginé + détail JobPosting (TI/créatif QC)
- dedup.AGGREGATORS = {guichet_emplois, jobillico, espresso_jobs} :
autorité 10 contre 0 pour les pages carrières — les doublons
portail↔direct sont masqués côté portail (dup_of)
- sources.json : entrées portails (aggregator: true) + dépôt direct
Québec Emploi (quebecemploi.gouv.qc.ca) sondé mais non retenu : SPA React
avec API interne authentifiée (clicSÉQUR), pas d accès public propre.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 changed files +348 −2
added
jobka/connectors/espresso_jobs.py
+117 −0
@@ -0,0 +1,117 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/espresso_jobs.py | |
| 6 | +# Rôle : Connecteur portail — Espresso-Jobs (emplois TI / créatif / web au | |
| 7 | +# Québec). AGRÉGATEUR : moins autoritaire qu'une page carrière. | |
| 8 | +# Créé : 2026-08-18 Modifié : 2026-08-18 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Portail spécialisé Espresso-Jobs (espresso-jobs.com — TI et créatif QC). | |
| 11 | + | |
| 12 | +- liste : pages /emploi?page_no=N — chaque page embarque un JSON-LD | |
| 13 | + schema.org/ItemList (URL + titre de ~21 offres) ; | |
| 14 | +- détail : chaque page offre embarque un JSON-LD schema.org/JobPosting | |
| 15 | + (employeur, description, lieu, salaire) — cache BD + budget. | |
| 16 | +""" | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import json | |
| 20 | +import os | |
| 21 | +import re | |
| 22 | + | |
| 23 | +from ..schema import JobPosting, is_quebec_location | |
| 24 | +from .base import BaseConnector | |
| 25 | +from . import _jsonld | |
| 26 | + | |
| 27 | +BASE = "https://www.espresso-jobs.com" | |
| 28 | +MAX_PAGES = int(os.environ.get("JOBKA_ESPRESSO_PAGES", "15")) | |
| 29 | +MAX_DETAILS = int(os.environ.get("JOBKA_ESPRESSO_DETAIL_LIMIT", "100")) | |
| 30 | + | |
| 31 | +_SCRIPT_RE = re.compile( | |
| 32 | + r'<script type="application/ld\+json">(.*?)</script>', re.S | re.I) | |
| 33 | +_ID_RE = re.compile(r"/emploi/(\d+)/") | |
| 34 | + | |
| 35 | + | |
| 36 | +class EspressoJobsConnector(BaseConnector): | |
| 37 | + """Portail spécialisé TI/créatif — offres majoritairement québécoises.""" | |
| 38 | + | |
| 39 | + source_id = "espresso_jobs" | |
| 40 | + ats = "portail" | |
| 41 | + request_delay = 1.0 | |
| 42 | + | |
| 43 | + def _list_page(self, page_no: int) -> list[tuple[str, str, str]]: | |
| 44 | + """[(id, url, titre)] extraits du JSON-LD ItemList de la page.""" | |
| 45 | + html = self.get(f"{BASE}/emploi", | |
| 46 | + params={"page_no": str(page_no)}).text | |
| 47 | + for m in _SCRIPT_RE.finditer(html): | |
| 48 | + try: | |
| 49 | + data = json.loads(m.group(1)) | |
| 50 | + except ValueError: | |
| 51 | + continue | |
| 52 | + if isinstance(data, dict) and data.get("@type") == "ItemList": | |
| 53 | + out = [] | |
| 54 | + for el in data.get("itemListElement") or []: | |
| 55 | + item = el.get("item") or {} | |
| 56 | + url = item.get("url") or item.get("@id") or "" | |
| 57 | + m_id = _ID_RE.search(url) | |
| 58 | + if m_id: | |
| 59 | + out.append((m_id.group(1), url, | |
| 60 | + item.get("name") or "")) | |
| 61 | + return out | |
| 62 | + return [] | |
| 63 | + | |
| 64 | + def _fetch_detail(self, url: str) -> dict: | |
| 65 | + html = self.get(url).text | |
| 66 | + node = _jsonld.extract_jobposting(html) | |
| 67 | + return _jsonld.jobposting_fields(node) if node else {} | |
| 68 | + | |
| 69 | + def fetch(self) -> list[JobPosting]: | |
| 70 | + seen: dict[str, tuple[str, str]] = {} | |
| 71 | + for page in range(1, MAX_PAGES + 1): | |
| 72 | + items = self._list_page(page) | |
| 73 | + if not items: | |
| 74 | + break | |
| 75 | + new = 0 | |
| 76 | + for eid, url, name in items: | |
| 77 | + if eid not in seen: | |
| 78 | + seen[eid] = (url, name) | |
| 79 | + new += 1 | |
| 80 | + if new == 0: | |
| 81 | + break | |
| 82 | + | |
| 83 | + out: list[JobPosting] = [] | |
| 84 | + details_used = 0 | |
| 85 | + for eid, (url, name) in seen.items(): | |
| 86 | + fields: dict = {} | |
| 87 | + if details_used < MAX_DETAILS: | |
| 88 | + fresh = [False] | |
| 89 | + | |
| 90 | + def _fn(u=url, fresh=fresh): | |
| 91 | + fresh[0] = True | |
| 92 | + return self._fetch_detail(u) | |
| 93 | + | |
| 94 | + fields = self.detail(eid, eid, _fn) | |
| 95 | + if fresh[0]: | |
| 96 | + details_used += 1 | |
| 97 | + else: | |
| 98 | + from .. import db | |
| 99 | + if self._detail_con is None: | |
| 100 | + self._detail_con = db.connect() | |
| 101 | + fields = db.get_cached_detail(self._detail_con, self.source_id, | |
| 102 | + eid, eid) or {} | |
| 103 | + job = JobPosting(source=self.source_id, external_id=eid, url=url, | |
| 104 | + title=name, ats=self.ats) | |
| 105 | + if fields: | |
| 106 | + _jsonld.apply_fields(job, fields, override_employer=True) | |
| 107 | + # filtre Québec indulgent : le portail est essentiellement QC — | |
| 108 | + # on n'écarte que les lieux explicitement hors province | |
| 109 | + region = (fields.get("region_code") or "").upper() | |
| 110 | + if region and region not in ("QC", "QUÉBEC", "QUEBEC"): | |
| 111 | + continue | |
| 112 | + if not region and job.city and not is_quebec_location(job.city) \ | |
| 113 | + and "télétravail" not in job.city.lower(): | |
| 114 | + continue | |
| 115 | + if job.title and job.employer: | |
| 116 | + out.append(job) | |
| 117 | + return out | |
added
jobka/connectors/guichet_emplois.py
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/guichet_emplois.py | |
| 6 | +# Rôle : Connecteur portail — Guichet-Emplois / Job Bank Canada (offres | |
| 7 | +# 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-18 | |
| 10 | +# ============================================================================= | |
| 11 | +"""Portail Guichet-Emplois (guichetemplois.gc.ca — gouvernement du Canada). | |
| 12 | + | |
| 13 | +Recherche HTML rendue côté serveur (27 offres/page) filtrée province=QC et | |
| 14 | +publication récente (fage). Chaque résultat inclut titre, employeur, ville, | |
| 15 | +salaire et date — la description est lue sur la page de l'offre (cache BD + | |
| 16 | +budget). Chaque fiche renvoie vers la page Guichet-Emplois, qui référence | |
| 17 | +l'offre originale — conformité aux conditions d'utilisation du site. | |
| 18 | + | |
| 19 | +robots.txt impose « Crawl-delay: 5 » : request_delay = 5 s, d'où une fenêtre | |
| 20 | +volontairement bornée (pages et détails limités par synchronisation). | |
| 21 | +""" | |
| 22 | +from __future__ import annotations | |
| 23 | + | |
| 24 | +import html as _html | |
| 25 | +import os | |
| 26 | +import re | |
| 27 | + | |
| 28 | +from ..schema import JobPosting, clean_html | |
| 29 | +from .base import BaseConnector | |
| 30 | + | |
| 31 | +BASE = "https://www.guichetemplois.gc.ca" | |
| 32 | +DAYS = os.environ.get("JOBKA_GUICHET_DAYS", "7") | |
| 33 | +MAX_PAGES = int(os.environ.get("JOBKA_GUICHET_PAGES", "20")) | |
| 34 | +MAX_DETAILS = int(os.environ.get("JOBKA_GUICHET_DETAIL_LIMIT", "50")) | |
| 35 | + | |
| 36 | +_ITEM_RE = re.compile( | |
| 37 | + r'href="/rechercheemplois/offredemploi/(\d+)[^"]*"[^>]*class="resultJobItem"' | |
| 38 | + r"(.*?)</ul>", re.S) | |
| 39 | +_TITLE_RE = re.compile(r'<span class="noctitle">\s*([^<]+)', re.S) | |
| 40 | +_LI_RE = {k: re.compile(rf'<li class="{k}"[^>]*>(.*?)</li>', re.S) | |
| 41 | + for k in ("date", "business", "location", "salary")} | |
| 42 | + | |
| 43 | + | |
| 44 | +def _txt(s: str) -> str: | |
| 45 | + return re.sub(r"\s+", " ", | |
| 46 | + _html.unescape(re.sub(r"<[^>]+>", " ", s or ""))).strip() | |
| 47 | + | |
| 48 | + | |
| 49 | +class GuichetEmploisConnector(BaseConnector): | |
| 50 | + """Portail fédéral — volume élevé, fenêtre glissante des offres récentes.""" | |
| 51 | + | |
| 52 | + source_id = "guichet_emplois" | |
| 53 | + ats = "portail" | |
| 54 | + request_delay = 5.0 # robots.txt : Crawl-delay 5 | |
| 55 | + | |
| 56 | + EMPLOYER = "" # l'employeur vient de chaque offre | |
| 57 | + | |
| 58 | + def _fetch_detail(self, jid: str) -> dict: | |
| 59 | + html = self.get(f"{BASE}/rechercheemplois/offredemploi/{jid}").text | |
| 60 | + d: dict = {} | |
| 61 | + m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S | re.I) | |
| 62 | + body = m.group(1) if m else "" | |
| 63 | + # couper l'entête (titre/meta déjà connus) et le pied « Signaler » | |
| 64 | + body = re.split(r"Comment postuler|How to apply|Signaler un problème", | |
| 65 | + body)[0] | |
| 66 | + if body: | |
| 67 | + d["description"] = clean_html(body)[:20000] | |
| 68 | + m = re.search(r'property="validThrough"[^>]*content="([^"]+)"', html) | |
| 69 | + if m: | |
| 70 | + d["date_deadline"] = m.group(1) | |
| 71 | + return d | |
| 72 | + | |
| 73 | + def fetch(self) -> list[JobPosting]: | |
| 74 | + out: list[JobPosting] = [] | |
| 75 | + details_used = 0 | |
| 76 | + seen: set[str] = set() | |
| 77 | + for page in range(1, MAX_PAGES + 1): | |
| 78 | + html = self.get(f"{BASE}/jobsearch/rechercheemplois", | |
| 79 | + params={"fprov": "QC", "sort": "D", | |
| 80 | + "fage": DAYS, "page": str(page)}).text | |
| 81 | + items = _ITEM_RE.findall(html) | |
| 82 | + if not items: | |
| 83 | + break | |
| 84 | + for jid, block in items: | |
| 85 | + if jid in seen: | |
| 86 | + continue | |
| 87 | + seen.add(jid) | |
| 88 | + title_m = _TITLE_RE.search(block) | |
| 89 | + fields = {} | |
| 90 | + for k, rx in _LI_RE.items(): | |
| 91 | + m = rx.search(block) | |
| 92 | + fields[k] = _txt(m.group(1)) if m else "" | |
| 93 | + location = re.sub(r"\bEmplacement\b", "", fields["location"]) | |
| 94 | + location = location.replace("(QC)", "").strip(" ,") | |
| 95 | + salary = re.sub(r"^.*?Salaire\s*:?", "", fields["salary"]).strip() | |
| 96 | + job = JobPosting( | |
| 97 | + source=self.source_id, external_id=jid, | |
| 98 | + url=f"{BASE}/rechercheemplois/offredemploi/{jid}", | |
| 99 | + employer=fields["business"], | |
| 100 | + title=_txt(title_m.group(1)) if title_m else "", | |
| 101 | + city=location.split(";")[0].split(",")[0], | |
| 102 | + region="Québec", | |
| 103 | + location_label=location, | |
| 104 | + salary_label=salary, | |
| 105 | + date_posted=fields["date"] or None, | |
| 106 | + ats=self.ats, | |
| 107 | + ) | |
| 108 | + if not job.title or not job.employer: | |
| 109 | + continue | |
| 110 | + key = f"{fields['date']}|{job.title[:40]}" | |
| 111 | + if details_used < MAX_DETAILS: | |
| 112 | + fresh = [False] | |
| 113 | + | |
| 114 | + def _fn(j=jid, fresh=fresh): | |
| 115 | + fresh[0] = True | |
| 116 | + return self._fetch_detail(j) | |
| 117 | + | |
| 118 | + d = self.detail(jid, key, _fn) | |
| 119 | + if fresh[0]: | |
| 120 | + details_used += 1 | |
| 121 | + job.description = d.get("description", "") | |
| 122 | + job.date_deadline = d.get("date_deadline") | |
| 123 | + out.append(job) | |
| 124 | + return out | |
added
jobka/connectors/jobillico.py
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/jobillico.py | |
| 6 | +# 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-08-18 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Portail Jobillico (jobillico.com). | |
| 11 | + | |
| 12 | +- 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 complet | |
| 15 | + (employeur, description, lieu, salaire) — cache BD + budget de | |
| 16 | + nouvelles visites par synchronisation. | |
| 17 | + | |
| 18 | +Le filtre Québec s'applique après lecture du détail (le portail publie aussi | |
| 19 | +quelques offres hors province). | |
| 20 | +""" | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import os | |
| 24 | +import re | |
| 25 | + | |
| 26 | +from ..schema import JobPosting, is_quebec_location | |
| 27 | +from .base import BaseConnector | |
| 28 | +from . import _jsonld | |
| 29 | + | |
| 30 | +BASE = "https://www.jobillico.com" | |
| 31 | +SITEMAPS = [f"{BASE}/sitemap_job_postings_{i}.xml" for i in range(1, 5)] | |
| 32 | +MAX_TRACK = int(os.environ.get("JOBKA_JOBILLICO_TRACK", "2500")) | |
| 33 | +MAX_DETAILS = int(os.environ.get("JOBKA_JOBILLICO_DETAIL_LIMIT", "250")) | |
| 34 | + | |
| 35 | +_ENTRY_RE = re.compile( | |
| 36 | + r"<loc>([^<]+/(\d+))</loc>\s*(?:<lastmod>([^<]+)</lastmod>)?", re.I) | |
| 37 | + | |
| 38 | + | |
| 39 | +class JobillicoConnector(BaseConnector): | |
| 40 | + """Portail québécois — fenêtre glissante des offres les plus récentes.""" | |
| 41 | + | |
| 42 | + source_id = "jobillico" | |
| 43 | + ats = "portail" | |
| 44 | + request_delay = 0.6 | |
| 45 | + | |
| 46 | + def _fetch_detail(self, url: str) -> dict: | |
| 47 | + html = self.get(url).text | |
| 48 | + node = _jsonld.extract_jobposting(html) | |
| 49 | + return _jsonld.jobposting_fields(node) if node else {} | |
| 50 | + | |
| 51 | + def fetch(self) -> list[JobPosting]: | |
| 52 | + entries: list[tuple[str, str, str]] = [] # (lastmod, url, id) | |
| 53 | + for sm in SITEMAPS: | |
| 54 | + try: | |
| 55 | + xml = self.get(sm).text | |
| 56 | + except Exception: | |
| 57 | + continue | |
| 58 | + for url, eid, lastmod in _ENTRY_RE.findall(xml): | |
| 59 | + entries.append((lastmod or "", url, eid)) | |
| 60 | + entries.sort(reverse=True) # plus récentes d'abord | |
| 61 | + entries = entries[:MAX_TRACK] | |
| 62 | + | |
| 63 | + out: list[JobPosting] = [] | |
| 64 | + details_used = 0 | |
| 65 | + seen: set[str] = set() | |
| 66 | + for lastmod, url, eid in entries: | |
| 67 | + if eid in seen: | |
| 68 | + continue | |
| 69 | + seen.add(eid) | |
| 70 | + fields: dict = {} | |
| 71 | + if details_used < MAX_DETAILS: | |
| 72 | + fresh = [False] | |
| 73 | + | |
| 74 | + def _fn(u=url, fresh=fresh): | |
| 75 | + fresh[0] = True | |
| 76 | + return self._fetch_detail(u) | |
| 77 | + | |
| 78 | + fields = self.detail(eid, lastmod or eid, _fn) | |
| 79 | + if fresh[0]: | |
| 80 | + details_used += 1 | |
| 81 | + else: | |
| 82 | + from .. import db | |
| 83 | + if self._detail_con is None: | |
| 84 | + self._detail_con = db.connect() | |
| 85 | + fields = db.get_cached_detail(self._detail_con, self.source_id, | |
| 86 | + eid, lastmod or eid) or {} | |
| 87 | + if not fields or not fields.get("title"): | |
| 88 | + continue | |
| 89 | + region = (fields.get("region_code") or "").upper() | |
| 90 | + if region and region not in ("QC", "QUÉBEC", "QUEBEC"): | |
| 91 | + continue | |
| 92 | + if not region and fields.get("city") and \ | |
| 93 | + not is_quebec_location(fields["city"]): | |
| 94 | + continue | |
| 95 | + job = JobPosting(source=self.source_id, external_id=eid, url=url, | |
| 96 | + title="", ats=self.ats) | |
| 97 | + _jsonld.apply_fields(job, fields, override_employer=True) | |
| 98 | + if not job.employer: | |
| 99 | + # l'employeur figure dans l'URL /fr/offre-d-emploi/<employeur>/… | |
| 100 | + m = re.search(r"/offre-d-emploi/([^/]+)/", url) | |
| 101 | + job.employer = m.group(1).replace("-", " ").title() if m else "" | |
| 102 | + if job.title and job.employer: | |
| 103 | + out.append(job) | |
| 104 | + return out | |
modified
jobka/dedup.py
+3 −2
@@ -33,8 +33,9 @@ SALARY_TOL = 0.04 # ±4 % d'écart toléré entre salaires « identiques » | ||
| 33 | 33 | MAX_BLOCK = 200 # bloc anormalement gros = clé trop générique, ignorer |
| 34 | 34 | |
| 35 | 35 | # Sources agrégatrices (guichets, portails) — moins autoritaires que la page |
| 36 | −# carrière de l'employeur. À enrichir quand des portails s'ajouteront. | |
| 37 | −AGGREGATORS: set[str] = set() | |
| 36 | +# carrière de l'employeur : en cas de doublon, l'offre DIRECTE gagne la | |
| 37 | +# canonique et la copie portail est masquée. | |
| 38 | +AGGREGATORS: set[str] = {"guichet_emplois", "jobillico", "espresso_jobs"} | |
| 38 | 39 | |
| 39 | 40 | # Mots de bruit RH retirés du titre pour le blocage (bilinguisme, urgence…) |
| 40 | 41 | _TITLE_NOISE = re.compile( |
| 41 | 42 | |