SPB Git

spb/job-ka

Public

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>
Simon-Pierre Boucher committed (Aug 18, 2026) parent 4917d57 Browse files

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
Menu

Theme