Commit
[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>
modified
data/sources.json
+1 −1
@@ -1008,7 +1008,7 @@ | |||
| 1008 | { | 1008 | { |
| 1009 | "id": "eq3_ltd", | 1009 | "id": "eq3_ltd", |
| 1010 | "name": "EQ3", | 1010 | "name": "EQ3", |
| 1011 | − "url": "", | 1011 | + "url": "https://www.eq3.com", |
| 1012 | "careers_url": "https://eq3-ltd.breezy.hr", | 1012 | "careers_url": "https://eq3-ltd.breezy.hr", |
| 1013 | "sectors": [ | 1013 | "sectors": [ |
| 1014 | "Commerce de détail", | 1014 | "Commerce de détail", |
modified
jobka/connectors/eq3_ltd.py
+104 −1
@@ -5,12 +5,115 @@ | |||
| 5 | # Fichier : jobka/connectors/eq3_ltd.py | 5 | # Fichier : jobka/connectors/eq3_ltd.py |
| 6 | # Rôle : Connecteur EQ3 — breezy (org « eq3-ltd ») | 6 | # Rôle : Connecteur EQ3 — breezy (org « eq3-ltd ») |
| 7 | # [généré par scripts/gen_connectors.py, flux vérifié le 2026-08-17] | 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 | from .breezy import BreezyConnector | 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 | class Eq3LtdConnector(BreezyConnector): | 36 | class Eq3LtdConnector(BreezyConnector): |
| 14 | source_id = 'eq3_ltd' | 37 | source_id = 'eq3_ltd' |
| 15 | EMPLOYER = 'EQ3' | 38 | EMPLOYER = 'EQ3' |
| 16 | ORG = 'eq3-ltd' | 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 | # Fichier : tests/test_connectors.py | 5 | # Fichier : tests/test_connectors.py |
| 6 | # Rôle : Tests de régression des connecteurs (rejeu des fixtures hors ligne) | 6 | # Rôle : Tests de régression des connecteurs (rejeu des fixtures hors ligne) |
| 7 | # Ré-enregistrer après un changement de site : run.py record <source> | 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 | import json | 10 | import json |
| 11 | import re | 11 | import re |
@@ -226,3 +226,93 @@ def test_lassonde_list_brightdata_fallback(monkeypatch): | |||
| 226 | monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", | 226 | monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", |
| 227 | bd_forbidden) | 227 | bd_forbidden) |
| 228 | assert conn3._fetch_list() == real_list | 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 | ||