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 | MAX_BLOCK = 200 # bloc anormalement gros = clé trop générique, ignorer | 33 | MAX_BLOCK = 200 # bloc anormalement gros = clé trop générique, ignorer | ||
| 34 | 34 | ||||
| 35 | # Sources agrégatrices (guichets, portails) — moins autoritaires que la page | 35 | # Sources agrégatrices (guichets, portails) — moins autoritaires que la page | ||
| 36 | −# carrière de l'employeur. À enrichir quand des portails s'ajouteront. | 36 | +# carrière de l'employeur : en cas de doublon, l'offre DIRECTE gagne la | ||
| 37 | −AGGREGATORS: set[str] = set() | 37 | +# canonique et la copie portail est masquée. | ||
| 38 | +AGGREGATORS: set[str] = {"guichet_emplois", "jobillico", "espresso_jobs"} | ||||
| 38 | 39 | ||||
| 39 | # Mots de bruit RH retirés du titre pour le blocage (bilinguisme, urgence…) | 40 | # Mots de bruit RH retirés du titre pour le blocage (bilinguisme, urgence…) | ||
| 40 | _TITLE_NOISE = re.compile( | 41 | _TITLE_NOISE = re.compile( | ||
| 41 | 42 | ||||