SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%

feat: intégration de job-ka (9e service KA) — collecteur + supervision

- Collecteur JobkaCollector (src/collectors/jobka_collector.py) : GET /api/jobs
  de job-ka (M3U96a:8096), pagination limit/offset (clé jobs, total annoncé),
  table jobka_data — pipeline standard fetch → checksum → dédup → backup pg_dump
  → collection_runs. One-shot validé : 7 953 offres actives collectées.
- Config : jobka ajouté aux SERVICES (9) + JOBKA_SOURCE_URL (.env/.env.example).
  Il hérite automatiquement du run quotidien 02:00, du backfill, du backup 03:30
  et des endpoints /api/v1/jobka{,/latest,/date/…,/stats}.
- Supervision : jobka dans connector_health (cadence attendue 1 h, recent_syncs
  par source + facteur de rotation — 200+ sources pour une fenêtre de 20) ;
  visible dans /api/v1/monitoring/connectors (19 sources + _app, tout ok).
- Stats : libellé Job·Ka ; page /stats à 9 services.
- Tests : 3 tests jobka (pipeline, pagination offset, registre) + compteurs
  8→9 — suite complète 51 passed.

Rien à changer côté CORS (job-ka.com déjà dans les origines) ni côté agent KA
(chercher_emplois pointe déjà sur www.job-ka.com/api/jobs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 99bff92

13 changed files +148 −16

modified .env.example +1 −0
@@ -26,3 +26,4 @@ FABRIKA_SOURCE_URL=
26 26 RESTOKA_SOURCE_URL=
27 27 SORTIKA_SOURCE_URL=
28 28 CREAKA_SOURCE_URL=
29 +JOBKA_SOURCE_URL=
modified src/api/routes/stats.py +1 −0
@@ -65,6 +65,7 @@ SERVICE_NAMES = {
65 65 "restoka": "Resto·Ka",
66 66 "sortika": "Sorti·Ka",
67 67 "creaka": "Créa·Ka",
68 + "jobka": "Job·Ka",
68 69 }
69 70
70 71 PERIOD_LABELS = {
modified src/api/web/stats.html +2 −2
@@ -19,12 +19,12 @@ records, bouton PDF). Données : GET /api/stats/dashboard — rien d'inventé.
19 19 <meta charset="utf-8">
20 20 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
21 21 <title>Statistiques — API-Ka — Un service Groupe KA</title>
22 −<meta name="description" content="Tableau de bord analytique d'API-Ka : appels API par endpoint, latences moyennes et p95, taux d'erreur, runs de collecte quotidiens des 8 services KA et export PDF Groupe-KA.">
22 +<meta name="description" content="Tableau de bord analytique d'API-Ka : appels API par endpoint, latences moyennes et p95, taux d'erreur, runs de collecte quotidiens des 9 services KA et export PDF Groupe-KA.">
23 23 <meta name="theme-color" content="#f5f3ee">
24 24 <link rel="icon" type="image/svg+xml" href="/favicon.svg">
25 25 <link rel="apple-touch-icon" href="/apple-touch-icon.png">
26 26 <meta property="og:title" content="Statistiques — API-Ka — Un service Groupe KA">
27 −<meta property="og:description" content="Tableau de bord analytique d'API-Ka : appels API, latences, taux d'erreur, collectes quotidiennes des 8 services KA et export PDF Groupe-KA.">
27 +<meta property="og:description" content="Tableau de bord analytique d'API-Ka : appels API, latences, taux d'erreur, collectes quotidiennes des 9 services KA et export PDF Groupe-KA.">
28 28 <meta property="og:url" content="https://www.api-ka.com/stats">
29 29 <meta property="og:type" content="website">
30 30 <meta property="og:image" content="https://www.api-ka.com/og.png">
modified src/collectors/__init__.py +4 −2
@@ -6,7 +6,7 @@
6 6 # Contact : contact@spboucher.ai
7 7 # Date : 2026-08-16
8 8 # ============================================
9 −"""Collecteurs des 8 services KA et fabriques associées."""
9 +"""Collecteurs des 9 services KA et fabriques associées."""
10 10
11 11 from __future__ import annotations
12 12
@@ -16,6 +16,7 @@ from src.collectors.creaka_collector import CreakaCollector
16 16 from src.collectors.fabrika_collector import FabrikaCollector
17 17 from src.collectors.foodka_collector import FoodkaCollector
18 18 from src.collectors.immoka_collector import ImmokaCollector
19 +from src.collectors.jobka_collector import JobkaCollector
19 20 from src.collectors.louka_collector import LoukaCollector
20 21 from src.collectors.restoka_collector import RestokaCollector
21 22 from src.collectors.sortika_collector import SortikaCollector
@@ -29,6 +30,7 @@ COLLECTORS: dict[str, type[BaseCollector]] = {
29 30 "restoka": RestokaCollector,
30 31 "sortika": SortikaCollector,
31 32 "creaka": CreakaCollector,
33 + "jobka": JobkaCollector,
32 34 }
33 35
34 36
@@ -45,5 +47,5 @@ def get_collector(service: str) -> BaseCollector:
45 47
46 48
47 49 def build_collectors() -> list[BaseCollector]:
48 − """Instancie les 8 collecteurs, dans l'ordre canonique des services."""
50 + """Instancie les 9 collecteurs, dans l'ordre canonique des services."""
49 51 return [cls() for cls in COLLECTORS.values()]
added src/collectors/jobka_collector.py +28 −0
@@ -0,0 +1,28 @@
1 +# ============================================
2 +# Projet : API-KA
3 +# Fichier : src/collectors/jobka_collector.py
4 +# Node : m3u96b
5 +# Author : Simon-Pierre Boucher
6 +# Contact : contact@spboucher.ai
7 +# Date : 2026-08-18
8 +# ============================================
9 +"""Collecteur quotidien du service job-ka (table jobka_data)."""
10 +
11 +from __future__ import annotations
12 +
13 +from src.collectors.base_collector import BaseCollector
14 +
15 +
16 +class JobkaCollector(BaseCollector):
17 + """Collecte les offres d'emploi quotidiennes de job-ka depuis JOBKA_SOURCE_URL.
18 +
19 + L'API source (``GET /api/jobs``) pagine en limit/offset (max 2000) et
20 + renvoie ``{"total": N, "count": n, "jobs": [...]}`` ; par défaut elle ne
21 + sert que les offres actives (``active=1``), ce qui est le comportement
22 + voulu pour la photo quotidienne.
23 + """
24 +
25 + service = "jobka"
26 + items_key = "jobs"
27 + pagination = "offset"
28 + page_size = 500 # l'API job-ka accepte jusqu'à 2000 ; 500 = pages raisonnables
modified src/config.py +2 −0
@@ -29,6 +29,7 @@ SERVICES: tuple[str, ...] = (
29 29 "restoka",
30 30 "sortika",
31 31 "creaka",
32 + "jobka",
32 33 )
33 34
34 35 SERVICE_TABLES: dict[str, str] = {service: f"{service}_data" for service in SERVICES}
@@ -42,6 +43,7 @@ SERVICE_SOURCE_ENV: dict[str, str] = {
42 43 "restoka": "RESTOKA_SOURCE_URL",
43 44 "sortika": "SORTIKA_SOURCE_URL",
44 45 "creaka": "CREAKA_SOURCE_URL",
46 + "jobka": "JOBKA_SOURCE_URL",
45 47 }
46 48
47 49 BASE_DIR = Path(__file__).resolve().parents[1]
modified src/database/models.py +9 −2
@@ -6,7 +6,7 @@
6 6 # Contact : contact@spboucher.ai
7 7 # Date : 2026-08-16
8 8 # ============================================
9 −"""Modèles SQLAlchemy : 8 tables de données (schéma identique) + collection_runs.
9 +"""Modèles SQLAlchemy : 9 tables de données (schéma identique) + collection_runs.
10 10
11 11 Règle append-only : aucune suppression destructive, l'historique complet est conservé.
12 12 Déduplication par contrainte unique (source, date_key, checksum).
@@ -45,7 +45,7 @@ class Base(DeclarativeBase):
45 45
46 46
47 47 class DataRecordMixin:
48 − """Colonnes communes aux 8 tables de données des services KA."""
48 + """Colonnes communes aux 9 tables de données des services KA."""
49 49
50 50 id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True)
51 51 payload: Mapped[dict[str, Any]] = mapped_column(JSONPayload, nullable=False)
@@ -119,6 +119,12 @@ class CreakaData(DataRecordMixin, Base):
119 119 __tablename__ = "creaka_data"
120 120
121 121
122 +class JobkaData(DataRecordMixin, Base):
123 + """Données quotidiennes du service job-ka."""
124 +
125 + __tablename__ = "jobka_data"
126 +
127 +
122 128 DATA_MODELS: dict[str, type[Base]] = {
123 129 "louka": LoukaData,
124 130 "immoka": ImmokaData,
@@ -128,6 +134,7 @@ DATA_MODELS: dict[str, type[Base]] = {
128 134 "restoka": RestokaData,
129 135 "sortika": SortikaData,
130 136 "creaka": CreakaData,
137 + "jobka": JobkaData,
131 138 }
132 139
133 140
modified src/monitoring/connector_health.py +4 −3
@@ -6,7 +6,7 @@
6 6 # Contact : contact@spboucher.ai
7 7 # Date : 2026-08-18
8 8 # ============================================
9 −"""Supervision centralisée des connecteurs des 8 apps de l'écosystème KA.
9 +"""Supervision centralisée des connecteurs des 9 apps de l'écosystème KA.
10 10
11 11 Toutes les 2 h, le job lit le ``GET /api/stats`` de chaque app sœur (LAN,
12 12 lecture seule, timeout 5 s) et en déduit un état par source quand le détail
@@ -77,6 +77,7 @@ EXPECTED_CADENCE_HOURS: dict[str, float] = {
77 77 "sortika": 1,
78 78 "creaka": 24,
79 79 "restoka": 168,
80 + "jobka": 1,
80 81 }
81 82
82 83
@@ -233,7 +234,7 @@ def parse_sync_entries(payload: dict[str, Any]) -> dict[str, list[SyncEntry]]:
233 234 """Extrait les entrées de sync par source depuis un payload /api/stats.
234 235
235 236 Comprend les deux formats observés dans l'écosystème :
236 − ``recent_syncs`` (lou-ka, immo-ka, auto-ka, food-ka, resto-ka) et
237 + ``recent_syncs`` (lou-ka, immo-ka, auto-ka, food-ka, resto-ka, job-ka) et
237 238 ``sync_log`` par magasin (fabri-ka, clé ``store_id`` + ``status``).
238 239 """
239 240 raw = payload.get("recent_syncs") or payload.get("sync_log") or []
@@ -484,7 +485,7 @@ def _load_rows(session: Any, service: str) -> dict[str, ConnectorHealth]:
484 485
485 486
486 487 def run_connector_health_check(now: float | None = None) -> dict[str, Any]:
487 − """Point d'entrée du job (toutes les 2 h) : vérifie les 8 apps.
488 + """Point d'entrée du job (toutes les 2 h) : vérifie les 9 apps.
488 489
489 490 L'échec d'une app (réseau, payload) ne fait jamais échouer le job.
490 491 """
modified src/scheduler/daily_job.py +3 −3
@@ -6,7 +6,7 @@
6 6 # Contact : contact@spboucher.ai
7 7 # Date : 2026-08-16
8 8 # ============================================
9 −"""Job quotidien (02:00, heure du node m3u96b) orchestrant les 8 collecteurs KA.
9 +"""Job quotidien (02:00, heure du node m3u96b) orchestrant les 9 collecteurs KA.
10 10
11 11 Les collecteurs s'exécutent en parallèle mais de façon indépendante :
12 12 l'échec d'un service ne bloque jamais les autres. Le backfill des 7 derniers
@@ -34,7 +34,7 @@ from src.utils.logger import alert, get_logger
34 34
35 35
36 36 def run_all(date_key: datetime.date | None = None) -> list[dict[str, Any]]:
37 − """Exécute les 8 collecteurs en parallèle puis le backfill des 7 derniers jours.
37 + """Exécute les 9 collecteurs en parallèle puis le backfill des 7 derniers jours.
38 38
39 39 Returns:
40 40 Résumés des runs du jour (un par service).
@@ -132,7 +132,7 @@ def main() -> None:
132 132 misfire_grace_time=3600,
133 133 )
134 134 # Supervision des connecteurs de l'écosystème (lecture seule des
135 − # /api/stats des 8 apps sœurs) : toutes les 2 h, premier passage 90 s
135 + # /api/stats des 9 apps sœurs) : toutes les 2 h, premier passage 90 s
136 136 # après le démarrage du scheduler.
137 137 scheduler.add_job(
138 138 run_connector_health_check,
modified src/utils/backup.py +1 −1
@@ -121,7 +121,7 @@ def backup_service(service: str, date_key: datetime.date | None = None) -> Path:
121 121
122 122
123 123 def backup_all(date_key: datetime.date | None = None) -> list[Path]:
124 − """Sauvegarde les 8 services ; un échec n'interrompt pas les autres."""
124 + """Sauvegarde les 9 services ; un échec n'interrompt pas les autres."""
125 125 paths: list[Path] = []
126 126 for service in SERVICES:
127 127 try:
modified tests/conftest.py +1 −1
@@ -23,7 +23,7 @@ from pathlib import Path
23 23 _TMP_DIR = tempfile.mkdtemp(prefix="apika-tests-")
24 24 os.environ["APP_ENV"] = "test"
25 25 os.environ["DATABASE_URL"] = f"sqlite:///{_TMP_DIR}/apika_test.db"
26 −for _svc in ("LOUKA", "IMMOKA", "FOODKA", "AUTOKA", "FABRIKA"):
26 +for _svc in ("LOUKA", "IMMOKA", "FOODKA", "AUTOKA", "FABRIKA", "JOBKA"):
27 27 os.environ[f"{_svc}_SOURCE_URL"] = f"https://example.test/{_svc.lower()}"
28 28
29 29 import pytest # noqa: E402
modified tests/test_collectors.py +91 −1
@@ -59,7 +59,7 @@ def _last_run(service: str, date_key: datetime.date) -> CollectionRun | None:
59 59 def test_registry_covers_all_services() -> None:
60 60 """Chaque service KA a un collecteur enregistré."""
61 61 assert set(COLLECTORS) == set(SERVICES)
62 − assert len(build_collectors()) == 8
62 + assert len(build_collectors()) == 9
63 63 with pytest.raises(ValueError):
64 64 get_collector("inconnu")
65 65
@@ -158,3 +158,93 @@ def test_checksum_is_stable_and_order_insensitive() -> None:
158 158 assert a == b
159 159 assert a != c
160 160 assert len(a) == 64
161 +
162 +
163 +# ------------------------------------------------------------------- job-ka
164 +
165 +
166 +def test_jobka_pipeline_inserts_into_jobka_data(
167 + monkeypatch: pytest.MonkeyPatch,
168 +) -> None:
169 + """Le collecteur job-ka insère dans jobka_data et journalise un run success."""
170 + from src.collectors.jobka_collector import JobkaCollector
171 +
172 + date_key = datetime.date(2026, 8, 5)
173 + collector = JobkaCollector(attempts=1, delays=(0,))
174 + monkeypatch.setattr(
175 + collector,
176 + "fetch",
177 + lambda: [
178 + {"uid": "a1", "title": "Analyste", "employer": "Ville de Québec"},
179 + {"uid": "b2", "title": "Technicien", "employer": "CHU de Québec"},
180 + ],
181 + )
182 +
183 + result = collector.run(date_key=date_key)
184 +
185 + assert result["service"] == "jobka"
186 + assert result["status"] == "success"
187 + assert result["records_count"] == 2
188 + assert _count_rows("jobka", date_key) == 2
189 + run = _last_run("jobka", date_key)
190 + assert run is not None
191 + assert run.status == "success"
192 +
193 +
194 +def test_jobka_fetch_paginates_offset_until_total(
195 + monkeypatch: pytest.MonkeyPatch,
196 +) -> None:
197 + """fetch() pagine /api/jobs (limit/offset, clé "jobs") jusqu'au total annoncé."""
198 + from src.collectors import base_collector
199 + from src.collectors.jobka_collector import JobkaCollector
200 +
201 + total = 7
202 + jobs = [{"uid": f"job-{i}", "title": f"Poste {i}"} for i in range(total)]
203 + calls: list[dict] = []
204 +
205 + class FakeResponse:
206 + def __init__(self, offset: int, limit: int) -> None:
207 + self._batch = jobs[offset : offset + limit]
208 +
209 + def raise_for_status(self) -> None:
210 + return None
211 +
212 + def json(self) -> dict:
213 + return {"total": total, "count": len(self._batch), "jobs": self._batch}
214 +
215 + class FakeClient:
216 + def __init__(self, *args, **kwargs) -> None:
217 + pass
218 +
219 + def __enter__(self) -> "FakeClient":
220 + return self
221 +
222 + def __exit__(self, *exc) -> None:
223 + return None
224 +
225 + def get(self, url: str, params: dict) -> FakeResponse:
226 + calls.append({"url": url, **params})
227 + return FakeResponse(params["offset"], params["limit"])
228 +
229 + monkeypatch.setattr(base_collector.httpx, "Client", FakeClient)
230 + collector = JobkaCollector(attempts=1, delays=(0,))
231 + monkeypatch.setattr(type(collector), "page_size", 3)
232 +
233 + items = collector.fetch()
234 +
235 + assert [i["uid"] for i in items] == [f"job-{i}" for i in range(total)]
236 + # 3 pages : offsets 0, 3, 6 — l'arrêt vient du total annoncé par l'API.
237 + assert [c["offset"] for c in calls] == [0, 3, 6]
238 + assert all(c["limit"] == 3 for c in calls)
239 +
240 +
241 +def test_jobka_registered_with_expected_settings() -> None:
242 + """job-ka est enregistré dans le registre avec la bonne configuration."""
243 + from src.collectors.jobka_collector import JobkaCollector
244 +
245 + assert COLLECTORS["jobka"] is JobkaCollector
246 + collector = get_collector("jobka")
247 + assert collector.service == "jobka"
248 + assert collector.items_key == "jobs"
249 + assert collector.pagination == "offset"
250 + assert DATA_MODELS["jobka"].__tablename__ == "jobka_data"
modified tests/test_connector_health.py +1 −1
@@ -293,7 +293,7 @@ def test_job_app_injoignable_ne_crashe_pas(
293 293
294 294 monkeypatch.setattr(ch, "_fetch_stats", _boom)
295 295 summary = run_connector_health_check(NOW)
296 − assert len(summary["services"]) == 8
296 + assert len(summary["services"]) == 9
297 297 with session_scope() as session:
298 298 row = session.execute(
299 299 select(ConnectorHealth).where(
300 300