SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
2 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

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 1 mo ago (Aug 18, 2026) parent 4917d57

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