[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 | # jamais inventées. Limite assumée : pas de découverte de NOUVELLES | 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 | 17 | # offres tant que le board est délisté ; dès que /json redevient | ||
| 18 | # non vide, le chemin normal reprend seul. | 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 | from __future__ import annotations | 28 | from __future__ import annotations | ||
| 22 | 29 | ||||
@@ -51,7 +58,7 @@ class Eq3LtdConnector(BreezyConnector): | |||||
| 51 | con = db.connect() | 58 | con = db.connect() | ||
| 52 | try: | 59 | try: | ||
| 53 | rows = con.execute( | 60 | rows = con.execute( | ||
| 54 | − "SELECT external_id, url, title, city, location_label," | 61 | + "SELECT uid, external_id, url, title, city, location_label," | ||
| 55 | " salary_label, date_posted, details, company_logo" | 62 | " salary_label, date_posted, details, company_logo" | ||
| 56 | " FROM jobs WHERE source=? AND active=1 AND url<>''", | 63 | " FROM jobs WHERE source=? AND active=1 AND url<>''", | ||
| 57 | (self.source_id,)).fetchall() | 64 | (self.source_id,)).fetchall() | ||
@@ -59,6 +66,27 @@ class Eq3LtdConnector(BreezyConnector): | |||||
| 59 | finally: | 66 | finally: | ||
| 60 | con.close() | 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 | def _fetch_board_delisted(self) -> list[JobPosting]: | 90 | def _fetch_board_delisted(self) -> list[JobPosting]: | ||
| 63 | rows = self._known_active_rows() | 91 | rows = self._known_active_rows() | ||
| 64 | if not rows: | 92 | if not rows: | ||
@@ -66,6 +94,7 @@ class Eq3LtdConnector(BreezyConnector): | |||||
| 66 | print(f"[job-ka] eq3_ltd: board Breezy délisté (/json vide) — " | 94 | 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)") | 95 | f"re-vérification live de {len(rows)} offre(s) connue(s)") | ||
| 68 | out: list[JobPosting] = [] | 96 | out: list[JobPosting] = [] | ||
| 97 | + closed_uids: list[str] = [] | ||||
| 69 | for row in rows: | 98 | for row in rows: | ||
| 70 | try: | 99 | try: | ||
| 71 | html = self.get(row["url"]).text | 100 | html = self.get(row["url"]).text | ||
@@ -74,7 +103,9 @@ class Eq3LtdConnector(BreezyConnector): | |||||
| 74 | # le chemin normal miss_count -> retrait après délai de grâce | 103 | # le chemin normal miss_count -> retrait après délai de grâce | ||
| 75 | continue | 104 | continue | ||
| 76 | if _CLOSED_RE.search(html): | 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 | ld = None | 109 | ld = None | ||
| 79 | for m in _LD_RE.finditer(html): | 110 | for m in _LD_RE.finditer(html): | ||
| 80 | try: | 111 | try: | ||
@@ -116,4 +147,6 @@ class Eq3LtdConnector(BreezyConnector): | |||||
| 116 | if details.get(key): | 147 | if details.get(key): | ||
| 117 | job.details[key] = details[key] | 148 | job.details[key] = details[key] | ||
| 118 | out.append(job) | 149 | out.append(job) | ||
| 150 | + if closed_uids: | ||||
| 151 | + self._deactivate_closed(closed_uids) | ||||
| 119 | return out | 152 | return out | ||
modified
tests/test_connectors.py
+16 −7
@@ -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-15 | 8 | +# Créé : 2026-08-17 Modifié : 2026-09-16 | ||
| 9 | # ============================================================================= | 9 | # ============================================================================= | ||
| 10 | import json | 10 | import json | ||
| 11 | import re | 11 | import re | ||
@@ -233,8 +233,9 @@ def test_eq3_ltd_delisted_board_fallback(monkeypatch): | |||||
| 233 | vide) alors que les pages /p/ des offres restaient en ligne et ouvertes. | 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 | 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, | 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 | 236 | + une page « Position Closed » (fermeture vérifiée → retrait direct en | ||
| 237 | − non vide ne déclenche jamais le repli.""" | 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 | from jobka.connectors.eq3_ltd import Eq3LtdConnector | 239 | from jobka.connectors.eq3_ltd import Eq3LtdConnector | ||
| 239 | 240 | ||||
| 240 | class _Resp: | 241 | class _Resp: | ||
@@ -261,16 +262,19 @@ def test_eq3_ltd_delisted_board_fallback(monkeypatch): | |||||
| 261 | "addressRegion": region, "addressLocality": city}}} | 262 | "addressRegion": region, "addressLocality": city}}} | ||
| 262 | 263 | ||||
| 263 | rows = [ | 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 | "title": "Associé(e) aux ventes", "city": "Brossard", | 267 | "title": "Associé(e) aux ventes", "city": "Brossard", | ||
| 266 | "location_label": "Brossard, Quebec", "salary_label": "18 $ à 25 $/h", | 268 | "location_label": "Brossard, Quebec", "salary_label": "18 $ à 25 $/h", | ||
| 267 | "date_posted": "2026-08-26", "details": '{"employment_label": ' | 269 | "date_posted": "2026-08-26", "details": '{"employment_label": ' | ||
| 268 | '"Temps plein"}', "company_logo": ""}, | 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 | "title": "Fermée", "city": "Laval", "location_label": "Laval", | 273 | "title": "Fermée", "city": "Laval", "location_label": "Laval", | ||
| 271 | "salary_label": "", "date_posted": None, "details": "{}", | 274 | "salary_label": "", "date_posted": None, "details": "{}", | ||
| 272 | "company_logo": ""}, | 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 | "title": "Hors QC", "city": "Denver", "location_label": "Denver, CO", | 278 | "title": "Hors QC", "city": "Denver", "location_label": "Denver, CO", | ||
| 275 | "salary_label": "", "date_posted": None, "details": "{}", | 279 | "salary_label": "", "date_posted": None, "details": "{}", | ||
| 276 | "company_logo": ""}, | 280 | "company_logo": ""}, | ||
@@ -284,10 +288,14 @@ def test_eq3_ltd_delisted_board_fallback(monkeypatch): | |||||
| 284 | } | 288 | } | ||
| 285 | 289 | ||||
| 286 | # 1. /json vide → repli : seule l'offre vivante et québécoise survit, | 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 | conn = Eq3LtdConnector() | 294 | conn = Eq3LtdConnector() | ||
| 289 | conn.request_delay = 0 | 295 | conn.request_delay = 0 | ||
| 296 | + deactivated = [] | ||||
| 290 | monkeypatch.setattr(conn, "_known_active_rows", lambda: rows) | 297 | monkeypatch.setattr(conn, "_known_active_rows", lambda: rows) | ||
| 298 | + monkeypatch.setattr(conn, "_deactivate_closed", deactivated.extend) | ||||
| 291 | monkeypatch.setattr(conn, "get", lambda url, **kw: _Resp( | 299 | monkeypatch.setattr(conn, "get", lambda url, **kw: _Resp( | ||
| 292 | pages.get(url, []))) | 300 | pages.get(url, []))) | ||
| 293 | out = conn.fetch() | 301 | out = conn.fetch() | ||
@@ -295,6 +303,7 @@ def test_eq3_ltd_delisted_board_fallback(monkeypatch): | |||||
| 295 | assert out[0].title == "Associé(e) aux ventes" | 303 | assert out[0].title == "Associé(e) aux ventes" | ||
| 296 | assert out[0].salary_label == "18 $ à 25 $/h" | 304 | assert out[0].salary_label == "18 $ à 25 $/h" | ||
| 297 | assert out[0].details["employment_label"] == "Temps plein" | 305 | assert out[0].details["employment_label"] == "Temps plein" | ||
| 306 | + assert deactivated == ["eq3_ltd:bbb222"] | ||||
| 298 | 307 | ||||
| 299 | # 2. liste non vide → chemin normal, le repli n'est jamais sollicité | 308 | # 2. liste non vide → chemin normal, le repli n'est jamais sollicité | ||
| 300 | conn2 = Eq3LtdConnector() | 309 | conn2 = Eq3LtdConnector() | ||
| 301 | 310 | ||||