[ka6] fix connecteur eq3_ltd: repli « board délisté » — /json vide alors que les 12 offres restent EN LIGNE (re-vérification live des pages /p/) — le 2026-09-15 vers 12:07, le board Breezy eq3-ltd a cessé de lister ses postes (/json et /json?verbose=true = [] — vérifié direct, UA navigateur ET proxy résidentiel CA —, sitemap.xml sans aucune offre, HTML %LABEL_NO_POSITIONS%) alors que les 12 offres QC connues sont toutes encore en ligne et ouvertes aux candidatures (pages /p/ 200 avec JSON-LD JobPosting complet et bouton APPLY, aucune « Position Closed ») et que la page carrières officielle eq3.com/ca/en/aboutus/careers pointe toujours vers ce même board : delisting du portail côté tenant, PAS une dépublication (contrairement aux précédents hera 4990c80/autotradercanada e3bb827 où les offres retirées affichaient « Position Closed »/404). L'API Breezy est saine globalement (bellatrx 14, brandmomentum 34, recruiting 78…) ; aucun slug alternatif (eq3.breezy.hr existe mais vide) ni endpoint de rechange (embed/feed/rss → 302). Correctif tenant-spécifique dans eq3_ltd.py (breezy.py intact) : quand la liste est vide, re-vérification live de chaque offre active connue via sa page /p/ — JSON-LD JobPosting exigé, rejet des pages « Position Closed »/« no longer accepting » et hors Québec, champs frais depuis le JSON-LD (titre, description, ville, date), libellé salarial conservé du dernier état liste (8/12 avec salaire, préservés). 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 et les offres réellement fermées suivront miss_count → retrait. Correction au passage du même artefact sources.json que samsara/delos_data/solmentalhealth/hera : url vide → https://www.eq3.com. Test unitaire ajouté (offre vivante QC conservée avec salaire, « Position Closed » et hors-QC lâchées, repli jamais sollicité sur liste non vide, 0 requête si base vide). Syncs réels rejoués : found=12 (médiane exacte) ×2, updated=4 puis 0, aucune alerte, miss_count=0 partout. 105 tests pytest verts. pm2 restart job-ka-sync OK, site 200.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 changed files +196 −3
modified
data/sources.json
+1 −1
@@ -1008,7 +1008,7 @@ | ||
| 1008 | 1008 | { |
| 1009 | 1009 | "id": "eq3_ltd", |
| 1010 | 1010 | "name": "EQ3", |
| 1011 | − "url": "", | |
| 1011 | + "url": "https://www.eq3.com", | |
| 1012 | 1012 | "careers_url": "https://eq3-ltd.breezy.hr", |
| 1013 | 1013 | "sectors": [ |
| 1014 | 1014 | "Commerce de détail", |
modified
jobka/connectors/eq3_ltd.py
+104 −1
@@ -5,12 +5,115 @@ | ||
| 5 | 5 | # Fichier : jobka/connectors/eq3_ltd.py |
| 6 | 6 | # Rôle : Connecteur EQ3 — breezy (org « eq3-ltd ») |
| 7 | 7 | # [généré par scripts/gen_connectors.py, flux vérifié le 2026-08-17] |
| 8 | −# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 8 | +# + repli « board délisté » : le 2026-09-15, EQ3 a masqué le listing | |
| 9 | +# public de son board Breezy (/json = [], sitemap vide, HTML | |
| 10 | +# %LABEL_NO_POSITIONS%) alors que les pages /p/ des offres restent | |
| 11 | +# EN LIGNE et ouvertes aux candidatures (vérifié live) et que la | |
| 12 | +# page carrières officielle eq3.com pointe toujours vers ce board. | |
| 13 | +# Quand la liste est vide, on re-vérifie donc chaque offre active | |
| 14 | +# connue via sa page /p/ (JSON-LD JobPosting exigé, rejet des pages | |
| 15 | +# « Position Closed ») — données réelles constatées à la source, | |
| 16 | +# jamais inventées. Limite assumée : pas de découverte de NOUVELLES | |
| 17 | +# offres tant que le board est délisté ; dès que /json redevient | |
| 18 | +# non vide, le chemin normal reprend seul. | |
| 19 | +# Créé : 2026-08-17 Modifié : 2026-09-15 | |
| 9 | 20 | # ============================================================================= |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import json | |
| 24 | +import re | |
| 25 | + | |
| 26 | +from ..schema import JobPosting, clean_html, is_quebec_location | |
| 10 | 27 | from .breezy import BreezyConnector |
| 11 | 28 | |
| 29 | +_LD_RE = re.compile(r'<script type="application/ld\+json">(.*?)</script>', | |
| 30 | + re.S) | |
| 31 | +# page d'offre fermée : Breezy garde le JSON-LD mais affiche | |
| 32 | +# <h1>Position Closed</h1> « no longer accepting candidates » | |
| 33 | +_CLOSED_RE = re.compile(r"position closed|no longer accepting", re.I) | |
| 34 | + | |
| 12 | 35 | |
| 13 | 36 | class Eq3LtdConnector(BreezyConnector): |
| 14 | 37 | source_id = 'eq3_ltd' |
| 15 | 38 | EMPLOYER = 'EQ3' |
| 16 | 39 | ORG = 'eq3-ltd' |
| 40 | + | |
| 41 | + def fetch(self) -> list[JobPosting]: | |
| 42 | + jobs = super().fetch() | |
| 43 | + if jobs: | |
| 44 | + return jobs | |
| 45 | + return self._fetch_board_delisted() | |
| 46 | + | |
| 47 | + def _known_active_rows(self) -> list[dict]: | |
| 48 | + """Offres actives déjà en base pour cette source (dernier état vu | |
| 49 | + via l'API liste) — servent de liste de rappel à re-vérifier live.""" | |
| 50 | + from .. import db | |
| 51 | + con = db.connect() | |
| 52 | + try: | |
| 53 | + rows = con.execute( | |
| 54 | + "SELECT external_id, url, title, city, location_label," | |
| 55 | + " salary_label, date_posted, details, company_logo" | |
| 56 | + " FROM jobs WHERE source=? AND active=1 AND url<>''", | |
| 57 | + (self.source_id,)).fetchall() | |
| 58 | + return [dict(r) for r in rows] | |
| 59 | + finally: | |
| 60 | + con.close() | |
| 61 | + | |
| 62 | + def _fetch_board_delisted(self) -> list[JobPosting]: | |
| 63 | + rows = self._known_active_rows() | |
| 64 | + if not rows: | |
| 65 | + return [] | |
| 66 | + print(f"[job-ka] eq3_ltd: board Breezy délisté (/json vide) — " | |
| 67 | + f"re-vérification live de {len(rows)} offre(s) connue(s)") | |
| 68 | + out: list[JobPosting] = [] | |
| 69 | + for row in rows: | |
| 70 | + try: | |
| 71 | + html = self.get(row["url"]).text | |
| 72 | + except Exception: | |
| 73 | + # page injoignable ce cycle : l'offre manque le sync et suivra | |
| 74 | + # le chemin normal miss_count -> retrait après délai de grâce | |
| 75 | + continue | |
| 76 | + if _CLOSED_RE.search(html): | |
| 77 | + continue # réellement fermée : on la lâche | |
| 78 | + ld = None | |
| 79 | + for m in _LD_RE.finditer(html): | |
| 80 | + try: | |
| 81 | + cand = json.loads(m.group(1)) | |
| 82 | + except ValueError: | |
| 83 | + continue | |
| 84 | + if isinstance(cand, dict) and cand.get("@type") == "JobPosting": | |
| 85 | + ld = cand | |
| 86 | + break | |
| 87 | + if ld is None: | |
| 88 | + continue # pas une fiche d'offre rendue | |
| 89 | + addr = ((ld.get("jobLocation") or {}).get("address") or {}) | |
| 90 | + region = (addr.get("addressRegion") or "").upper() | |
| 91 | + city = addr.get("addressLocality") or row["city"] or "" | |
| 92 | + if region != "QC" and not is_quebec_location(f"{city}, {region}"): | |
| 93 | + continue | |
| 94 | + job = JobPosting( | |
| 95 | + source=self.source_id, | |
| 96 | + external_id=str(row["external_id"]), | |
| 97 | + url=row["url"], | |
| 98 | + employer=self.EMPLOYER, | |
| 99 | + title=ld.get("title") or row["title"] or "", | |
| 100 | + description=clean_html(ld.get("description") or ""), | |
| 101 | + city=city, | |
| 102 | + location_label=row["location_label"] or city, | |
| 103 | + date_posted=ld.get("datePosted") or row["date_posted"], | |
| 104 | + # le libellé salarial n'existe que dans l'API liste — on | |
| 105 | + # conserve le dernier vu (finalize() sait aussi l'extraire | |
| 106 | + # de la description au besoin) | |
| 107 | + salary_label=row["salary_label"] or "", | |
| 108 | + company_logo=row["company_logo"] or "", | |
| 109 | + ats=self.ats, | |
| 110 | + ) | |
| 111 | + try: | |
| 112 | + details = json.loads(row["details"] or "{}") | |
| 113 | + except ValueError: | |
| 114 | + details = {} | |
| 115 | + for key in ("employment_label", "team"): | |
| 116 | + if details.get(key): | |
| 117 | + job.details[key] = details[key] | |
| 118 | + out.append(job) | |
| 119 | + return out | |
modified
tests/test_connectors.py
+91 −1
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | # Fichier : tests/test_connectors.py |
| 6 | 6 | # Rôle : Tests de régression des connecteurs (rejeu des fixtures hors ligne) |
| 7 | 7 | # Ré-enregistrer après un changement de site : run.py record <source> |
| 8 | −# Créé : 2026-08-17 Modifié : 2026-09-14 | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-09-15 | |
| 9 | 9 | # ============================================================================= |
| 10 | 10 | import json |
| 11 | 11 | import re |
@@ -226,3 +226,93 @@ def test_lassonde_list_brightdata_fallback(monkeypatch): | ||
| 226 | 226 | monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", |
| 227 | 227 | bd_forbidden) |
| 228 | 228 | assert conn3._fetch_list() == real_list |
| 229 | + | |
| 230 | + | |
| 231 | +def test_eq3_ltd_delisted_board_fallback(monkeypatch): | |
| 232 | + """Le 2026-09-15, EQ3 a délisté son board Breezy (/json = [], sitemap | |
| 233 | + vide) alors que les pages /p/ des offres restaient en ligne et ouvertes. | |
| 234 | + Quand la liste est vide, le connecteur re-vérifie chaque offre active | |
| 235 | + connue via sa page : une page vivante avec JSON-LD QC est conservée, | |
| 236 | + une page « Position Closed » ou hors Québec est lâchée ; et une liste | |
| 237 | + non vide ne déclenche jamais le repli.""" | |
| 238 | + from jobka.connectors.eq3_ltd import Eq3LtdConnector | |
| 239 | + | |
| 240 | + class _Resp: | |
| 241 | + def __init__(self, payload): | |
| 242 | + self._payload = payload | |
| 243 | + self.text = payload if isinstance(payload, str) else "" | |
| 244 | + | |
| 245 | + def json(self): | |
| 246 | + return self._payload | |
| 247 | + | |
| 248 | + def _page(ld, closed=False): | |
| 249 | + body = ('<div class="application-confirmed"><h1>Position Closed</h1>' | |
| 250 | + "<p>Sorry, this position is no longer accepting candidates." | |
| 251 | + "</p></div>") if closed else "<h1>offre</h1>" | |
| 252 | + return ('<html><script type="application/ld+json">' | |
| 253 | + + json.dumps(ld) + f"</script>{body}</html>") | |
| 254 | + | |
| 255 | + def _ld(title, city, region): | |
| 256 | + return {"@context": "https://schema.org/", "@type": "JobPosting", | |
| 257 | + "title": title, "description": "<p>desc</p>", | |
| 258 | + "datePosted": "2026-08-26", | |
| 259 | + "jobLocation": {"@type": "Place", "address": { | |
| 260 | + "@type": "PostalAddress", "addressCountry": "CA", | |
| 261 | + "addressRegion": region, "addressLocality": city}}} | |
| 262 | + | |
| 263 | + rows = [ | |
| 264 | + {"external_id": "aaa111", "url": "https://x/p/aaa111-vivante", | |
| 265 | + "title": "Associé(e) aux ventes", "city": "Brossard", | |
| 266 | + "location_label": "Brossard, Quebec", "salary_label": "18 $ à 25 $/h", | |
| 267 | + "date_posted": "2026-08-26", "details": '{"employment_label": ' | |
| 268 | + '"Temps plein"}', "company_logo": ""}, | |
| 269 | + {"external_id": "bbb222", "url": "https://x/p/bbb222-fermee", | |
| 270 | + "title": "Fermée", "city": "Laval", "location_label": "Laval", | |
| 271 | + "salary_label": "", "date_posted": None, "details": "{}", | |
| 272 | + "company_logo": ""}, | |
| 273 | + {"external_id": "ccc333", "url": "https://x/p/ccc333-denver", | |
| 274 | + "title": "Hors QC", "city": "Denver", "location_label": "Denver, CO", | |
| 275 | + "salary_label": "", "date_posted": None, "details": "{}", | |
| 276 | + "company_logo": ""}, | |
| 277 | + ] | |
| 278 | + pages = { | |
| 279 | + "https://x/p/aaa111-vivante": | |
| 280 | + _page(_ld("Associé(e) aux ventes", "Brossard", "QC")), | |
| 281 | + "https://x/p/bbb222-fermee": | |
| 282 | + _page(_ld("Fermée", "Laval", "QC"), closed=True), | |
| 283 | + "https://x/p/ccc333-denver": _page(_ld("Hors QC", "Denver", "CO")), | |
| 284 | + } | |
| 285 | + | |
| 286 | + # 1. /json vide → repli : seule l'offre vivante et québécoise survit, | |
| 287 | + # le libellé salarial connu est conservé | |
| 288 | + conn = Eq3LtdConnector() | |
| 289 | + conn.request_delay = 0 | |
| 290 | + monkeypatch.setattr(conn, "_known_active_rows", lambda: rows) | |
| 291 | + monkeypatch.setattr(conn, "get", lambda url, **kw: _Resp( | |
| 292 | + pages.get(url, []))) | |
| 293 | + out = conn.fetch() | |
| 294 | + assert [j.external_id for j in out] == ["aaa111"] | |
| 295 | + assert out[0].title == "Associé(e) aux ventes" | |
| 296 | + assert out[0].salary_label == "18 $ à 25 $/h" | |
| 297 | + assert out[0].details["employment_label"] == "Temps plein" | |
| 298 | + | |
| 299 | + # 2. liste non vide → chemin normal, le repli n'est jamais sollicité | |
| 300 | + conn2 = Eq3LtdConnector() | |
| 301 | + conn2.request_delay = 0 | |
| 302 | + item = {"id": "ddd444", "name": "Poste liste", "url": "https://x/p/ddd444", | |
| 303 | + "location": {"country": {"id": "CA"}, "state": {"id": "QC"}, | |
| 304 | + "city": "Montréal"}, | |
| 305 | + "type": {"id": "fullTime", "name": "Temps plein"}, | |
| 306 | + "published_date": "2026-09-01", "description": "<p>d</p>"} | |
| 307 | + monkeypatch.setattr(conn2, "get", lambda url, **kw: _Resp([item])) | |
| 308 | + monkeypatch.setattr(conn2, "_known_active_rows", lambda: (_ for _ in () | |
| 309 | + ).throw(AssertionError("repli interdit"))) | |
| 310 | + out2 = conn2.fetch() | |
| 311 | + assert [j.external_id for j in out2] == ["ddd444"] | |
| 312 | + | |
| 313 | + # 3. /json vide et aucune offre connue en base → 0, sans requête page | |
| 314 | + conn3 = Eq3LtdConnector() | |
| 315 | + conn3.request_delay = 0 | |
| 316 | + monkeypatch.setattr(conn3, "get", lambda url, **kw: _Resp([])) | |
| 317 | + monkeypatch.setattr(conn3, "_known_active_rows", lambda: []) | |
| 318 | + assert conn3.fetch() == [] | |
| 229 | 319 | |