# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/eq3_ltd.py # Rôle : Connecteur EQ3 — breezy (org « eq3-ltd ») # [généré par scripts/gen_connectors.py, flux vérifié le 2026-08-17] # + repli « board délisté » : le 2026-09-15, EQ3 a masqué le listing # public de son board Breezy (/json = [], sitemap vide, HTML # %LABEL_NO_POSITIONS%) alors que les pages /p/ des offres restent # EN LIGNE et ouvertes aux candidatures (vérifié live) et que la # page carrières officielle eq3.com pointe toujours vers ce board. # Quand la liste est vide, on re-vérifie donc chaque offre active # connue via sa page /p/ (JSON-LD JobPosting exigé, rejet des pages # « Position Closed ») — données réelles constatées à la source, # jamais inventées. Limite assumée : pas de découverte de NOUVELLES # offres tant que le board est délisté ; dès que /json redevient # non vide, le chemin normal reprend seul. # + retrait immédiat des offres CONFIRMÉES fermées (2026-09-16) : # dans la nuit du 15 au 16, EQ3 a fermé ses 12 postes (« Position # Closed » vérifié live sur chaque page /p/). La garde anti-dérive # du moteur suspend les retraits quand found=0 vs médiane — correct # pour un connecteur cassé, mais ici la fermeture est PROUVÉE page # par page : ces offres sont donc désactivées directement, sans # attendre que la fenêtre de médiane se vide. # Créé : 2026-08-17 Modifié : 2026-09-16 # ============================================================================= from __future__ import annotations import json import re from ..schema import JobPosting, clean_html, is_quebec_location from .breezy import BreezyConnector _LD_RE = re.compile(r'', re.S) # page d'offre fermée : Breezy garde le JSON-LD mais affiche #

Position Closed

« no longer accepting candidates » _CLOSED_RE = re.compile(r"position closed|no longer accepting", re.I) class Eq3LtdConnector(BreezyConnector): source_id = 'eq3_ltd' EMPLOYER = 'EQ3' ORG = 'eq3-ltd' def fetch(self) -> list[JobPosting]: jobs = super().fetch() if jobs: return jobs return self._fetch_board_delisted() def _known_active_rows(self) -> list[dict]: """Offres actives déjà en base pour cette source (dernier état vu via l'API liste) — servent de liste de rappel à re-vérifier live.""" from .. import db con = db.connect() try: rows = con.execute( "SELECT uid, external_id, url, title, city, location_label," " salary_label, date_posted, details, company_logo" " FROM jobs WHERE source=? AND active=1 AND url<>''", (self.source_id,)).fetchall() return [dict(r) for r in rows] finally: con.close() def _deactivate_closed(self, uids: list[str]) -> None: """Retire immédiatement les offres dont la page /p/ affiche « Position Closed » : fermeture VÉRIFIÉE à la source, pas une simple disparition de liste. Sans ce retrait direct, la garde anti-dérive du moteur (retraits suspendus quand found=0 vs médiane) maintiendrait en ligne des offres prouvées fermées pendant plusieurs cycles.""" import time from .. import db con = db.connect() try: now = time.time() con.executemany( "UPDATE jobs SET active=0, updated_at=? WHERE uid=? AND active=1", [(now, uid) for uid in uids]) con.commit() finally: con.close() print(f"[job-ka] eq3_ltd: {len(uids)} offre(s) confirmée(s) " f"fermée(s) sur leur page /p/ (« Position Closed ») — retirées") def _fetch_board_delisted(self) -> list[JobPosting]: rows = self._known_active_rows() if not rows: return [] print(f"[job-ka] eq3_ltd: board Breezy délisté (/json vide) — " f"re-vérification live de {len(rows)} offre(s) connue(s)") out: list[JobPosting] = [] closed_uids: list[str] = [] for row in rows: try: html = self.get(row["url"]).text except Exception: # page injoignable ce cycle : l'offre manque le sync et suivra # le chemin normal miss_count -> retrait après délai de grâce continue if _CLOSED_RE.search(html): # réellement fermée (constaté sur la page) : retrait direct closed_uids.append(row["uid"]) continue ld = None for m in _LD_RE.finditer(html): try: cand = json.loads(m.group(1)) except ValueError: continue if isinstance(cand, dict) and cand.get("@type") == "JobPosting": ld = cand break if ld is None: continue # pas une fiche d'offre rendue addr = ((ld.get("jobLocation") or {}).get("address") or {}) region = (addr.get("addressRegion") or "").upper() city = addr.get("addressLocality") or row["city"] or "" if region != "QC" and not is_quebec_location(f"{city}, {region}"): continue job = JobPosting( source=self.source_id, external_id=str(row["external_id"]), url=row["url"], employer=self.EMPLOYER, title=ld.get("title") or row["title"] or "", description=clean_html(ld.get("description") or ""), city=city, location_label=row["location_label"] or city, date_posted=ld.get("datePosted") or row["date_posted"], # le libellé salarial n'existe que dans l'API liste — on # conserve le dernier vu (finalize() sait aussi l'extraire # de la description au besoin) salary_label=row["salary_label"] or "", company_logo=row["company_logo"] or "", ats=self.ats, ) try: details = json.loads(row["details"] or "{}") except ValueError: details = {} for key in ("employment_label", "team"): if details.get(key): job.details[key] = details[key] out.append(job) if closed_uids: self._deactivate_closed(closed_uids) return out