# =============================================================================
# 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