taleo_tbe: secours og:title (retrait du JSON-LD par la plateforme) + fenêtre syncs_since_h
- Taleo TBE a retiré le JSON-LD JobPosting de requisition.jsp : les offres
sans cache de détail étaient toutes différées par le filtre Québec →
kruger publiait 0 offre depuis des jours (72 items RSS). Secours : lieu
extrait du og:title (« … – Ville, RÉGION, PAYS »). Kruger retrouve 54
offres QC ; sepaq/hec/exo tenaient encore grâce à leurs caches.
- base.detail() : ne met plus en cache les payloads vides ({} cadenassé par
clé stable = connecteur gelé) ; 89 caches vides purgés de la base.
- /api/stats : paramètre optionnel syncs_since_h pour la supervision api-ka
(fenêtre complète du sync_log au lieu des 20 dernières entrées).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 changed files +49 −7
modified
jobka/connectors/base.py
+7 −3
@@ -141,9 +141,13 @@ class BaseConnector: | ||
| 141 | 141 | if cached is not None: |
| 142 | 142 | return cached |
| 143 | 143 | payload = fetch_fn() or {} |
| 144 | − # détail « vide » (aucune description alors que le connecteur en | |
| 145 | − # attendait une) : NE PAS le mettre en cache — il sera re-tenté au | |
| 146 | − # prochain cycle au lieu de figer une fiche sans contenu | |
| 144 | + # détail « vide » (payload sans aucun champ, ou aucune description | |
| 145 | + # alors que le connecteur en attendait une) : NE PAS le mettre en | |
| 146 | + # cache — il sera re-tenté au prochain cycle au lieu de figer une | |
| 147 | + # fiche sans contenu (un {} cadenassé par clé stable avait gelé les | |
| 148 | + # connecteurs Taleo TBE quand la plateforme a retiré son JSON-LD) | |
| 149 | + if not payload: | |
| 150 | + return payload | |
| 147 | 151 | desc_keys = [k for k in ("description", "description_html") |
| 148 | 152 | if k in payload] |
| 149 | 153 | if desc_keys and not any((payload.get(k) or "").strip() |
modified
jobka/connectors/taleo_tbe.py
+26 −1
@@ -42,6 +42,8 @@ _TAG_RE = {t: re.compile(rf"<{t}>(.*?)</{t}>", re.S | re.I) | ||
| 42 | 42 | for t in ("title", "link", "description")} |
| 43 | 43 | _RID_RE = re.compile(r"[?&]rid=(\d+)", re.I) |
| 44 | 44 | _QC_REGION = {"QC", "QUÉBEC", "QUEBEC"} |
| 45 | +_OG_TITLE_RE = re.compile( | |
| 46 | + r'<meta\s+property="og:title"\s+content="([^"]*)"', re.I) | |
| 45 | 47 | |
| 46 | 48 | |
| 47 | 49 | class TaleoTBEConnector(BaseConnector): |
@@ -71,7 +73,30 @@ class TaleoTBEConnector(BaseConnector): | ||
| 71 | 73 | def _fetch_detail(self, rid: str) -> dict: |
| 72 | 74 | html = self.get(self._detail_url(rid)).text |
| 73 | 75 | node = _jsonld.extract_jobposting(html) |
| 74 | − return _jsonld.jobposting_fields(node) if node else {} | |
| 76 | + if node: | |
| 77 | + return _jsonld.jobposting_fields(node) | |
| 78 | + return self._fields_from_og(html) | |
| 79 | + | |
| 80 | + @staticmethod | |
| 81 | + def _fields_from_og(html: str) -> dict: | |
| 82 | + """Secours quand le JSON-LD JobPosting est absent — retrait constaté | |
| 83 | + sur toute la plateforme TBE le 2026-08-23 (les connecteurs TBE | |
| 84 | + publiaient 0 offre, ex. Kruger : 72 items RSS, 0 retenu). Le lieu | |
| 85 | + reste exposé dans og:title : « … – Ville, RÉGION, PAYS ».""" | |
| 86 | + m = _OG_TITLE_RE.search(html or "") | |
| 87 | + if not m: | |
| 88 | + return {} | |
| 89 | + title = _html.unescape(m.group(1)) | |
| 90 | + if "–" not in title: | |
| 91 | + return {} | |
| 92 | + place = title.rsplit("–", 1)[1].strip() | |
| 93 | + parts = [p.strip() for p in place.split(",") if p.strip()] | |
| 94 | + if not parts: | |
| 95 | + return {} | |
| 96 | + fields: dict = {"city": parts[0]} | |
| 97 | + if len(parts) >= 2: | |
| 98 | + fields["region_code"] = parts[1].upper() | |
| 99 | + return fields | |
| 75 | 100 | |
| 76 | 101 | def _is_qc(self, fields: dict) -> bool: |
| 77 | 102 | region = (fields.get("region_code") or "").upper() |
modified
jobka/web.py
+16 −3
@@ -302,7 +302,7 @@ def sources(): | ||
| 302 | 302 | |
| 303 | 303 | |
| 304 | 304 | @app.get("/api/stats") |
| 305 | −def stats(): | |
| 305 | +def stats(syncs_since_h: float | None = None): | |
| 306 | 306 | con = db.connect() |
| 307 | 307 | row = con.execute( |
| 308 | 308 | """SELECT COUNT(*) total, |
@@ -325,8 +325,21 @@ def stats(): | ||
| 325 | 325 | """SELECT category, COUNT(*) n FROM jobs |
| 326 | 326 | WHERE active=1 AND dup_of IS NULL AND category<>'' |
| 327 | 327 | GROUP BY category ORDER BY n DESC""")] |
| 328 | − log = [dict(r) for r in con.execute( | |
| 329 | − "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] | |
| 328 | + # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on | |
| 329 | + # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre | |
| 330 | + # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation | |
| 331 | + # des sources → fausses alertes « stale »). | |
| 332 | + if syncs_since_h and syncs_since_h > 0: | |
| 333 | + cutoff = time.time() - syncs_since_h * 3600 | |
| 334 | + log = [dict(r) for r in con.execute( | |
| 335 | + "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000", | |
| 336 | + (cutoff,))] | |
| 337 | + if len(log) < 20: | |
| 338 | + log = [dict(r) for r in con.execute( | |
| 339 | + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] | |
| 340 | + else: | |
| 341 | + log = [dict(r) for r in con.execute( | |
| 342 | + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] | |
| 330 | 343 | # offres directes (pages carrières + dépôt direct) c. portails agrégateurs |
| 331 | 344 | agg = sorted(AGGREGATORS) or ["__aucun__"] |
| 332 | 345 | ph = ",".join("?" * len(agg)) |
| 333 | 346 | |