# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/espresso_jobs.py # Rôle : Connecteur portail — Espresso-Jobs (emplois TI / créatif / web au # Québec). AGRÉGATEUR : moins autoritaire qu'une page carrière. # Créé : 2026-08-18 Modifié : 2026-08-18 # ============================================================================= """Portail spécialisé Espresso-Jobs (espresso-jobs.com — TI et créatif QC). - liste : pages /emploi?page_no=N — chaque page embarque un JSON-LD schema.org/ItemList (URL + titre de ~21 offres) ; - détail : chaque page offre embarque un JSON-LD schema.org/JobPosting (employeur, description, lieu, salaire) — cache BD + budget. """ from __future__ import annotations import json import os import re from ..schema import JobPosting, is_quebec_location from .base import BaseConnector from . import _jsonld BASE = "https://www.espresso-jobs.com" MAX_PAGES = int(os.environ.get("JOBKA_ESPRESSO_PAGES", "15")) MAX_DETAILS = int(os.environ.get("JOBKA_ESPRESSO_DETAIL_LIMIT", "100")) _SCRIPT_RE = re.compile( r'', re.S | re.I) _ID_RE = re.compile(r"/emploi/(\d+)/") class EspressoJobsConnector(BaseConnector): """Portail spécialisé TI/créatif — offres majoritairement québécoises.""" source_id = "espresso_jobs" ats = "portail" request_delay = 1.0 def _list_page(self, page_no: int) -> list[tuple[str, str, str]]: """[(id, url, titre)] extraits du JSON-LD ItemList de la page.""" html = self.get(f"{BASE}/emploi", params={"page_no": str(page_no)}).text for m in _SCRIPT_RE.finditer(html): try: data = json.loads(m.group(1)) except ValueError: continue if isinstance(data, dict) and data.get("@type") == "ItemList": out = [] for el in data.get("itemListElement") or []: item = el.get("item") or {} url = item.get("url") or item.get("@id") or "" m_id = _ID_RE.search(url) if m_id: out.append((m_id.group(1), url, item.get("name") or "")) return out return [] def _fetch_detail(self, url: str) -> dict: html = self.get(url).text node = _jsonld.extract_jobposting(html) return _jsonld.jobposting_fields(node) if node else {} def fetch(self) -> list[JobPosting]: seen: dict[str, tuple[str, str]] = {} for page in range(1, MAX_PAGES + 1): items = self._list_page(page) if not items: break new = 0 for eid, url, name in items: if eid not in seen: seen[eid] = (url, name) new += 1 if new == 0: break out: list[JobPosting] = [] details_used = 0 for eid, (url, name) in seen.items(): fields: dict = {} if details_used < MAX_DETAILS: fresh = [False] def _fn(u=url, fresh=fresh): fresh[0] = True return self._fetch_detail(u) fields = self.detail(eid, eid, _fn) if fresh[0]: details_used += 1 else: from .. import db if self._detail_con is None: self._detail_con = db.connect() fields = db.get_cached_detail(self._detail_con, self.source_id, eid, eid) or {} job = JobPosting(source=self.source_id, external_id=eid, url=url, title=name, ats=self.ats) if fields: _jsonld.apply_fields(job, fields, override_employer=True) # filtre Québec indulgent : le portail est essentiellement QC — # on n'écarte que les lieux explicitement hors province region = (fields.get("region_code") or "").upper() if region and region not in ("QC", "QUÉBEC", "QUEBEC"): continue if not region and job.city and not is_quebec_location(job.city) \ and "télétravail" not in job.city.lower(): continue if job.title and job.employer: out.append(job) return out