[ka6] fix connecteur eq3_ltd: retrait immédiat des offres CONFIRMÉES fermées — dans la nuit du 15 au 16/09, EQ3 a réellement fermé ses 12 postes (« Position Closed » vérifié live sur chacune des 12 pages /p/, plus aucun formulaire APPLY), étape suivante du délisting du board traité en 6012d1c ; le repli « board délisté » a fonctionné exactement comme conçu (re-vérification des 12 offres, rejet des pages fermées → found=0, volume désormais légitime), mais la garde anti-dérive du moteur (found=0 vs médiane 12 → retraits suspendus) maintenait les 12 offres fermées EN LIGNE sur le site (active=1, miss_count=0) pendant que la fenêtre de médiane (DRIFT_HISTORY=5) se vidait — correct pour un connecteur cassé, injustifié quand la fermeture est PROUVÉE page par page. Correctif tenant-spécifique dans eq3_ltd.py seul (db.py intact) : le repli collecte les uid des offres dont la page /p/ affiche « Position Closed » et les désactive directement (_deactivate_closed, UPDATE active=0), fermeture vérifiée à la source — les offres injoignables ou hors QC continuent de suivre le chemin normal miss_count. Vérifié live : la page carrières officielle eq3.com pointe toujours vers eq3-ltd.breezy.hr (pas de migration d'ATS), /json toujours [], validThrough des 12 offres tous dans le passé. Test unitaire adapté (offre « Position Closed » → retrait direct capturé, vivante conservée, hors-QC simplement lâchée). Sync réel rejoué : 12 offre(s) confirmée(s) fermée(s) — retirées, 0 offre active restante en base, found=0 légitime (le tenant n'a plus aucun poste ouvert ; dès republication le chemin normal /json reprend seul). 105 tests pytest verts. pm2 restart job-ka-sync OK, site 200.
2 changed files +52 −10
modified
jobka/connectors/eq3_ltd.py
+36 −3
@@ -16,7 +16,14 @@ | ||
| 16 | 16 | # jamais inventées. Limite assumée : pas de découverte de NOUVELLES |
| 17 | 17 | # offres tant que le board est délisté ; dès que /json redevient |
| 18 | 18 | # non vide, le chemin normal reprend seul. |
| 19 | −# Créé : 2026-08-17 Modifié : 2026-09-15 | |
| 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 (« Position | |
| 21 | +# Closed » vérifié live sur chaque page /p/). La garde anti-dérive | |
| 22 | +# du moteur suspend les retraits quand found=0 vs médiane — correct | |
| 23 | +# pour un connecteur cassé, mais ici la fermeture est PROUVÉE page | |
| 24 | +# par page : ces offres sont donc désactivées directement, sans | |
| 25 | +# attendre que la fenêtre de médiane se vide. | |
| 26 | +# Créé : 2026-08-17 Modifié : 2026-09-16 | |
| 20 | 27 | # ============================================================================= |
| 21 | 28 | from __future__ import annotations |
| 22 | 29 | |
@@ -51,7 +58,7 @@ class Eq3LtdConnector(BreezyConnector): | ||
| 51 | 58 | con = db.connect() |
| 52 | 59 | try: |
| 53 | 60 | rows = con.execute( |
| 54 | − "SELECT external_id, url, title, city, location_label," | |
| 61 | + "SELECT uid, external_id, url, title, city, location_label," | |
| 55 | 62 | " salary_label, date_posted, details, company_logo" |
| 56 | 63 | " FROM jobs WHERE source=? AND active=1 AND url<>''", |
| 57 | 64 | (self.source_id,)).fetchall() |
@@ -59,6 +66,27 @@ class Eq3LtdConnector(BreezyConnector): | ||
| 59 | 66 | finally: |
| 60 | 67 | con.close() |
| 61 | 68 | |
| 69 | + def _deactivate_closed(self, uids: list[str]) -> None: | |
| 70 | + """Retire immédiatement les offres dont la page /p/ affiche | |
| 71 | + « Position Closed » : fermeture VÉRIFIÉE à la source, pas une simple | |
| 72 | + disparition de liste. Sans ce retrait direct, la garde anti-dérive du | |
| 73 | + moteur (retraits suspendus quand found=0 vs médiane) maintiendrait en | |
| 74 | + ligne des offres prouvées fermées pendant plusieurs cycles.""" | |
| 75 | + import time | |
| 76 | + | |
| 77 | + from .. import db | |
| 78 | + 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") | |
| 89 | + | |
| 62 | 90 | def _fetch_board_delisted(self) -> list[JobPosting]: |
| 63 | 91 | rows = self._known_active_rows() |
| 64 | 92 | if not rows: |
@@ -66,6 +94,7 @@ class Eq3LtdConnector(BreezyConnector): | ||
| 66 | 94 | print(f"[job-ka] eq3_ltd: board Breezy délisté (/json vide) — " |
| 67 | 95 | f"re-vérification live de {len(rows)} offre(s) connue(s)") |
| 68 | 96 | out: list[JobPosting] = [] |
| 97 | + closed_uids: list[str] = [] | |
| 69 | 98 | for row in rows: |
| 70 | 99 | try: |
| 71 | 100 | html = self.get(row["url"]).text |
@@ -74,7 +103,9 @@ class Eq3LtdConnector(BreezyConnector): | ||
| 74 | 103 | # le chemin normal miss_count -> retrait après délai de grâce |
| 75 | 104 | continue |
| 76 | 105 | if _CLOSED_RE.search(html): |
| 77 | − continue # réellement fermée : on la lâche | |
| 106 | + # réellement fermée (constaté sur la page) : retrait direct | |
| 107 | + closed_uids.append(row["uid"]) | |
| 108 | + continue | |
| 78 | 109 | ld = None |
| 79 | 110 | for m in _LD_RE.finditer(html): |
| 80 | 111 | try: |
@@ -116,4 +147,6 @@ class Eq3LtdConnector(BreezyConnector): | ||
| 116 | 147 | if details.get(key): |
| 117 | 148 | job.details[key] = details[key] |
| 118 | 149 | out.append(job) |
| 150 | + if closed_uids: | |
| 151 | + self._deactivate_closed(closed_uids) | |
| 119 | 152 | return out |
modified
tests/test_connectors.py
+16 −7
@@ -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-15 | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-09-16 | |
| 9 | 9 | # ============================================================================= |
| 10 | 10 | import json |
| 11 | 11 | import re |
@@ -233,8 +233,9 @@ def test_eq3_ltd_delisted_board_fallback(monkeypatch): | ||
| 233 | 233 | vide) alors que les pages /p/ des offres restaient en ligne et ouvertes. |
| 234 | 234 | Quand la liste est vide, le connecteur re-vérifie chaque offre active |
| 235 | 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.""" | |
| 236 | + une page « Position Closed » (fermeture vérifiée → retrait direct en | |
| 237 | + base, malgré la garde anti-dérive) ou hors Québec est lâchée ; et une | |
| 238 | + liste non vide ne déclenche jamais le repli.""" | |
| 238 | 239 | from jobka.connectors.eq3_ltd import Eq3LtdConnector |
| 239 | 240 | |
| 240 | 241 | class _Resp: |
@@ -261,16 +262,19 @@ def test_eq3_ltd_delisted_board_fallback(monkeypatch): | ||
| 261 | 262 | "addressRegion": region, "addressLocality": city}}} |
| 262 | 263 | |
| 263 | 264 | rows = [ |
| 264 | − {"external_id": "aaa111", "url": "https://x/p/aaa111-vivante", | |
| 265 | + {"uid": "eq3_ltd:aaa111", "external_id": "aaa111", | |
| 266 | + "url": "https://x/p/aaa111-vivante", | |
| 265 | 267 | "title": "Associé(e) aux ventes", "city": "Brossard", |
| 266 | 268 | "location_label": "Brossard, Quebec", "salary_label": "18 $ à 25 $/h", |
| 267 | 269 | "date_posted": "2026-08-26", "details": '{"employment_label": ' |
| 268 | 270 | '"Temps plein"}', "company_logo": ""}, |
| 269 | − {"external_id": "bbb222", "url": "https://x/p/bbb222-fermee", | |
| 271 | + {"uid": "eq3_ltd:bbb222", "external_id": "bbb222", | |
| 272 | + "url": "https://x/p/bbb222-fermee", | |
| 270 | 273 | "title": "Fermée", "city": "Laval", "location_label": "Laval", |
| 271 | 274 | "salary_label": "", "date_posted": None, "details": "{}", |
| 272 | 275 | "company_logo": ""}, |
| 273 | − {"external_id": "ccc333", "url": "https://x/p/ccc333-denver", | |
| 276 | + {"uid": "eq3_ltd:ccc333", "external_id": "ccc333", | |
| 277 | + "url": "https://x/p/ccc333-denver", | |
| 274 | 278 | "title": "Hors QC", "city": "Denver", "location_label": "Denver, CO", |
| 275 | 279 | "salary_label": "", "date_posted": None, "details": "{}", |
| 276 | 280 | "company_logo": ""}, |
@@ -284,10 +288,14 @@ def test_eq3_ltd_delisted_board_fallback(monkeypatch): | ||
| 284 | 288 | } |
| 285 | 289 | |
| 286 | 290 | # 1. /json vide → repli : seule l'offre vivante et québécoise survit, |
| 287 | − # le libellé salarial connu est conservé | |
| 291 | + # le libellé salarial connu est conservé ; l'offre « Position Closed » | |
| 292 | + # est retirée directement en base (fermeture vérifiée à la source), | |
| 293 | + # l'offre hors QC est simplement lâchée (pas de preuve de fermeture) | |
| 288 | 294 | conn = Eq3LtdConnector() |
| 289 | 295 | conn.request_delay = 0 |
| 296 | + deactivated = [] | |
| 290 | 297 | monkeypatch.setattr(conn, "_known_active_rows", lambda: rows) |
| 298 | + monkeypatch.setattr(conn, "_deactivate_closed", deactivated.extend) | |
| 291 | 299 | monkeypatch.setattr(conn, "get", lambda url, **kw: _Resp( |
| 292 | 300 | pages.get(url, []))) |
| 293 | 301 | out = conn.fetch() |
@@ -295,6 +303,7 @@ def test_eq3_ltd_delisted_board_fallback(monkeypatch): | ||
| 295 | 303 | assert out[0].title == "Associé(e) aux ventes" |
| 296 | 304 | assert out[0].salary_label == "18 $ à 25 $/h" |
| 297 | 305 | assert out[0].details["employment_label"] == "Temps plein" |
| 306 | + assert deactivated == ["eq3_ltd:bbb222"] | |
| 298 | 307 | |
| 299 | 308 | # 2. liste non vide → chemin normal, le repli n'est jamais sollicité |
| 300 | 309 | conn2 = Eq3LtdConnector() |
| 301 | 310 | |