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>
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 | |