HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/eq3_ltd.py6# Rôle : Connecteur EQ3 — breezy (org « eq3-ltd »)7# [généré par scripts/gen_connectors.py, flux vérifié le 2026-08-17]8# + repli « board délisté » : le 2026-09-15, EQ3 a masqué le listing9# public de son board Breezy (/json = [], sitemap vide, HTML10# %LABEL_NO_POSITIONS%) alors que les pages /p/ des offres restent11# EN LIGNE et ouvertes aux candidatures (vérifié live) et que la12# page carrières officielle eq3.com pointe toujours vers ce board.13# Quand la liste est vide, on re-vérifie donc chaque offre active14# connue via sa page /p/ (JSON-LD JobPosting exigé, rejet des pages15# « Position Closed ») — données réelles constatées à la source,16# jamais inventées. Limite assumée : pas de découverte de NOUVELLES17# offres tant que le board est délisté ; dès que /json redevient18# non vide, le chemin normal reprend seul.19# + retrait immédiat des offres CONFIRMÉES fermées (2026-09-16) :20# dans la nuit du 15 au 16, EQ3 a fermé ses 12 postes (« Position21# Closed » vérifié live sur chaque page /p/). La garde anti-dérive22# du moteur suspend les retraits quand found=0 vs médiane — correct23# pour un connecteur cassé, mais ici la fermeture est PROUVÉE page24# par page : ces offres sont donc désactivées directement, sans25# attendre que la fenêtre de médiane se vide.26# Créé : 2026-08-17 Modifié : 2026-09-1627# =============================================================================28from __future__ import annotations2930import json31import re3233from ..schema import JobPosting, clean_html, is_quebec_location34from .breezy import BreezyConnector3536_LD_RE = re.compile(r'<script type="application/ld\+json">(.*?)</script>',37 re.S)38# page d'offre fermée : Breezy garde le JSON-LD mais affiche39# <h1>Position Closed</h1> « no longer accepting candidates »40_CLOSED_RE = re.compile(r"position closed|no longer accepting", re.I)414243class Eq3LtdConnector(BreezyConnector):44 source_id = 'eq3_ltd'45 EMPLOYER = 'EQ3'46 ORG = 'eq3-ltd'4748 def fetch(self) -> list[JobPosting]:49 jobs = super().fetch()50 if jobs:51 return jobs52 return self._fetch_board_delisted()5354 def _known_active_rows(self) -> list[dict]:55 """Offres actives déjà en base pour cette source (dernier état vu56 via l'API liste) — servent de liste de rappel à re-vérifier live."""57 from .. import db58 con = db.connect()59 try:60 rows = con.execute(61 "SELECT uid, external_id, url, title, city, location_label,"62 " salary_label, date_posted, details, company_logo"63 " FROM jobs WHERE source=? AND active=1 AND url<>''",64 (self.source_id,)).fetchall()65 return [dict(r) for r in rows]66 finally:67 con.close()6869 def _deactivate_closed(self, uids: list[str]) -> None:70 """Retire immédiatement les offres dont la page /p/ affiche71 « Position Closed » : fermeture VÉRIFIÉE à la source, pas une simple72 disparition de liste. Sans ce retrait direct, la garde anti-dérive du73 moteur (retraits suspendus quand found=0 vs médiane) maintiendrait en74 ligne des offres prouvées fermées pendant plusieurs cycles."""75 import time7677 from .. import db78 con = db.connect()79 try:80 now = time.time()81 con.executemany(82 "UPDATE jobs SET active=0, updated_at=? WHERE uid=? AND active=1",83 [(now, uid) for uid in uids])84 con.commit()85 finally:86 con.close()87 print(f"[job-ka] eq3_ltd: {len(uids)} offre(s) confirmée(s) "88 f"fermée(s) sur leur page /p/ (« Position Closed ») — retirées")8990 def _fetch_board_delisted(self) -> list[JobPosting]:91 rows = self._known_active_rows()92 if not rows:93 return []94 print(f"[job-ka] eq3_ltd: board Breezy délisté (/json vide) — "95 f"re-vérification live de {len(rows)} offre(s) connue(s)")96 out: list[JobPosting] = []97 closed_uids: list[str] = []98 for row in rows:99 try:100 html = self.get(row["url"]).text101 except Exception:102 # page injoignable ce cycle : l'offre manque le sync et suivra103 # le chemin normal miss_count -> retrait après délai de grâce104 continue105 if _CLOSED_RE.search(html):106 # réellement fermée (constaté sur la page) : retrait direct107 closed_uids.append(row["uid"])108 continue109 ld = None110 for m in _LD_RE.finditer(html):111 try:112 cand = json.loads(m.group(1))113 except ValueError:114 continue115 if isinstance(cand, dict) and cand.get("@type") == "JobPosting":116 ld = cand117 break118 if ld is None:119 continue # pas une fiche d'offre rendue120 addr = ((ld.get("jobLocation") or {}).get("address") or {})121 region = (addr.get("addressRegion") or "").upper()122 city = addr.get("addressLocality") or row["city"] or ""123 if region != "QC" and not is_quebec_location(f"{city}, {region}"):124 continue125 job = JobPosting(126 source=self.source_id,127 external_id=str(row["external_id"]),128 url=row["url"],129 employer=self.EMPLOYER,130 title=ld.get("title") or row["title"] or "",131 description=clean_html(ld.get("description") or ""),132 city=city,133 location_label=row["location_label"] or city,134 date_posted=ld.get("datePosted") or row["date_posted"],135 # le libellé salarial n'existe que dans l'API liste — on136 # conserve le dernier vu (finalize() sait aussi l'extraire137 # de la description au besoin)138 salary_label=row["salary_label"] or "",139 company_logo=row["company_logo"] or "",140 ats=self.ats,141 )142 try:143 details = json.loads(row["details"] or "{}")144 except ValueError:145 details = {}146 for key in ("employment_label", "team"):147 if details.get(key):148 job.details[key] = details[key]149 out.append(job)150 if closed_uids:151 self._deactivate_closed(closed_uids)152 return out153