SPB Git forge

spb/job-ka

Public
227commits 1branches 0releases
37.7 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

[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 9 days ago (Sep 15, 2026) parent 4990c80

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