SPB Git

Repository Public

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>
Simon-Pierre Boucher committed · Sep 15, 2026 parent 4990c80
3 changed files +196 −3 Browse files
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
Menu

Theme