SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%

Forma-Ka — Québec training aggregator: 18 connectors, detail-first Formation schema, FastAPI + React PWA

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 h ago (Aug 12, 2026)

Showing 54 changed files with +10,143 and −0

added .gitignore +8 −0
@@ -0,0 +1,8 @@
1 +.env
2 +.venv/
3 +__pycache__/
4 +*.pyc
5 +data/formaka.db
6 +frontend/node_modules/
7 +frontend/dist/
8 +.DS_Store
added README.md +191 −0
@@ -0,0 +1,191 @@
1 +<div align="center">
2 +
3 +# Forma·Ka
4 +
5 +### Every training program in Québec. One place.
6 +
7 +**[www.forma-ka.com](https://www.forma-ka.com)**
8 +
9 +![Python](https://img.shields.io/badge/Python-3.14-141814?style=for-the-badge&logo=python&logoColor=ffd54d)
10 +![FastAPI](https://img.shields.io/badge/FastAPI-API-141814?style=for-the-badge&logo=fastapi&logoColor=ffd54d)
11 +![React](https://img.shields.io/badge/React_18-Vite_+_TS-141814?style=for-the-badge&logo=react&logoColor=ffd54d)
12 +![SQLite](https://img.shields.io/badge/SQLite-storage-141814?style=for-the-badge&logo=sqlite&logoColor=ffd54d)
13 +![PWA](https://img.shields.io/badge/PWA-mobile_ready-141814?style=for-the-badge&logoColor=ffd54d)
14 +
15 +![Sources](https://img.shields.io/badge/sources-18-1d3f66?style=flat-square)
16 +![Connecteurs](https://img.shields.io/badge/active_connectors-18-1d3f66?style=flat-square)
17 +![Formations](https://img.shields.io/badge/aggregated_programs-4%2C400%2B-1d3f66?style=flat-square)
18 +![Gratuites](https://img.shields.io/badge/free_programs-130%2B-1d3f66?style=flat-square)
19 +![Couverture](https://img.shields.io/badge/coverage-all_of_Qu%C3%A9bec-1d3f66?style=flat-square)
20 +
21 +*Independent aggregator of training programs — online courses, university and
22 +college courses, seminars, workshops, certifications and bootcamps — every
23 +program with its full standardized details and a direct link to the original
24 +page. Always up to date, automatically.*
25 +
26 +<br/>
27 +
28 +**Author : Simon-Pierre Boucher — [contact@spboucher.ai](mailto:contact@spboucher.ai)**
29 +
30 +</div>
31 +
32 +---
33 +
34 +## Screenshots
35 +
36 +| Home — search, filters, live ticker | Program page — details first |
37 +|---|---|
38 +| ![Forma-Ka home](docs/home.png) | ![Program details](docs/formation.png) |
39 +
40 +<details>
41 +<summary><b>Stats page — live portrait of Québec's training offer</b></summary>
42 +
43 +![Stats](docs/stats.png)
44 +
45 +</details>
46 +
47 +## Why Forma-Ka?
48 +
49 +Looking for training in Québec means opening dozens of websites — universities,
50 +CEGEPs, training firms, event organizers — each with its own navigation and
51 +format. **Forma-Ka flips the problem**: a dedicated connector per institution
52 +visits each site, normalizes every program into a single schema, and detects
53 +changes continuously.
54 +
55 +> Training sites don't offer webhooks. Forma-Ka reproduces the equivalent:
56 +> **periodic sync + content hashing** → additions, updates and removals detected
57 +> automatically. A program that disappears from the source site disappears from
58 +> Forma-Ka (after a 2-sync grace period).
59 +
60 +**Philosophy**: unlike a product aggregator, **price is optional** (university
61 +courses don't display one) — what matters are the **details** of each program:
62 +full description, learning objectives, course outline, prerequisites, target
63 +audience, duration, credits/CEUs, delivery mode, offered dates.
64 +
65 +## Architecture in 30 seconds
66 +
67 +```mermaid
68 +flowchart LR
69 + subgraph Sources["18 training institutions"]
70 + S1["ÉTS Formation · TÉLUQ · ULaval<br/>McGill · HEC · UQAM · Technologia<br/>AFI · Cégep à distance · Les Affaires<br/>… one connector per site"]
71 + end
72 + subgraph FormaKa["Forma-Ka"]
73 + C["Connectors<br/><i>1 adapter / site</i>"] --> N["Normalization<br/><i>single Formation schema</i>"]
74 + N --> D[("SQLite<br/>hash + diff")]
75 + D --> A["FastAPI<br/>/api/formations · /api/facets"]
76 + A --> F["React 18 + Vite<br/>mobile PWA · light theme"]
77 + end
78 + W["⏱ Periodic watcher<br/>(PM2)"] -.-> C
79 + S1 --> C
80 + F --> U["🔑 Learner"]
81 +```
82 +
83 +| Layer | Role | Files |
84 +|---|---|---|
85 +| **Connectors** | 1 Python module per institution: server-rendered HTML, internal JSON APIs (Shopify products.json, WordPress REST, Gatsby page-data, Destiny One…), schema.org JSON-LD (Course/Event), **Scrapfly** (robust anti-bot) or **Firecrawl** (JS rendering) for hard sites | `formaka/connectors/*.py` |
86 +| **Schema** | Standardized `Formation`: type, category, mode, description, objectives, outline, prerequisites, duration, credits/CEUs, sessions, price *(optional)* | `formaka/schema.py` |
87 +| **Diff engine** | Content-hash upsert — new / changed / gone (grace period), connector drift detection | `formaka/db.py` |
88 +| **API** | Filters: type / category / mode / city / language / level / free / price / source / search, facets, stats, sync trigger | `formaka/web.py` |
89 +| **Frontend** | "Editorial sharp" design: Space Grotesk, offset shadows, amber accent, live ticker, mobile bottom sheet, 12-per-page pagination, installable PWA | `frontend/` |
90 +
91 +## The three fetch backends
92 +
93 +Each connector picks one (or chains them via the automatic `fetch_html()` fallback):
94 +
95 +1. **direct requests** — server-rendered sites (fast, free);
96 +2. **Scrapfly** (`SCRAPFLY_API_KEY`) — robust backend: anti-bot bypass (`asp`),
97 + JavaScript rendering (`render_js`), Canadian geolocation;
98 +3. **Firecrawl** (`FIRECRAWL_API_KEY`) — fallback JS rendering, html/markdown formats.
99 +
100 +Detail pages are cached in the database (`detail_cache`) with a weekly key:
101 +each page is revisited only when new, changed, or when the ISO week rolls over.
102 +
103 +## Aggregated sources
104 +
105 +| Institution | Offer | Programs |
106 +|---|---|---|
107 +| Université Laval — Distance | University courses (distance/hybrid) | ~1,760 |
108 +| Technologia | Professional training — IT, AI, management | ~590 |
109 +| Université TÉLUQ | University courses, 100 % online | ~510 |
110 +| AFI by Edgenda | Professional training — IT, leadership | ~360 |
111 +| ÉTS Formation | Continuing education + CEUs | ~300 |
112 +| Versalys | Office tools, IT, languages | ~230 |
113 +| McGill School of Continuing Studies | Continuing education (English) | ~200 |
114 +| Isarta Formations | Marketing, communications, HR | ~190 |
115 +| CRHA — Espace Formation | HR training and events | ~175 |
116 +| Événements Les Affaires | Business conferences and webinars | ~135 |
117 +| Cégep à distance | College courses at a distance | ~120 |
118 +| HEC Montréal — École des dirigeant(e)s | Executive seminars and certifications | ~90 |
119 +| ITHQ | Wine/food workshops + hospitality training | ~80 |
120 +| École des entrepreneurs du Québec | Entrepreneur training (mostly free) | ~45 |
121 +| Institut de leadership | Leadership certifications and programs | ~35 |
122 +| AlphaNumérique | Free digital-literacy courses | ~30 |
123 +| Formation continue UQAM | Continuing education + CEUs | ~50 |
124 +| Le Wagon Montréal | Web dev / data / AI bootcamps | 4 |
125 +
126 +## Quick start
127 +
128 +```bash
129 +git clone https://git.spboucher.ai/forma-ka.git && cd forma-ka
130 +
131 +# Backend
132 +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
133 +
134 +# Frontend
135 +cd frontend && npm install && npm run build && cd ..
136 +
137 +# Scraping backend keys (JavaScript / anti-bot sites)
138 +cat > .env <<EOF
139 +FIRECRAWL_API_KEY=fc-your-key
140 +SCRAPFLY_API_KEY=scp-live-your-key
141 +EOF
142 +
143 +# Ingest, then serve
144 +.venv/bin/python run.py sync # all sources (or: run.py sync ets_formation teluq)
145 +.venv/bin/python run.py serve 8080 # API + frontend -> http://localhost:8080
146 +.venv/bin/python run.py watch 360 # sync loop (default: every 6 h)
147 +```
148 +
149 +## Adding a connector
150 +
151 +1. Create `formaka/connectors/<source_id>.py`: a class inheriting from
152 + `BaseConnector`, define `source_id` and implement `fetch() -> list[Formation]`.
153 + The registry is **auto-discovering** — nothing else to edit.
154 +2. Add the matching entry to `data/sources.json`.
155 +3. Test: `.venv/bin/python run.py sync <source_id>`.
156 +
157 +```python
158 +class MySchoolConnector(BaseConnector):
159 + source_id = "my_school"
160 +
161 + def fetch(self) -> list[Formation]:
162 + html = self.fetch_html(LIST_URL) # direct -> Scrapfly -> Firecrawl
163 + ...
164 + return [Formation(source=self.source_id, external_id=..., url=...,
165 + title=..., description=..., objectives=[...], ...)]
166 +```
167 +
168 +## API
169 +
170 +| Endpoint | Description |
171 +|---|---|
172 +| `GET /api/formations` | Filterable list (`training_type`, `category`, `mode`, `city`, `language`, `level`, `source`, `free`, `price_max`, `starts_after`, `q`, `sort`, `limit`, `offset`) |
173 +| `GET /api/formations/{uid}` | Full program page + price history + similar programs |
174 +| `GET /api/facets` | Distinct values for building filters |
175 +| `GET /api/sources` | Institution registry + sync state |
176 +| `GET /api/stats` | Global portrait + sync log |
177 +| `POST /api/sync` | Trigger a background sync |
178 +
179 +## Tests
180 +
181 +```bash
182 +.venv/bin/python -m pytest tests/ -q
183 +```
184 +
185 +---
186 +
187 +<div align="center">
188 +
189 +© 2026 **Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai)
190 +
191 +</div>
added data/sources.json +185 −0
@@ -0,0 +1,185 @@
1 +{
2 + "_comment": "Forma-Ka — Registre des sources (établissements de formation, province de Québec). Auteur : Simon-Pierre Boucher — contact@spboucher.ai",
3 + "sources": [
4 + {
5 + "id": "ets_formation",
6 + "name": "ÉTS Formation",
7 + "url": "https://www.perf.etsmtl.ca",
8 + "listing_url": "https://www.perf.etsmtl.ca/Formations",
9 + "type_offre": "Formation continue professionnelle — technologie, construction, gestion, RH (attestations + UEC)",
10 + "connector": "ets_formation",
11 + "status": "actif",
12 + "region": "Montréal"
13 + },
14 + {
15 + "id": "teluq",
16 + "name": "Université TÉLUQ",
17 + "url": "https://www.teluq.ca",
18 + "listing_url": "https://www.teluq.ca/etudes/cours",
19 + "type_offre": "Cours universitaires 100 % à distance — tous cycles, ~500 cours crédités",
20 + "connector": "teluq",
21 + "status": "actif",
22 + "region": "Québec (à distance)"
23 + },
24 + {
25 + "id": "ulaval_distance",
26 + "name": "Université Laval — Formation à distance",
27 + "url": "https://www.distance.ulaval.ca",
28 + "listing_url": "https://www.distance.ulaval.ca/etudes/cours",
29 + "type_offre": "Cours universitaires à distance, hybrides et comodaux (~1 750 cours crédités)",
30 + "connector": "ulaval_distance",
31 + "status": "actif",
32 + "region": "Québec (à distance / hybride)"
33 + },
34 + {
35 + "id": "uqam_perfectionnement",
36 + "name": "Formation continue UQAM",
37 + "url": "https://formation.uqam.ca",
38 + "listing_url": "https://formation.uqam.ca/formations/",
39 + "type_offre": "Formation continue universitaire — UEC, séminaires et perfectionnement professionnel",
40 + "connector": "uqam_perfectionnement",
41 + "status": "actif",
42 + "region": "Montréal"
43 + },
44 + {
45 + "id": "hec_dirigeants",
46 + "name": "École des dirigeant(e)s HEC Montréal",
47 + "url": "https://ecole-dirigeants.hec.ca",
48 + "listing_url": "https://ecole-dirigeants.hec.ca/collections/all",
49 + "type_offre": "Séminaires et certifications pour cadres et dirigeant·e·s",
50 + "connector": "hec_dirigeants",
51 + "status": "actif",
52 + "region": "Montréal"
53 + },
54 + {
55 + "id": "mcgill_scs",
56 + "name": "McGill School of Continuing Studies",
57 + "url": "https://continuingstudies.mcgill.ca",
58 + "listing_url": "https://continuingstudies.mcgill.ca/search/publicCourseAdvancedSearch.do",
59 + "type_offre": "Formation continue universitaire (anglais) — professionnel, langues, CEU",
60 + "connector": "mcgill_scs",
61 + "status": "actif",
62 + "region": "Montréal"
63 + },
64 + {
65 + "id": "cegep_a_distance",
66 + "name": "Cégep à distance",
67 + "url": "https://cegepadistance.ca",
68 + "listing_url": "https://guidedechoixdecours.cegepadistance.ca/cours/",
69 + "type_offre": "Cours collégiaux à distance — unités, préalables, tous régimes",
70 + "connector": "cegep_a_distance",
71 + "status": "actif",
72 + "region": "Québec (en ligne)"
73 + },
74 + {
75 + "id": "technologia",
76 + "name": "Technologia",
77 + "url": "https://www.technologia.com",
78 + "listing_url": "https://www.technologia.com/formations",
79 + "type_offre": "Formation continue professionnelle — TI, IA, gestion de projets, leadership",
80 + "connector": "technologia",
81 + "status": "actif",
82 + "region": "Montréal / Québec"
83 + },
84 + {
85 + "id": "afi",
86 + "name": "AFI par Edgenda",
87 + "url": "https://www.afiexpertise.com",
88 + "listing_url": "https://www.afiexpertise.com/fr/formations-professionnelles",
89 + "type_offre": "Formation continue professionnelle — TI, bureautique, leadership, IA",
90 + "connector": "afi",
91 + "status": "actif",
92 + "region": "Québec / Montréal"
93 + },
94 + {
95 + "id": "versalys",
96 + "name": "Versalys",
97 + "url": "https://www.versalys.com",
98 + "listing_url": "https://www.versalys.com/formations/",
99 + "type_offre": "Formation continue — bureautique, TI, langues, développement professionnel",
100 + "connector": "versalys",
101 + "status": "actif",
102 + "region": "Montréal · Québec · Laval · Brossard"
103 + },
104 + {
105 + "id": "isarta",
106 + "name": "Isarta Formations",
107 + "url": "https://formations.isarta.com",
108 + "listing_url": "https://formations.isarta.com/",
109 + "type_offre": "Formation continue — marketing, communication, médias sociaux, IA, RH",
110 + "connector": "isarta",
111 + "status": "actif",
112 + "region": "Montréal (virtuel)"
113 + },
114 + {
115 + "id": "eeq",
116 + "name": "École des entrepreneurs du Québec",
117 + "url": "https://eequebec.com",
118 + "listing_url": "https://eequebec.com/formations/",
119 + "type_offre": "Formations pour entrepreneurs — majoritairement gratuites/subventionnées",
120 + "connector": "eeq",
121 + "status": "actif",
122 + "region": "Montréal + en ligne"
123 + },
124 + {
125 + "id": "ithq",
126 + "name": "ITHQ — Ateliers et formations",
127 + "url": "https://www.ithq.qc.ca",
128 + "listing_url": "https://www.ithq.qc.ca/ateliers-et-formation-continue/",
129 + "type_offre": "Ateliers grand public (vins, cuisine) + formation continue tourisme/hôtellerie/restauration",
130 + "connector": "ithq",
131 + "status": "actif",
132 + "region": "Montréal (+ province)"
133 + },
134 + {
135 + "id": "alphanumerique",
136 + "name": "AlphaNumérique",
137 + "url": "https://alphanumerique.ca",
138 + "listing_url": "https://alphanumerique.ca/espace-public/cours-autonomes/",
139 + "type_offre": "Cours autonomes de littératie numérique — 100 % gratuits",
140 + "connector": "alphanumerique",
141 + "status": "actif",
142 + "region": "Québec (en ligne)"
143 + },
144 + {
145 + "id": "lesaffaires",
146 + "name": "Événements Les Affaires",
147 + "url": "https://evenements.lesaffaires.com",
148 + "listing_url": "https://evenements.lesaffaires.com/collections/all",
149 + "type_offre": "Conférences, formations et webinaires d'affaires — dates précises",
150 + "connector": "lesaffaires",
151 + "status": "actif",
152 + "region": "Montréal / Québec / en ligne"
153 + },
154 + {
155 + "id": "crha",
156 + "name": "CRHA — Espace Formation",
157 + "url": "https://ordrecrha.org",
158 + "listing_url": "https://carrefourrh.org/formation",
159 + "type_offre": "Formations et événements en ressources humaines (heures de formation continue CRHA)",
160 + "connector": "crha",
161 + "status": "actif",
162 + "region": "Québec (province) — surtout en ligne"
163 + },
164 + {
165 + "id": "institut_leadership",
166 + "name": "Institut de leadership",
167 + "url": "https://www.institutleadership.ca",
168 + "listing_url": "https://www.institutleadership.ca/montreal/",
169 + "type_offre": "Certifications, programmes et formations signatures en leadership",
170 + "connector": "institut_leadership",
171 + "status": "actif",
172 + "region": "Montréal (+ cohortes en ligne)"
173 + },
174 + {
175 + "id": "lewagon_mtl",
176 + "name": "Le Wagon Montréal",
177 + "url": "https://www.lewagon.com/fr/montreal",
178 + "listing_url": "https://www.lewagon.com/fr/montreal",
179 + "type_offre": "Bootcamps développement web, IA, science des données (temps plein/partiel)",
180 + "connector": "lewagon_mtl",
181 + "status": "actif",
182 + "region": "Montréal"
183 + }
184 + ]
185 +}
added docs/formation.png +0 −0

Binary file not shown.

added docs/home.png +0 −0

Binary file not shown.

added docs/stats.png +0 −0

Binary file not shown.

added formaka/__init__.py +8 −0
@@ -0,0 +1,8 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# -----------------------------------------------------------------------------
5 +"""Forma-Ka : toutes les formations du Québec — cours en ligne, séminaires,
6 +cours universitaires et collégiaux, ateliers, certifications — un seul endroit."""
7 +
8 +__version__ = "1.0.0"
added formaka/connectors/__init__.py +30 −0
@@ -0,0 +1,30 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/__init__.py : registre AUTO-DÉCOUVRANT des connecteurs
5 +# Tout module de ce paquet contenant une sous-classe de BaseConnector avec un
6 +# source_id non vide est enregistré automatiquement — aucun fichier partagé
7 +# à modifier pour ajouter un connecteur.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import importlib
12 +import pkgutil
13 +import sys
14 +
15 +from .base import BaseConnector
16 +
17 +CONNECTORS: dict[str, type[BaseConnector]] = {}
18 +
19 +for _mod in pkgutil.iter_modules(__path__):
20 + if _mod.name in ("base", "__init__"):
21 + continue
22 + try:
23 + module = importlib.import_module(f"{__name__}.{_mod.name}")
24 + except Exception as exc: # un connecteur cassé ne bloque pas les autres
25 + print(f"[forma-ka] connecteur '{_mod.name}' ignoré : {exc}", file=sys.stderr)
26 + continue
27 + for obj in vars(module).values():
28 + if (isinstance(obj, type) and issubclass(obj, BaseConnector)
29 + and obj is not BaseConnector and getattr(obj, "source_id", "")):
30 + CONNECTORS[obj.source_id] = obj
added formaka/connectors/afi.py +203 −0
@@ -0,0 +1,203 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/afi.py : connecteur AFI par Edgenda (afiexpertise.com)
5 +# Firme de formation TI, bureautique et leadership (Québec, Montréal,
6 +# classe virtuelle) — ~470 formations au calendrier public.
7 +# Site Gatsby : chaque fiche possède un JSON statique complet à
8 +# /page-data/fr/formation/<slug>/page-data.json — nom, description HTML,
9 +# plan de cours en tableau (public concerné, certification, prérequis,
10 +# objectifs, méthode pédagogique, contenu par modules), sessions datées
11 +# avec villes et horaires, prix PAR JOUR (le prix affiché sur le site est
12 +# RegularPrice × nb de jours, « Sur demande » quand aucune session),
13 +# thématique, éditeur, formateurs. Liste découverte via sitemap-0.xml.
14 +# Fiches en cache, rafraîchies chaque semaine (clé « AAAA-WSS »).
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import datetime
19 +import re
20 +
21 +from bs4 import BeautifulSoup
22 +
23 +from ..schema import Formation, clean_text
24 +from .base import BaseConnector
25 +
26 +BASE = "https://www.afiexpertise.com"
27 +SITEMAP_URL = f"{BASE}/sitemap-0.xml"
28 +
29 +_COURSE_LOC_RE = re.compile(
30 + r"<loc>https://www\.afiexpertise\.com(/fr/formation/([^<]+))</loc>")
31 +
32 +
33 +def _cell_text(td) -> str:
34 + return clean_text(td.get_text(" ")) if td is not None else ""
35 +
36 +
37 +def _cell_items(td) -> list[str]:
38 + items = [clean_text(li.get_text(" ")) for li in td.find_all("li")]
39 + return [i for i in dict.fromkeys(items) if i]
40 +
41 +
42 +class AfiConnector(BaseConnector):
43 + source_id = "afi"
44 + request_delay = 0.5
45 + limit: int | None = None # borne optionnelle (tests/débogage)
46 +
47 + def fetch(self) -> list[Formation]:
48 + # 1) Liste des fiches françaises depuis le sitemap
49 + xml = self.get(SITEMAP_URL).text
50 + slugs = list(dict.fromkeys(m.group(2).strip("/")
51 + for m in _COURSE_LOC_RE.finditer(xml)))
52 + if self.limit:
53 + slugs = slugs[: self.limit]
54 +
55 + # 2) JSON statique Gatsby par formation — cache hebdomadaire
56 + week = datetime.date.today().strftime("%G-W%V")
57 + out: list[Formation] = []
58 + for slug in slugs:
59 + payload = self.detail(slug, week,
60 + lambda s=slug: self._fetch_detail(s))
61 + if not payload:
62 + continue
63 + f = Formation(
64 + source=self.source_id,
65 + external_id=slug,
66 + url=f"{BASE}/fr/formation/{slug}",
67 + training_type="Formation continue",
68 + language="fr",
69 + )
70 + for k, v in payload.items():
71 + if hasattr(f, k) and v not in (None, "", []):
72 + setattr(f, k, v)
73 + out.append(f)
74 + return out
75 +
76 + # -- fiche ----------------------------------------------------------------
77 + def _fetch_detail(self, slug: str) -> dict:
78 + url = f"{BASE}/page-data/fr/formation/{slug}/page-data.json"
79 + try:
80 + data = self.get(url).json()
81 + except Exception:
82 + return {}
83 + result = (data.get("result") or {})
84 + course = ((result.get("data") or {}).get("course") or {})
85 + if not course:
86 + return {}
87 + payload: dict = {}
88 + details: dict = {}
89 +
90 + payload["title"] = clean_text(
91 + (course.get("Name") or {}).get("fr") or "")
92 + desc = BeautifulSoup(
93 + (course.get("Description") or {}).get("fr") or "", "html.parser")
94 + paragraphs = [clean_text(p.get_text(" "))
95 + for p in desc.find_all(["p", "li"])]
96 + payload["description"] = "\n\n".join(
97 + dict.fromkeys(p for p in paragraphs if p)) or clean_text(
98 + desc.get_text(" "))
99 + if not payload["description"]:
100 + payload["description"] = clean_text(
101 + (course.get("MetaDescription") or {}).get("fr") or "")
102 +
103 + # plan de cours : tableau à deux colonnes (libellé -> contenu)
104 + plan = BeautifulSoup((course.get("Plan") or {}).get("fr") or "",
105 + "html.parser")
106 + program: list[str] = []
107 + for tr in plan.find_all("tr"):
108 + tds = tr.find_all("td")
109 + if len(tds) < 2:
110 + continue
111 + label = clean_text(tds[0].get_text(" ")).lower()
112 + cell = tds[1]
113 + if "public" in label:
114 + payload["audience"] = _cell_text(cell)
115 + elif "prérequis" in label or "prealable" in label:
116 + payload["prerequisites"] = _cell_text(cell)
117 + elif "objectif" in label:
118 + payload["objectives"] = _cell_items(cell) or \
119 + [t for t in (_cell_text(cell),) if t]
120 + elif "certification" in label:
121 + details["certification"] = _cell_text(cell)
122 + elif "méthode" in label:
123 + details["methode_pedagogique"] = _cell_text(cell)
124 + elif "contenu" in label:
125 + # titres de modules (<p>) quand ils structurent le contenu,
126 + # sinon puces (<li>)
127 + ptexts = [clean_text(p.get_text(" "))
128 + for p in cell.find_all("p")]
129 + ptexts = [p for p in dict.fromkeys(ptexts) if p]
130 + litexts = _cell_items(cell)
131 + program = ptexts if len(ptexts) >= 2 else litexts or ptexts
132 + if program:
133 + payload["program"] = program
134 +
135 + # durée en jours + prix affiché = prix/jour × jours (min. 1 jour)
136 + days = course.get("DurationInDays")
137 + if days:
138 + payload["duration"] = (f"{days:g} jour"
139 + if days <= 1 else f"{days:g} jours")
140 + payload["duration_hours"] = days * 7.0
141 + sessions_raw = course.get("Sessions") or []
142 + price = course.get("RegularPrice")
143 + if price is not None and sessions_raw:
144 + total = price * days if days and days >= 1 else price
145 + payload["price"] = float(total)
146 + payload["price_label"] = f"{total:g} $ + tx"
147 + pref = course.get("PreferentialPrice")
148 + if pref is not None:
149 + details["prix_preferentiel"] = (pref * days
150 + if days and days >= 1 else pref)
151 + elif not sessions_raw:
152 + payload["price_label"] = "Sur demande"
153 +
154 + # sessions datées (première journée de chaque cohorte) + villes
155 + sessions, cities, langs = [], [], []
156 + for s in sessions_raw:
157 + dates = s.get("Dates") or []
158 + if dates:
159 + start = str(dates[0])[:10]
160 + if re.match(r"20\d{2}-\d{2}-\d{2}", start):
161 + sessions.append(start)
162 + if s.get("City"):
163 + cities.append(s["City"])
164 + if s.get("Language"):
165 + langs.append(s["Language"])
166 + sessions = sorted(set(sessions))
167 + if sessions:
168 + payload["sessions"] = sessions
169 + payload["start_date"] = sessions[0]
170 + cities = list(dict.fromkeys(cities))
171 + modes = ["en ligne" if c.lower() == "classe virtuelle" else "présentiel"
172 + for c in cities]
173 + modes = list(dict.fromkeys(modes))
174 + if not modes and course.get("IsVirtual"):
175 + modes = ["en ligne"]
176 + if modes:
177 + payload["mode"] = modes[0] if len(modes) == 1 else "hybride"
178 + details["modes_offerts"] = modes
179 + real_cities = [c for c in cities if c.lower() != "classe virtuelle"]
180 + if real_cities:
181 + payload["city"] = real_cities[0]
182 + if langs:
183 + payload["language"] = "/".join(dict.fromkeys(langs))
184 +
185 + # contexte de page : thématique (catégorie) et éditeur (Microsoft…)
186 + ctx = result.get("pageContext") or {}
187 + theme = ((ctx.get("theme") or {}).get("name") or {}).get("fr", "")
188 + if theme:
189 + payload["category"] = theme
190 + editor = ((ctx.get("editor") or {}).get("name") or {}).get("fr", "")
191 + if editor:
192 + payload["tags"] = [editor]
193 +
194 + teachers = [clean_text(f"{t.get('FirstName', '')} {t.get('LastName', '')}")
195 + for t in course.get("Teachers") or []]
196 + teachers = [t for t in dict.fromkeys(teachers) if t]
197 + if teachers:
198 + payload["instructor"] = ", ".join(teachers)
199 + if course.get("IsNew"):
200 + details["nouveau"] = True
201 + if details:
202 + payload["details"] = details
203 + return payload
added formaka/connectors/alphanumerique.py +155 −0
@@ -0,0 +1,155 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/alphanumerique.py : connecteur AlphaNumérique (alphanumerique.ca)
5 +# Initiative de littératie numérique pilotée par des bibliothèques publiques
6 +# québécoises — cours autonomes en ligne 100 % GRATUITS pour le grand public
7 +# (bases de l'informatique, courriel, sécurité en ligne, fausses nouvelles,
8 +# tablettes, intelligence artificielle…).
9 +# Site WordPress rendu serveur :
10 +# - liste : /espace-public/cours-autonomes/ — cartes regroupées par
11 +# thématique (thème, niveau, titre, visuel)
12 +# - fiche : page Elementor avec description, liste « Ce cours vous
13 +# permettra de… » (objectifs) et vidéo du cours.
14 +# Fiches en cache, rafraîchies chaque semaine (clé ISO).
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import datetime
19 +import re
20 +from urllib.parse import urljoin
21 +
22 +from bs4 import BeautifulSoup
23 +
24 +from ..schema import Formation, clean_text
25 +from .base import BaseConnector
26 +
27 +BASE = "https://alphanumerique.ca"
28 +LIST_URL = f"{BASE}/espace-public/cours-autonomes/"
29 +
30 +_SLUG_RE = re.compile(r"/ressources/([^/]+)/?")
31 +# marqueurs de fin de la zone descriptive d'une fiche
32 +_STOP_RE = re.compile(r"cliquer sur la vid[ée]o|cliquez sur le lien ci-dessous|"
33 + r"ressources compl[ée]mentaires|[ée]valuer nos ressources",
34 + re.I)
35 +
36 +
37 +class AlphanumeriqueConnector(BaseConnector):
38 + source_id = "alphanumerique"
39 + request_delay = 0.5
40 +
41 + def fetch(self) -> list[Formation]:
42 + html = self.fetch_html(LIST_URL)
43 + cards = self._parse_listing(html)
44 +
45 + # fiche détaillée par cours — cache hebdomadaire
46 + week = datetime.date.today().strftime("%G-W%V")
47 + out: list[Formation] = []
48 + for slug, card in cards.items():
49 + key = f"{week}:{card['title']}:{card['level']}"
50 + payload = self.detail(slug, key,
51 + lambda c=card: self._fetch_detail(c))
52 + f = Formation(
53 + source=self.source_id,
54 + external_id=slug,
55 + url=card["url"],
56 + title=card["title"],
57 + training_type="Cours en ligne",
58 + category=card["theme"],
59 + mode="asynchrone",
60 + language="fr",
61 + price=0.0,
62 + price_label="Gratuit",
63 + is_free=True,
64 + level=card["level"].lower(),
65 + audience="Grand public",
66 + tags=["littératie numérique"],
67 + images=card["images"],
68 + )
69 + for k, v in (payload or {}).items():
70 + if hasattr(f, k) and v not in (None, "", []):
71 + setattr(f, k, v)
72 + out.append(f)
73 + return out
74 +
75 + # -- liste ------------------------------------------------------------------
76 + def _parse_listing(self, html: str) -> dict[str, dict]:
77 + """Cartes de cours autonomes, indexées par slug (dédupliquées)."""
78 + soup = BeautifulSoup(html, "html.parser")
79 + cards: dict[str, dict] = {}
80 + for box in soup.find_all("div", class_="ressources-container"):
81 + a = box.find("a", href=_SLUG_RE)
82 + if a is None:
83 + continue
84 + url = urljoin(BASE, a["href"])
85 + slug = _SLUG_RE.search(url).group(1)
86 + if slug in cards:
87 + continue
88 + terms = [clean_text(t.get_text(" "))
89 + for t in box.find_all("div", class_="term")]
90 + title = box.find("div", class_="title")
91 + img = box.find("img")
92 + src = (img.get("data-lazy-src") or img.get("src") or "") if img else ""
93 + cards[slug] = {
94 + "url": url,
95 + "title": clean_text(title.get_text(" ")) if title else slug,
96 + "theme": terms[0] if terms else "",
97 + "level": terms[1] if len(terms) > 1 else "",
98 + "images": [src] if src.startswith("http") else [],
99 + }
100 + return cards
101 +
102 + # -- fiche --------------------------------------------------------------------
103 + def _fetch_detail(self, card: dict) -> dict:
104 + if not card.get("url"):
105 + return {}
106 + html = self.fetch_html(card["url"])
107 + soup = BeautifulSoup(html, "html.parser")
108 + main = (soup.find(class_="elementor-location-single")
109 + or soup.find("main") or soup.body)
110 + if main is None:
111 + return {}
112 + payload: dict = {}
113 +
114 + # textes déjà connus (titre, thème, niveau) à exclure de la description
115 + title = main.find(class_="elementor-heading-title")
116 + skip = {clean_text(title.get_text(" ")) if title else "",
117 + card.get("title", ""), card.get("theme", ""),
118 + card.get("level", "")}
119 +
120 + desc_parts: list[str] = []
121 + objectives: list[str] = []
122 + program: list[str] = []
123 + for el in main.find_all(["p", "ul"]):
124 + text = clean_text(el.get_text(" "))
125 + if not text or text in skip:
126 + continue
127 + if _STOP_RE.search(text):
128 + break
129 + if el.name == "ul":
130 + items = [t for t in (clean_text(li.get_text(" "))
131 + for li in el.find_all("li"))
132 + if t and t not in skip]
133 + if not items:
134 + continue
135 + lead = desc_parts[-1] if desc_parts else ""
136 + # « Ce cours vous permettra de… » = objectifs
137 + if not objectives and re.search(
138 + r"permettra|apprendrez|d[ée]couvrirez", lead, re.I):
139 + objectives = items
140 + desc_parts.pop() # phrase d'amorce retirée
141 + # « …les grandes questions qui seront abordées » = plan du cours
142 + elif not program and re.search(r"abord[ée]e?s?\b", lead, re.I):
143 + program = items
144 + else:
145 + desc_parts += items
146 + else:
147 + desc_parts.append(text)
148 +
149 + if desc_parts:
150 + payload["description"] = "\n\n".join(desc_parts)
151 + if objectives:
152 + payload["objectives"] = objectives
153 + if program:
154 + payload["program"] = program
155 + return payload
added formaka/connectors/base.py +203 −0
@@ -0,0 +1,203 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/base.py : classe de base des connecteurs + backends de fetch
5 +# 1. requests direct (sites rendus serveur)
6 +# 2. Scrapfly (asp=anti-bot bypass, render_js) — backend robuste privilégié
7 +# 3. Firecrawl (rendu JavaScript) — solution de repli / sites SPA
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import json
12 +import os
13 +import re
14 +import time
15 +from urllib.parse import urlencode
16 +
17 +import requests
18 +
19 +from ..schema import Formation
20 +
21 +_LDJSON_RE = re.compile(
22 + r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S | re.I)
23 +
24 +
25 +def ldjson_objects(html: str) -> list[dict]:
26 + """Extrait tous les objets JSON-LD d'une page (schema.org Course, etc.).
27 +
28 + Tolère les blocs contenant plusieurs objets concaténés (sans virgule),
29 + fréquents sur les sites ASP.NET.
30 + """
31 + dec = json.JSONDecoder()
32 + out: list[dict] = []
33 + for m in _LDJSON_RE.finditer(html):
34 + blob = m.group(1).strip()
35 + i = 0
36 + while i < len(blob):
37 + try:
38 + obj, j = dec.raw_decode(blob, i)
39 + if isinstance(obj, dict):
40 + if "@graph" in obj:
41 + out.extend(g for g in obj["@graph"] if isinstance(g, dict))
42 + else:
43 + out.append(obj)
44 + elif isinstance(obj, list):
45 + out.extend(o for o in obj if isinstance(o, dict))
46 + i = j
47 + while i < len(blob) and blob[i] in " \r\n\t,":
48 + i += 1
49 + except ValueError:
50 + i += 1
51 + return out
52 +
53 +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
54 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
55 + "FormaKaBot/1.0 (+https://www.forma-ka.com/bot; contact@spboucher.ai)")
56 +
57 +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
58 +SCRAPFLY_API = "https://api.scrapfly.io/scrape"
59 +
60 +
61 +class BaseConnector:
62 + """Un connecteur = un adaptateur propre à un site de formation.
63 +
64 + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne
65 + la liste complète des formations actuellement affichées sur le site.
66 + Le pipeline (ingest.py) s'occupe du diff avec la base de données.
67 + """
68 +
69 + source_id: str = ""
70 + request_delay: float = 0.6 # politesse entre requêtes
71 + timeout: int = 30
72 + use_detail_cache: bool = True # cache BD des pages détail
73 +
74 + def __init__(self) -> None:
75 + self.session = requests.Session()
76 + self.session.headers["User-Agent"] = USER_AGENT
77 + self._last_request = 0.0
78 + self._detail_con = None
79 +
80 + # -- backends -------------------------------------------------------------
81 + def get(self, url: str, **kw) -> requests.Response:
82 + """GET direct avec throttling poli."""
83 + wait = self.request_delay - (time.time() - self._last_request)
84 + if wait > 0:
85 + time.sleep(wait)
86 + resp = self.session.get(url, timeout=self.timeout, **kw)
87 + self._last_request = time.time()
88 + resp.raise_for_status()
89 + return resp
90 +
91 + def post(self, url: str, **kw) -> requests.Response:
92 + """POST direct avec throttling poli (APIs JSON internes)."""
93 + wait = self.request_delay - (time.time() - self._last_request)
94 + if wait > 0:
95 + time.sleep(wait)
96 + resp = self.session.post(url, timeout=self.timeout, **kw)
97 + self._last_request = time.time()
98 + resp.raise_for_status()
99 + return resp
100 +
101 + def get_scrapfly(self, url: str, render_js: bool = False,
102 + asp: bool = True, country: str = "ca",
103 + wait_for: str | None = None) -> str:
104 + """Récupère le HTML via Scrapfly — backend robuste (anti-bot bypass).
105 +
106 + Nécessite SCRAPFLY_API_KEY dans l'environnement (.env).
107 + `asp=True` active l'Anti Scraping Protection bypass (Cloudflare…),
108 + `render_js=True` exécute le JavaScript (navigateur headless),
109 + `wait_for` attend un sélecteur CSS avant de capturer le HTML.
110 + """
111 + key = os.environ.get("SCRAPFLY_API_KEY")
112 + if not key:
113 + raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")
114 + params: dict = {"key": key, "url": url, "country": country}
115 + if asp:
116 + params["asp"] = "true"
117 + if render_js:
118 + params["render_js"] = "true"
119 + if wait_for:
120 + params["render_js"] = "true"
121 + params["wait_for_selector"] = wait_for
122 + resp = requests.get(f"{SCRAPFLY_API}?{urlencode(params)}", timeout=120)
123 + resp.raise_for_status()
124 + data = resp.json()
125 + result = data.get("result") or {}
126 + status = result.get("status_code")
127 + if status and int(status) >= 400:
128 + raise RuntimeError(f"Scrapfly: la cible a répondu {status} pour {url}")
129 + return result.get("content", "")
130 +
131 + def get_firecrawl(self, url: str, formats: list[str] | None = None) -> dict:
132 + """Récupère la page via Firecrawl (rendu JavaScript).
133 +
134 + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).
135 + Retourne le dict `data` de Firecrawl ({"html": …, "markdown": …}).
136 + """
137 + key = os.environ.get("FIRECRAWL_API_KEY")
138 + if not key:
139 + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
140 + resp = requests.post(
141 + FIRECRAWL_API,
142 + json={"url": url, "formats": formats or ["html"]},
143 + headers={"Authorization": f"Bearer {key}"},
144 + timeout=90,
145 + )
146 + resp.raise_for_status()
147 + return resp.json().get("data") or {}
148 +
149 + def get_rendered(self, url: str) -> str:
150 + """HTML rendu (JavaScript exécuté) — Scrapfly d'abord (plus robuste),
151 + Firecrawl en repli. À utiliser pour les sites SPA / derrière Cloudflare.
152 + """
153 + try:
154 + html = self.get_scrapfly(url, render_js=True)
155 + if html:
156 + return html
157 + except Exception:
158 + pass
159 + return self.get_firecrawl(url).get("html", "")
160 +
161 + def fetch_html(self, url: str) -> str:
162 + """HTML d'une page « normale » avec repli automatique :
163 + requests direct -> Scrapfly (asp) -> Firecrawl.
164 + """
165 + try:
166 + resp = self.get(url)
167 + if resp.status_code == 200 and len(resp.text) > 500:
168 + return resp.text
169 + except Exception:
170 + pass
171 + try:
172 + html = self.get_scrapfly(url)
173 + if html:
174 + return html
175 + except Exception:
176 + pass
177 + return self.get_firecrawl(url).get("html", "")
178 +
179 + def detail(self, external_id: str, key: str, fetch_fn) -> dict:
180 + """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si
181 + la formation est nouvelle ou si sa clé (hash du contenu liste) a changé.
182 +
183 + Permet d'extraire les champs riches (description, objectifs, plan de
184 + cours, préalables…) sans revisiter chaque page à chaque synchronisation.
185 + `fetch_fn` doit retourner un dict JSON-sérialisable.
186 + """
187 + if not self.use_detail_cache:
188 + return fetch_fn() or {}
189 + from .. import db
190 + if self._detail_con is None:
191 + self._detail_con = db.connect()
192 + cached = db.get_cached_detail(self._detail_con, self.source_id,
193 + str(external_id), key)
194 + if cached is not None:
195 + return cached
196 + payload = fetch_fn() or {}
197 + db.put_cached_detail(self._detail_con, self.source_id,
198 + str(external_id), key, payload)
199 + return payload
200 +
201 + # -- contrat --------------------------------------------------------------
202 + def fetch(self) -> list[Formation]:
203 + raise NotImplementedError
added formaka/connectors/cegep_a_distance.py +224 −0
@@ -0,0 +1,224 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/cegep_a_distance.py : connecteur Cégep à distance
5 +# Guide de choix de cours du Cégep à distance (cegepadistance.ca) — ~150
6 +# cours collégiaux offerts à distance (formation générale et spécifique).
7 +# Site WordPress rendu serveur (guidedechoixdecours.cegepadistance.ca) :
8 +# - liste : /cours/ — TOUTES les cartes sur une seule page (préalables,
9 +# heures/pondération, unités, compétences, option), regroupées par sigle
10 +# (un même cours existe en plusieurs « options » : Internet, papier…)
11 +# - fiche : sections titrées (Description, Infos avec compétences complètes,
12 +# Option, Particularités et matériel, Droits de scolarité et prix du
13 +# matériel). Fiches en cache, rafraîchies chaque semaine (clé ISO).
14 +# Prix affiché = droits de scolarité + matériel (cours régulier).
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import datetime
19 +import re
20 +
21 +from bs4 import BeautifulSoup
22 +
23 +from ..schema import Formation, clean_text, parse_price
24 +from .base import BaseConnector
25 +
26 +BASE = "https://guidedechoixdecours.cegepadistance.ca"
27 +LIST_URL = f"{BASE}/cours/"
28 +
29 +# libellés des régimes de frais -> clés du dict details["frais"]
30 +_FEE_LABELS = (("Droits de scolarité", "droits_scolarite"),
31 + ("Matériel", "materiel"),
32 + ("Total", "total"))
33 +
34 +
35 +def _card_field(card, label_rx: str) -> str:
36 + """Valeur d'un champ « <strong>Label :</strong> valeur » d'une carte."""
37 + strong = card.find("strong", string=re.compile(label_rx, re.I))
38 + if strong is None:
39 + return ""
40 + parent = strong.parent
41 + txt = clean_text(parent.get_text(" "))
42 + return clean_text(re.sub(r"^[^:]*:?", "", txt, count=1))
43 +
44 +
45 +def _section_content(soup: BeautifulSoup, heading_rx: str):
46 + """Bloc « content » qui suit un titre de section (h2) de la fiche."""
47 + h = soup.find(lambda t: t.name == "h2"
48 + and re.search(heading_rx, t.get_text(" ", strip=True), re.I))
49 + if h is None:
50 + return None
51 + return h.find_next(lambda t: t.name == "div"
52 + and "content" in (t.get("class") or []))
53 +
54 +
55 +class CegepADistanceConnector(BaseConnector):
56 + source_id = "cegep_a_distance"
57 + request_delay = 0.5
58 +
59 + def fetch(self) -> list[Formation]:
60 + html = self.fetch_html(LIST_URL)
61 + courses = self._parse_listing(html)
62 +
63 + # fiche détaillée par sigle — cache hebdomadaire
64 + week = datetime.date.today().strftime("%G-W%V")
65 + out: list[Formation] = []
66 + for sigle, info in courses.items():
67 + key = f"{week}:{'|'.join(info['options'])}:{info['hours']}"
68 + payload = self.detail(sigle, key,
69 + lambda u=info["url"]: self._fetch_detail(u))
70 + # cours listés à titre indicatif mais suivis dans un autre cégep
71 + if re.search(r"n[’']est pas offert au C[ée]gep à distance",
72 + payload.get("description", "")):
73 + continue
74 + f = Formation(
75 + source=self.source_id,
76 + external_id=sigle,
77 + url=info["url"],
78 + title=info["title"],
79 + training_type="Cours collégial",
80 + category=info["category"],
81 + mode="en ligne",
82 + language="fr",
83 + code=sigle,
84 + duration=info["hours"],
85 + credits=info["credits"],
86 + prerequisites=info["prerequisites"],
87 + details={k: v for k, v in (("options", info["options"]),
88 + ("ponderation", info["ponderation"]),
89 + ("competences", info["competences"]))
90 + if v},
91 + )
92 + for k, v in (payload or {}).items():
93 + if k == "details":
94 + f.details = {**f.details, **v}
95 + elif hasattr(f, k) and v not in (None, "", []):
96 + setattr(f, k, v)
97 + out.append(f)
98 + return out
99 +
100 + # -- liste ------------------------------------------------------------------
101 + def _parse_listing(self, html: str) -> dict[str, dict]:
102 + """Cartes de la liste regroupées par sigle (fusion des options)."""
103 + soup = BeautifulSoup(html, "html.parser")
104 + courses: dict[str, dict] = {}
105 + for a in soup.select("div.course-details a.gutter[href]"):
106 + name = a.find("span", class_="name")
107 + code = a.find("span", class_="code")
108 + if name is None or code is None:
109 + continue
110 + sigle = clean_text(code.get_text())
111 + prereq = _card_field(a, r"Pr[ée]alables")
112 + hp = _card_field(a, r"Heures\s*/\s*Pond[ée]ration")
113 + hours, _, ponderation = (p.strip() for p in hp.partition("/"))
114 + unites = _card_field(a, r"Unit[ée]s")
115 + option = _card_field(a, r"Option")
116 + comp = _card_field(a, r"Comp[ée]tences")
117 + info = courses.setdefault(sigle, {
118 + "url": a["href"].rstrip("/") + "/",
119 + "title": clean_text(name.get_text()),
120 + "category": self._discipline(sigle),
121 + "hours": hours,
122 + "ponderation": ponderation,
123 + "credits": (f"{unites} unités" if unites not in ("", "1")
124 + else "1 unité") if unites else "",
125 + "prerequisites": "" if re.match(r"aucun", prereq, re.I) else prereq,
126 + "competences": comp.split() if comp else [],
127 + "options": [],
128 + })
129 + if option and option not in info["options"]:
130 + info["options"].append(option)
131 + return courses
132 +
133 + @staticmethod
134 + def _discipline(sigle: str) -> str:
135 + """Grande famille de cours d'après le préfixe du sigle ministériel."""
136 + prefix = sigle.split("-", 1)[0]
137 + return {
138 + "109": "Éducation physique", "201": "Mathématiques",
139 + "202": "Chimie", "203": "Physique", "101": "Biologie",
140 + "320": "Géographie", "330": "Histoire", "340": "Philosophie",
141 + "350": "Psychologie", "360": "Sciences humaines — méthodologie",
142 + "383": "Économie", "385": "Science politique", "387": "Sociologie",
143 + "401": "Administration", "410": "Techniques administratives",
144 + "412": "Bureautique", "420": "Informatique",
145 + "504": "Arts", "601": "Français, langue et littérature",
146 + "602": "Français, langue seconde", "603": "Anglais, langue et littérature",
147 + "604": "Anglais, langue seconde", "607": "Espagnol",
148 + "861": "Renforcement", "961": "Cheminement",
149 + }.get(prefix, "")
150 +
151 + # -- fiche --------------------------------------------------------------------
152 + def _fetch_detail(self, url: str) -> dict:
153 + if not url:
154 + return {}
155 + html = self.fetch_html(url)
156 + soup = BeautifulSoup(html, "html.parser")
157 + payload: dict = {}
158 + details: dict = {}
159 +
160 + # description complète
161 + div = _section_content(soup, r"^Description")
162 + if div is not None:
163 + payload["description"] = clean_text(div.get_text(" "))
164 +
165 + # compétences ministérielles complètes (section Infos) -> objectifs
166 + div = _section_content(soup, r"^Infos")
167 + if div is not None:
168 + b = div.find("b", string=re.compile(r"Comp[ée]tences", re.I))
169 + if b is not None:
170 + comp = clean_text(b.parent.get_text(" "))
171 + comp = re.sub(r"^Comp[ée]tences\s*", "", comp)
172 + objectives = [c.strip() for c in re.split(
173 + r"(?=\b\d?[0-9A-Z]{3,4}\s*:)", comp) if c.strip()]
174 + if objectives:
175 + payload["objectives"] = objectives
176 +
177 + # option de diffusion (« 60 - Cours plurimédia, devoirs par Internet… »)
178 + div = _section_content(soup, r"^Option")
179 + if div is not None:
180 + label = clean_text(div.get_text(" "))
181 + if label:
182 + details["option_label"] = label
183 + if re.search(r"anglais", label, re.I):
184 + payload["language"] = "en"
185 +
186 + # particularités et matériel obligatoire
187 + div = _section_content(soup, r"Particularit[ée]s")
188 + if div is not None:
189 + txt = clean_text(div.get_text(" "))
190 + if txt:
191 + details["particularites"] = txt
192 +
193 + # droits de scolarité et prix du matériel (par régime d'études)
194 + div = _section_content(soup, r"Droits de scolarit[ée]")
195 + if div is not None:
196 + frais: dict[str, dict] = {}
197 + for p in div.find_all("p"):
198 + b = p.find("b")
199 + if b is None:
200 + continue
201 + regime = clean_text(b.get_text())
202 + txt = p.get_text(" ", strip=True)
203 + entry: dict = {}
204 + for label, key in _FEE_LABELS:
205 + m = re.search(label + r"\s*:\s*([\d\s  ,.]+\$)", txt)
206 + if m:
207 + val = parse_price(m.group(1))
208 + if val is not None:
209 + entry[key] = val
210 + if entry:
211 + frais[regime] = entry
212 + if frais:
213 + details["frais"] = frais
214 + regulier = frais.get("Cours régulier") or next(iter(frais.values()))
215 + total = regulier.get("total")
216 + if total is not None:
217 + payload["price"] = total
218 + payload["price_label"] = (f"{total:.2f} $ "
219 + "(droits + matériel, cours régulier)"
220 + ).replace(".", ",")
221 +
222 + if details:
223 + payload["details"] = details
224 + return payload
added formaka/connectors/crha.py +124 −0
@@ -0,0 +1,124 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/crha.py : connecteur CRHA — Ordre des conseillers en ressources
5 +# humaines agréés (ordrecrha.org) — développement professionnel / Espace
6 +# Formation (~175 formations et événements RH).
7 +# Le catalogue public est servi par carrefourrh.org (portail de contenu de
8 +# l'Ordre) via une API JSON interne propre :
9 +# - /Formation/GetLaraFormations : toutes les formations (nom, description
10 +# courte, mots-clés, heures de formation continue par compétence, format,
11 +# prochaine date, lien vers la fiche Espace Formation, code d'événement)
12 +# - /Formation/GetFormats et /Formation/GetCategories : référentiels
13 +# (id chiffré -> libellé) des formats et des domaines de compétence.
14 +# Les fiches détail (formation.ordrecrha.org, LMS « Lära ») sont une SPA
15 +# fermée qui ne rend rien de plus publiquement — la liste JSON suffit.
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import html as _html
20 +
21 +from ..schema import Formation, clean_text
22 +from .base import BaseConnector
23 +
24 +
25 +def _txt(raw: str | None) -> str:
26 + """Nettoie un champ texte de l'API (entités HTML « &nbsp; » comprises)."""
27 + return clean_text(_html.unescape(raw or ""))
28 +
29 +BASE = "https://carrefourrh.org"
30 +LIST_URL = f"{BASE}/Formation/GetLaraFormations"
31 +FORMATS_URL = f"{BASE}/Formation/GetFormats"
32 +CATEGORIES_URL = f"{BASE}/Formation/GetCategories"
33 +
34 +# libellé de format Lära -> (type de formation, mode) canoniques Forma-Ka
35 +_FORMAT_MAP = {
36 + "événements": ("Conférence", ""),
37 + "formation en classe virtuelle": ("Formation continue", "en ligne"),
38 + "formation en ligne": ("Cours en ligne", "asynchrone"),
39 + "formation en ligne - gratuite": ("Cours en ligne", "asynchrone"),
40 + "formation en salle": ("Formation continue", "présentiel"),
41 + "groupe de discussion ou de codéveloppement": ("Atelier", ""),
42 +}
43 +
44 +
45 +class CrhaConnector(BaseConnector):
46 + source_id = "crha"
47 + request_delay = 0.5
48 +
49 + def fetch(self) -> list[Formation]:
50 + # 1) référentiels : formats et domaines de compétence (id -> nom)
51 + formats = {o["Id"]: o["Name"] for o in self.get(FORMATS_URL).json()}
52 + categories = {o["Id"]: o["Name"] for o in self.get(CATEGORIES_URL).json()}
53 +
54 + # 2) catalogue complet
55 + rows = self.get(LIST_URL).json()
56 +
57 + out: list[Formation] = []
58 + for row in rows:
59 + if not isinstance(row, dict) or not row.get("Name"):
60 + continue
61 + code = (row.get("CustomFields") or {}).get("CodeEvent", "")
62 + external_id = code or row.get("Id", "")
63 + url = row.get("CatalogLink") or row.get("ExternalLink") or ""
64 + if not external_id or not url:
65 + continue
66 +
67 + f = Formation(
68 + source=self.source_id,
69 + external_id=str(external_id),
70 + url=url,
71 + title=_txt(row["Name"]),
72 + language="fr",
73 + code=code,
74 + )
75 +
76 + # format -> type + mode ; les « en ligne » autoportants sont
77 + # asynchrones, la classe virtuelle est synchrone à distance
78 + fmt_name = formats.get(row.get("Type", ""), "")
79 + ttype, mode = _FORMAT_MAP.get(fmt_name.lower(),
80 + ("Formation continue", ""))
81 + f.training_type = ttype
82 + f.mode = mode
83 + if fmt_name:
84 + f.details["format"] = fmt_name
85 + if fmt_name.lower() == "formation en ligne - gratuite":
86 + f.price = 0.0
87 + f.is_free = True
88 + f.price_label = "Gratuit"
89 +
90 + # description : courte (la fiche LMS n'expose rien de plus)
91 + f.description = _txt(row.get("Description", "")) \
92 + or _txt(row.get("ShortDescription", ""))
93 +
94 + # heures de formation continue par domaine de compétence
95 + hours = 0.0
96 + names = []
97 + for cat in row.get("Categories") or []:
98 + name = categories.get(cat.get("Id", ""), "")
99 + if name:
100 + names.append(name)
101 + hours += float(cat.get("Credits") or 0)
102 + if names:
103 + f.category = names[0]
104 + f.tags.extend(names)
105 + if hours > 0:
106 + f.duration_hours = hours
107 + f.duration = f"{hours:g} h"
108 + f.credits = (f"{hours:g} h de formation continue"
109 + .replace(".", ","))
110 + f.credential = "Heures de formation continue CRHA"
111 +
112 + # prochaine occurrence : « 12 AOÛT 2026 » (parse via finalize)
113 + occ = _txt(row.get("OccurenceDate", ""))
114 + if occ:
115 + f.schedule_label = occ
116 +
117 + # mots-clés éditoriaux
118 + keywords = [_txt(k) for k in
119 + (row.get("Keywords") or "").split(",")]
120 + f.tags.extend(k for k in keywords if k)
121 + f.tags.append("Ressources humaines")
122 +
123 + out.append(f)
124 + return out
added formaka/connectors/eeq.py +193 −0
@@ -0,0 +1,193 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/eeq.py : connecteur École des entrepreneurs du Québec (eequebec.com)
5 +# ~50 formations pour entrepreneurs (démarrage, croissance, finances,
6 +# fiscalité…), majoritairement GRATUITES ou subventionnées.
7 +# Site WordPress rendu serveur :
8 +# - liste : /formations/?trainings_page=N (pagination) — cartes avec titre,
9 +# image, nombre de modules, durée (« 2 heures », « 11 minutes »), prix
10 +# (« Gratuit » ou montant) et type de parcours (activité / programme)
11 +# - fiche : intro, accordéon « Description » (avec liste d'objectifs),
12 +# boîte de métadonnées (Type, Coaching, Phase de la croissance, Prix,
13 +# clientèle) et menu déroulant des prochaines dates.
14 +# Fiches en cache, rafraîchies chaque semaine (clé ISO).
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import datetime
19 +import re
20 +from urllib.parse import urljoin
21 +
22 +from bs4 import BeautifulSoup
23 +
24 +from ..schema import Formation, clean_text, parse_date_fr
25 +from .base import BaseConnector
26 +
27 +BASE = "https://eequebec.com"
28 +LIST_URL = f"{BASE}/formations/?trainings_page={{page}}"
29 +MAX_PAGES = 30 # garde-fou (6 pages à l'écriture)
30 +
31 +_SLUG_RE = re.compile(r"/formation/([^/]+)/?")
32 +
33 +
34 +def _txt(node, cls: str) -> str:
35 + el = node.find(class_=cls) if node else None
36 + return clean_text(el.get_text(" ")) if el else ""
37 +
38 +
39 +class EeqConnector(BaseConnector):
40 + source_id = "eeq"
41 + request_delay = 0.5
42 +
43 + def fetch(self) -> list[Formation]:
44 + cards = self._fetch_cards()
45 +
46 + # fiche détaillée par formation — cache hebdomadaire
47 + week = datetime.date.today().strftime("%G-W%V")
48 + out: list[Formation] = []
49 + for slug, card in cards.items():
50 + key = f"{week}:{card['price_label']}:{card['duration']}"
51 + payload = self.detail(slug, key,
52 + lambda u=card["url"]: self._fetch_detail(u))
53 + f = Formation(
54 + source=self.source_id,
55 + external_id=slug,
56 + url=card["url"],
57 + title=card["title"],
58 + training_type="Formation continue",
59 + category="Entrepreneuriat",
60 + # les « programmes » sont des autoformations sur la plateforme
61 + mode="en ligne" if card["approach"] == "programme" else "",
62 + language="fr",
63 + duration=card["duration"],
64 + price_label=card["price_label"],
65 + is_free=(True if re.search(r"gratuit", card["price_label"], re.I)
66 + else None),
67 + images=card["images"],
68 + details={k: v for k, v in (("approche", card["approach"]),
69 + ("modules", card["modules"]),
70 + ("statut", card["status"]))
71 + if v},
72 + )
73 + for k, v in (payload or {}).items():
74 + if k == "details":
75 + f.details = {**f.details, **v}
76 + elif hasattr(f, k) and v not in (None, "", []):
77 + setattr(f, k, v)
78 + out.append(f)
79 + return out
80 +
81 + # -- liste ------------------------------------------------------------------
82 + def _fetch_cards(self) -> dict[str, dict]:
83 + """Cartes de toutes les pages de la liste, indexées par slug."""
84 + cards: dict[str, dict] = {}
85 + for page in range(1, MAX_PAGES + 1):
86 + html = self.fetch_html(LIST_URL.format(page=page))
87 + soup = BeautifulSoup(html, "html.parser")
88 + new = 0
89 + for item in soup.find_all(class_="training-item"):
90 + a = item.find("a", class_="training-item__link-to-single",
91 + href=True)
92 + if a is None:
93 + continue
94 + url = urljoin(BASE, a["href"])
95 + m = _SLUG_RE.search(url)
96 + if not m or m.group(1) in cards:
97 + continue
98 + img = item.find("img", src=True)
99 + cards[m.group(1)] = {
100 + "url": url,
101 + "title": _txt(item, "training-item__title"),
102 + "duration": _txt(item, "training-item__days__text"),
103 + "price_label": _txt(item, "training-item__price__nb"),
104 + "modules": _txt(item, "training-item__modules__nb"),
105 + "approach": _txt(item, "training-item__approach-type__text"),
106 + "status": _txt(item, "training-item__encours"),
107 + "images": [img["src"]] if img and img["src"].startswith("http")
108 + else [],
109 + }
110 + new += 1
111 + if new == 0: # dernière page atteinte
112 + break
113 + return cards
114 +
115 + # -- fiche --------------------------------------------------------------------
116 + def _fetch_detail(self, url: str) -> dict:
117 + if not url:
118 + return {}
119 + html = self.fetch_html(url)
120 + soup = BeautifulSoup(html, "html.parser")
121 + payload: dict = {}
122 + details: dict = {}
123 +
124 + # phrase d'introduction (« Une formation pour passer de l'idée à l'action! »)
125 + intro = _txt(soup, "single-training__intro")
126 +
127 + # accordéons de description
128 + desc_zone = soup.find(class_="single-training__descriptions")
129 + for acc in (desc_zone.find_all("section", class_="accordion")
130 + if desc_zone else []):
131 + title = _txt(acc, "accordion__header__title")
132 + body = acc.find(class_="accordion__sub-rows")
133 + if body is None:
134 + continue
135 + if re.match(r"description", title, re.I):
136 + # objectifs : liste à puces de l'accordéon Description
137 + ul = body.find("ul")
138 + if ul is not None:
139 + payload["objectives"] = [clean_text(li.get_text(" "))
140 + for li in ul.find_all("li")]
141 + ul.extract()
142 + text = clean_text(body.get_text(" "))
143 + text = re.sub(r"\s*Objectifs?\s*:?\s*$", "", text)
144 + if intro and text.startswith(intro[:60]):
145 + intro = "" # intro déjà reprise dans le corps
146 + payload["description"] = "\n\n".join(
147 + t for t in (intro, text) if t)
148 + elif re.match(r"table des mati|contenu|programme", title, re.I):
149 + payload["program"] = [clean_text(li.get_text(" "))
150 + for li in body.find_all("li")]
151 + if "description" not in payload and intro:
152 + payload["description"] = intro
153 +
154 + # boîte de métadonnées (Type, Coaching, Phase, Prix, clientèle…)
155 + for row in soup.find_all(class_="single-metas-box__detail-wrapper"):
156 + label = _txt(row, "single-training__detail-text").rstrip(" :")
157 + value = _txt(row, "single-training__detail-content")
158 + if not label or not value:
159 + continue
160 + low = label.lower()
161 + if low.startswith("type"):
162 + payload["training_type"] = value
163 + elif low.startswith("prix"):
164 + payload["price_label"] = value
165 + elif re.search(r"client[èe]le|public", low):
166 + payload["audience"] = value
167 + elif low.startswith("dur"):
168 + payload["duration"] = value
169 + else:
170 + details[low.replace(" ", "_")] = value
171 +
172 + # prochaines dates offertes (<select> avec valeurs ISO)
173 + drop = soup.find(class_="training-dropdown")
174 + if drop is not None:
175 + sessions = []
176 + for opt in drop.find_all("option"):
177 + iso = parse_date_fr(str(opt.get("value", ""))[:10]) \
178 + or parse_date_fr(clean_text(opt.get_text(" ")))
179 + if iso:
180 + sessions.append(iso)
181 + if sessions:
182 + payload["sessions"] = sorted(set(sessions))
183 + payload["start_date"] = payload["sessions"][0]
184 +
185 + # visuel de la fiche
186 + cover = soup.find(class_="single-training__cover")
187 + img = cover.find("img", src=True) if cover else None
188 + if img is not None and img["src"].startswith("http"):
189 + payload["images"] = [img["src"]]
190 +
191 + if details:
192 + payload["details"] = details
193 + return payload
added formaka/connectors/ets_formation.py +201 −0
@@ -0,0 +1,201 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/ets_formation.py : connecteur ÉTS Formation (perf.etsmtl.ca)
5 +# Formation continue de l'École de technologie supérieure — ~600 formations
6 +# professionnelles (technologie, gestion, construction, RH…).
7 +# Site ASP.NET rendu serveur, très bien balisé schema.org :
8 +# - liste : ItemList JSON-LD (toutes les formations, dédupliquées par sigle)
9 +# - fiche : Course JSON-LD (description, UEC, séances datées avec prix,
10 +# mode Onsite/Online, charge PTxxH, formateur, lieu) + sections HTML
11 +# (objectifs pédagogiques, contenu, clientèle visée) + catégorie du
12 +# fil d'Ariane. Fiches en cache, rafraîchies chaque semaine (clé ISO).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import datetime
17 +import re
18 +
19 +from bs4 import BeautifulSoup
20 +
21 +from ..schema import Formation, clean_text
22 +from .base import BaseConnector, ldjson_objects
23 +
24 +BASE = "https://www.perf.etsmtl.ca"
25 +LIST_URL = f"{BASE}/Formations"
26 +
27 +_WORKLOAD_RE = re.compile(r"PT(\d+(?:\.\d+)?)H", re.I)
28 +
29 +# eventAttendanceMode / courseMode -> mode canonique Forma-Ka
30 +_MODE_MAP = {
31 + "onsite": "présentiel",
32 + "online": "en ligne",
33 + "offlineeventattendancemode": "présentiel",
34 + "onlineeventattendancemode": "en ligne",
35 + "mixedeventattendancemode": "hybride",
36 +}
37 +
38 +
39 +def _instance_mode(inst: dict) -> str:
40 + for raw in (str(inst.get("eventAttendanceMode", "")),
41 + str(inst.get("courseMode", ""))):
42 + key = raw.rsplit("/", 1)[-1].lower()
43 + if key in _MODE_MAP:
44 + return _MODE_MAP[key]
45 + return ""
46 +
47 +
48 +def _flatten(seq) -> list[str]:
49 + """educationalCredentialAwarded arrive parfois en listes imbriquées."""
50 + out: list[str] = []
51 + if isinstance(seq, str):
52 + return [seq]
53 + for item in seq or []:
54 + out.extend(_flatten(item) if isinstance(item, (list, tuple)) else [str(item)])
55 + return out
56 +
57 +
58 +def _section_items(soup: BeautifulSoup, heading_rx: str) -> list[str]:
59 + """Items de liste (<li>) qui suivent un titre de section donné."""
60 + h = soup.find(["h2", "h3", "h4"],
61 + string=re.compile(heading_rx, re.I))
62 + if h is None:
63 + return []
64 + items: list[str] = []
65 + for sib in h.find_all_next(["ul", "ol", "h2", "h3", "h4"], limit=8):
66 + if sib.name in ("h2", "h3", "h4"):
67 + break
68 + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")]
69 + if items:
70 + break
71 + return [i for i in items if i]
72 +
73 +
74 +class EtsFormationConnector(BaseConnector):
75 + source_id = "ets_formation"
76 + request_delay = 0.5
77 +
78 + def fetch(self) -> list[Formation]:
79 + html = self.fetch_html(LIST_URL)
80 +
81 + # 1) Liste complète depuis l'ItemList JSON-LD (contient des doublons)
82 + courses: dict[str, dict] = {}
83 + for obj in ldjson_objects(html):
84 + if obj.get("@type") != "ItemList":
85 + continue
86 + for li in obj.get("itemListElement", []):
87 + item = li.get("item") or {}
88 + code = item.get("courseCode") or ""
89 + if item.get("@type") == "Course" and code and code not in courses:
90 + courses[code] = item
91 +
92 + # 2) Fiche détaillée par formation — cache hebdomadaire
93 + week = datetime.date.today().strftime("%G-W%V")
94 + out: list[Formation] = []
95 + for code, item in courses.items():
96 + url = item.get("url") or item.get("@id") or ""
97 + key = f"{week}:{item.get('name', '')}"
98 + payload = self.detail(code, key, lambda u=url: self._fetch_detail(u))
99 + f = Formation(
100 + source=self.source_id,
101 + external_id=code,
102 + url=url,
103 + title=item.get("name", ""),
104 + training_type="Formation continue",
105 + language="fr",
106 + code=code,
107 + )
108 + for k, v in (payload or {}).items():
109 + if hasattr(f, k) and v not in (None, "", []):
110 + setattr(f, k, v)
111 + out.append(f)
112 + return out
113 +
114 + # -- fiche ----------------------------------------------------------------
115 + def _fetch_detail(self, url: str) -> dict:
116 + if not url:
117 + return {}
118 + html = self.fetch_html(url)
119 + soup = BeautifulSoup(html, "html.parser")
120 + payload: dict = {}
121 +
122 + course = next((o for o in ldjson_objects(html)
123 + if o.get("@type") == "Course"), None)
124 + if course:
125 + payload["description"] = clean_text(course.get("description", ""))
126 + creds = _flatten(course.get("educationalCredentialAwarded"))
127 + payload["credential"] = " · ".join(dict.fromkeys(creds))
128 +
129 + sessions, cities, modes, instructors = [], [], [], []
130 + price = None
131 + hours = None
132 + for inst in course.get("hasCourseInstance", []) or []:
133 + if not isinstance(inst, dict):
134 + continue
135 + start = str(inst.get("startDate", ""))[:10]
136 + if re.match(r"20\d{2}-\d{2}-\d{2}", start):
137 + sessions.append(start)
138 + mode = _instance_mode(inst)
139 + if mode:
140 + modes.append(mode)
141 + loc = ((inst.get("location") or {}).get("address") or {})
142 + city = loc.get("addressLocality", "")
143 + if city:
144 + cities.append(city)
145 + offer = inst.get("offers") or {}
146 + if isinstance(offer, list):
147 + offer = offer[0] if offer else {}
148 + if price is None and offer.get("price"):
149 + try:
150 + price = float(str(offer["price"]).replace(",", "."))
151 + except ValueError:
152 + pass
153 + m = _WORKLOAD_RE.search(str(inst.get("courseWorkload", "")))
154 + if m and hours is None:
155 + hours = float(m.group(1))
156 + for pers in inst.get("instructor", []) or []:
157 + if isinstance(pers, dict) and pers.get("name"):
158 + instructors.append(pers["name"])
159 +
160 + sessions = sorted(set(sessions))
161 + if sessions:
162 + payload["sessions"] = sessions
163 + payload["start_date"] = sessions[0]
164 + modes = list(dict.fromkeys(modes))
165 + if modes:
166 + payload["mode"] = modes[0] if len(modes) == 1 else "hybride"
167 + payload["details"] = {"modes_offerts": modes}
168 + if cities:
169 + payload["city"] = cities[0]
170 + if price is not None:
171 + payload["price"] = price
172 + payload["price_label"] = f"{price:g} $ + tx"
173 + if hours is not None:
174 + payload["duration_hours"] = hours
175 + payload["duration"] = f"{hours:g} h"
176 + if instructors:
177 + payload["instructor"] = ", ".join(dict.fromkeys(instructors))
178 +
179 + # catégorie : lien du fil d'Ariane (« Technologies de l'information… »)
180 + cat = soup.find(id="ContentPlaceHolder1_LinkFilArianeCat")
181 + if cat:
182 + payload["category"] = clean_text(cat.get_text(" "))
183 +
184 + # sections riches de la fiche
185 + objectives = _section_items(soup, r"objectifs? p[ée]dagogiques?")
186 + if objectives:
187 + payload["objectives"] = objectives
188 + program = []
189 + for h in soup.find_all("h3"):
190 + txt = clean_text(h.get_text(" "))
191 + if ((txt.endswith(":") or txt.endswith(" :"))
192 + and not re.search(r"clients qui ont suivi", txt, re.I)):
193 + program.append(txt.rstrip(" :"))
194 + if program:
195 + payload["program"] = program
196 + aud = soup.find(["h2", "h3"], string=re.compile(r"client[èe]le", re.I))
197 + if aud:
198 + nxt = aud.find_next(["p", "ul"])
199 + if nxt:
200 + payload["audience"] = clean_text(nxt.get_text(" "))
201 + return payload
added formaka/connectors/hec_dirigeants.py +250 −0
@@ -0,0 +1,250 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/hec_dirigeants.py : connecteur École des dirigeant(e)s HEC Montréal
5 +# (ecole-dirigeants.hec.ca — remplace l'ancien ecoledesdirigeants.hec.ca,
6 +# dont le domaine ne résout plus). ~90 séminaires et certifications pour
7 +# cadres et gestionnaires (leadership, finance, IA, stratégie…).
8 +# Boutique Shopify : l'API publique /products.json fournit tout le catalogue
9 +# (titre, type, prix, variantes avec mode + dates de séance, résumé HTML,
10 +# images, formateurs en étiquettes). La fiche produit ajoute les sections
11 +# riches (OBJECTIFS, EST-CE POUR VOUS?, MÉTHODE PÉDAGOGIQUE, Programme,
12 +# Animé par, durée, fil d'Ariane -> catégorie). Fiches en cache,
13 +# rafraîchies chaque semaine (clé ISO « AAAA-WSS »).
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import datetime
18 +import re
19 +
20 +from bs4 import BeautifulSoup
21 +
22 +from ..schema import Formation, clean_text
23 +
24 +from .base import BaseConnector
25 +
26 +BASE = "https://ecole-dirigeants.hec.ca"
27 +PRODUCTS_URL = f"{BASE}/products.json"
28 +
29 +# les produits « Ticket »/« Event » sont des événements promotionnels gratuits
30 +# (conférences, webinaires d'information) — pas des formations du catalogue
31 +_SKIP_TYPES = {"ticket", "event"}
32 +
33 +# product_type Shopify -> type de formation Forma-Ka
34 +_TYPE_MAP = {
35 + "certification": "Certification",
36 + "séminaire": "Séminaire",
37 +}
38 +
39 +_DUREE_VAL_RE = re.compile(r"\d(?:[.,]\d)?\s*(?:jours?|journ[ée]es?|heures?|h\b|"
40 + r"semaines?|mois)", re.I)
41 +
42 +# sections h3 du corps « Présentation » de la fiche
43 +_SECTION_FIELDS = [
44 + (re.compile(r"^objectifs?", re.I), "objectives"),
45 + (re.compile(r"est-ce pour vous", re.I), "audience"),
46 + (re.compile(r"avantages distinctifs", re.I), "_avantages"),
47 + (re.compile(r"m[ée]thodes? p[ée]dagogiques?", re.I), "_methode"),
48 +]
49 +
50 +
51 +def _strip_html(html: str) -> str:
52 + """HTML de résumé Shopify -> texte propre en paragraphes."""
53 + soup = BeautifulSoup(html or "", "html.parser")
54 + paras = [clean_text(p.get_text(" ")) for p in soup.find_all(["p", "li"])]
55 + paras = [p for p in paras if p]
56 + return "\n\n".join(paras) or clean_text(soup.get_text(" "))
57 +
58 +
59 +def _rich_sections(container) -> dict:
60 + """Découpe un bloc riche (métachamp Shopify) selon ses titres h3/strong."""
61 + out: dict[str, list[str]] = {}
62 + current: str | None = None
63 + for el in container.find_all(["h3", "h4", "p", "ul", "ol"]):
64 + if el.name in ("h3", "h4"):
65 + heading = clean_text(el.get_text(" "))
66 + current = None
67 + for rx, field in _SECTION_FIELDS:
68 + if rx.search(heading):
69 + current = field
70 + out.setdefault(current, [])
71 + break
72 + continue
73 + if current is None:
74 + continue
75 + if el.name in ("ul", "ol"):
76 + out[current] += [clean_text(li.get_text(" "))
77 + for li in el.find_all("li") if clean_text(li.get_text(" "))]
78 + else:
79 + txt = clean_text(el.get_text(" "))
80 + if txt:
81 + out[current].append(txt)
82 + return out
83 +
84 +
85 +class HecDirigeantsConnector(BaseConnector):
86 + source_id = "hec_dirigeants"
87 + request_delay = 0.5
88 +
89 + def fetch(self) -> list[Formation]:
90 + # 1) Catalogue complet via l'API Shopify /products.json (paginée)
91 + products: list[dict] = []
92 + page = 1
93 + while True:
94 + resp = self.get(PRODUCTS_URL, params={"limit": 250, "page": page})
95 + batch = resp.json().get("products", [])
96 + products += batch
97 + if len(batch) < 250:
98 + break
99 + page += 1
100 +
101 + week = datetime.date.today().strftime("%G-W%V")
102 + out: list[Formation] = []
103 + for prod in products:
104 + ptype = (prod.get("product_type") or "").strip().lower()
105 + if ptype in _SKIP_TYPES:
106 + continue
107 + handle = prod.get("handle", "")
108 + url = f"{BASE}/products/{handle}"
109 +
110 + f = Formation(
111 + source=self.source_id,
112 + external_id=handle,
113 + url=url,
114 + title=clean_text(prod.get("title", "")),
115 + training_type=_TYPE_MAP.get(ptype, "Séminaire"),
116 + language="fr",
117 + description=_strip_html(prod.get("body_html", "")),
118 + images=[img["src"] for img in prod.get("images", [])
119 + if img.get("src")][:3],
120 + )
121 +
122 + # variantes : prix + options (Localisation, Date)
123 + prices = []
124 + for var in prod.get("variants", []):
125 + try:
126 + p = float(var.get("price") or 0)
127 + except (TypeError, ValueError):
128 + continue
129 + if p > 0:
130 + prices.append(p)
131 + if prices:
132 + f.price = min(prices)
133 + f.price_label = f"{f.price:g} $ + tx"
134 + if len(set(prices)) > 1:
135 + f.details["price_from"] = True
136 +
137 + modes, dates = [], []
138 + for opt in prod.get("options", []):
139 + name = (opt.get("name") or "").lower()
140 + values = [v for v in opt.get("values", []) if v]
141 + if "localisation" in name or "format" in name:
142 + modes += values
143 + elif "date" in name:
144 + dates += values
145 + if modes:
146 + f.mode = modes[0] if len(set(modes)) == 1 else "hybride"
147 + f.details["modes_offerts"] = sorted(set(modes))
148 + if dates:
149 + f.schedule_label = " ; ".join(dates)
150 + if re.search(r"présentiel|campus|montr[ée]al", f.mode, re.I):
151 + f.city = "Montréal"
152 +
153 + # étiquettes : catégorie + formateurs (affinées par la fiche)
154 + if prod.get("tags"):
155 + f.tags = [t for t in prod["tags"] if isinstance(t, str)]
156 +
157 + # 2) Fiche produit — sections riches, cache hebdomadaire
158 + key = f"{week}:{prod.get('updated_at', '')}"
159 + payload = self.detail(handle, key, lambda u=url: self._fetch_detail(u))
160 + for k, v in (payload or {}).items():
161 + if k == "details":
162 + f.details = {**f.details, **v}
163 + elif hasattr(f, k) and v not in (None, "", []):
164 + setattr(f, k, v)
165 + out.append(f)
166 + return out
167 +
168 + # -- fiche ----------------------------------------------------------------
169 + def _fetch_detail(self, url: str) -> dict:
170 + html = self.fetch_html(url)
171 + soup = BeautifulSoup(html, "html.parser")
172 + payload: dict = {}
173 + details: dict = {}
174 +
175 + # fil d'Ariane : « Accueil > Leadership > … » -> catégorie
176 + bc = soup.select_one(".product-main__breadcrumbs")
177 + if bc:
178 + crumbs = [clean_text(a.get_text(" ")) for a in bc.find_all("a")]
179 + crumbs = [c for c in crumbs if c and c.lower() != "accueil"]
180 + if crumbs:
181 + payload["category"] = crumbs[0]
182 +
183 + # contenus d'onglets : deux gabarits selon le type de produit —
184 + # séminaires (.product-main__presentation-body / __programme) ou
185 + # certifications (onglets dynamiques « hec-tab-* » appariés par ordre
186 + # aux boutons Présentation / Programme / Animé par)
187 + tabs: dict[str, "BeautifulSoup"] = {}
188 + btns = soup.select('[class*="hec-tab-btn"]')
189 + conts = soup.select('[class*="hec-tab-content"]')
190 + for btn, cont in zip(btns, conts):
191 + tabs[clean_text(btn.get_text(" ")).lower()] = cont
192 +
193 + # corps « Présentation » : OBJECTIFS / EST-CE POUR VOUS? / MÉTHODE…
194 + body = soup.select_one(".product-main__presentation-body") or \
195 + tabs.get("présentation")
196 + if body:
197 + sections = _rich_sections(body)
198 + if sections.get("objectives"):
199 + # on écarte les phrases d'amorce (« Ce programme vous permettra de : »)
200 + objs = [o for o in sections["objectives"] if not o.endswith(":")]
201 + payload["objectives"] = objs or sections["objectives"]
202 + if sections.get("audience"):
203 + payload["audience"] = " ".join(sections["audience"])
204 + if sections.get("_avantages"):
205 + details["avantages_distinctifs"] = sections["_avantages"]
206 + if sections.get("_methode"):
207 + details["methode_pedagogique"] = " ".join(sections["_methode"])
208 +
209 + # onglet « Programme » : plan de la formation
210 + prog = soup.select_one(".product-main__programme") or tabs.get("programme")
211 + if prog:
212 + items = [clean_text(li.get_text(" ")) for li in prog.find_all("li")]
213 + items = [i for i in items if i]
214 + if not items:
215 + items = [clean_text(p.get_text(" ")) for p in prog.find_all("p")
216 + if clean_text(p.get_text(" "))]
217 + if items:
218 + payload["program"] = items
219 +
220 + # « Animé par » : formateurs et formatrices
221 + names = [clean_text(n.get_text(" "))
222 + for n in soup.select(".product-main__animateurs-expert-name")]
223 + titles = [clean_text(t.get_text(" "))
224 + for t in soup.select(".product-main__animateurs-expert-title")]
225 + experts = list(dict.fromkeys(
226 + f"{n} ({t})" if t else n
227 + for n, t in zip(names, titles + [""] * len(names)) if n))
228 + if not experts and tabs.get("animé par") is not None:
229 + # gabarit certification : noms en <strong>, titres en <p> suivants
230 + experts = list(dict.fromkeys(
231 + clean_text(s.get_text(" "))
232 + for s in tabs["animé par"].find_all("strong")
233 + if clean_text(s.get_text(" "))))
234 + if experts:
235 + payload["instructor"] = ", ".join(experts)
236 +
237 + # encadré d'inscription : durée (« Durée » suivi de « 2 jours »…)
238 + lbl = soup.find(string=re.compile(r"^\s*Durée\s*$"))
239 + if lbl:
240 + for nxt in lbl.find_all_next(string=True, limit=8):
241 + val = clean_text(str(nxt))
242 + if _DUREE_VAL_RE.search(val):
243 + payload["duration"] = val
244 + break
245 + if re.search(r"Tarif|Langue|Format", val):
246 + break
247 +
248 + if details:
249 + payload["details"] = details
250 + return payload
added formaka/connectors/institut_leadership.py +247 −0
@@ -0,0 +1,247 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/institut_leadership.py : connecteur Institut de leadership
5 +# (institutleadership.ca, campus de Montréal) — certifications, programmes
6 +# et formations signatures en leadership et gestion (~30 programmes animés
7 +# par des dirigeants et personnalités québécoises).
8 +# Site WordPress multisite (sous-site /montreal/) :
9 +# - liste : liens du menu « Programmes de formation » de la page d'accueil
10 +# (slugs certification-*, programme-*, formation-signature-*, etc.)
11 +# - fiche : REST /montreal/wp-json/wp/v2/pages?slug=… -> content.rendered
12 +# propre (sans gabarit) : description, clientèle (« À QUI S'ADRESSE »),
13 +# modules (h4), objectifs, tarifs « 5595$ + taxes », cohortes datées,
14 +# mode en ligne / présentiel, formateurs (« animée par … »), image Yoast.
15 +# Fiches en cache, rafraîchies chaque semaine (clé ISO AAAA-WSS).
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import datetime
20 +import json
21 +import re
22 +
23 +from bs4 import BeautifulSoup
24 +
25 +from ..schema import Formation, clean_text, parse_date_fr
26 +from .base import BaseConnector
27 +
28 +BASE = "https://www.institutleadership.ca"
29 +LIST_URL = f"{BASE}/montreal/"
30 +API_URL = f"{BASE}/montreal/wp-json/wp/v2/pages"
31 +
32 +# slugs de pages « programme » dans le menu (le reste = pages corporatives)
33 +_PROGRAM_SLUG_RE = re.compile(
34 + r"^(certification-|programme-|formation-signature-|habiletes-politiques|"
35 + r"lessentiel-|communiquer-|c-lmh|experience-immersive)")
36 +
37 +_PRICE_RE = re.compile(r"(\d[\d\s ]{2,9})\s*\$\s*(?:\*)?\s*\+\s*taxes", re.I)
38 +_HOURS_RE = re.compile(r"de\s+(\d{1,2})\s*h\s*(\d{2})?\s*à\s+(\d{1,2})\s*h\s*(\d{2})?",
39 + re.I)
40 +_DAYS_RE = re.compile(r"(\d+)\s*(?:jours|journées)", re.I)
41 +_INSTRUCTOR_RE = re.compile(
42 + r"animée?s? par\s+((?:[A-ZÀ-Ý][\wà-ÿ'’.-]+\s+){1,3}[A-ZÀ-Ý][\wà-ÿ'’.-]+)")
43 +_DAY_MONTH_RE = re.compile(
44 + r"(\d{1,2}|1er)\s+(janvier|février|mars|avril|mai|juin|juillet|août|"
45 + r"septembre|octobre|novembre|décembre)", re.I)
46 +_YEAR_RE = re.compile(r"\b(20\d{2})\b")
47 +
48 +# mot-clé du slug/titre -> catégorie Forma-Ka
49 +_CATEGORY_MAP = [
50 + (re.compile(r"\bia\b|chatgpt|copilot|claude|numérique|agentique", re.I),
51 + "Intelligence artificielle"),
52 + (re.compile(r"gouvernance", re.I), "Gouvernance"),
53 + (re.compile(r"strat[ée]gi", re.I), "Stratégie"),
54 + (re.compile(r"gestion de projet|gestion de projets", re.I), "Gestion de projet"),
55 + (re.compile(r"n[ée]gociation", re.I), "Négociation"),
56 + (re.compile(r"finance", re.I), "Finance"),
57 + (re.compile(r"communiquer|parler en public|réunions|rétroaction", re.I),
58 + "Communication"),
59 + (re.compile(r"transfert d", re.I), "Transfert d'entreprise"),
60 +]
61 +
62 +
63 +def _json_lenient(raw: str):
64 + """L'API WP du site ajoute parfois du bruit après le JSON — décodage tolérant."""
65 + return json.JSONDecoder().raw_decode(raw.lstrip(" \r\n"))[0]
66 +
67 +
68 +class InstitutLeadershipConnector(BaseConnector):
69 + source_id = "institut_leadership"
70 + request_delay = 0.5
71 +
72 + def fetch(self) -> list[Formation]:
73 + # 1) liste des programmes depuis le menu de la page d'accueil Montréal
74 + html = self.fetch_html(LIST_URL)
75 + slugs = list(dict.fromkeys(
76 + s for s in re.findall(
77 + r'href="https?://(?:www\.)?institutleadership\.ca/montreal/([\w-]+)/"',
78 + html)
79 + if _PROGRAM_SLUG_RE.match(s)))
80 +
81 + # 2) fiche par programme via l'API REST — cache hebdomadaire
82 + week = datetime.date.today().strftime("%G-W%V")
83 + out: list[Formation] = []
84 + for slug in slugs:
85 + payload = self.detail(slug, week, lambda s=slug: self._fetch_detail(s))
86 + if not payload:
87 + continue
88 + f = Formation(
89 + source=self.source_id,
90 + external_id=slug,
91 + url=payload.get("url") or f"{BASE}/montreal/{slug}/",
92 + language="fr",
93 + )
94 + # type + accréditation selon la famille de programme
95 + if slug.startswith(("certification-", "c-lmh")):
96 + f.training_type = "Certification"
97 + f.credential = "Certification de l'Institut de leadership"
98 + elif slug.startswith(("programme-", "experience-immersive")):
99 + f.training_type = "Programme"
100 + else:
101 + f.training_type = "Formation continue"
102 + for k, v in payload.items():
103 + if k != "url" and hasattr(f, k) and v not in (None, "", []):
104 + setattr(f, k, v)
105 + # catégorie par mots-clés (défaut : Leadership)
106 + probe = f"{slug} {f.title}"
107 + f.category = next((cat for rx, cat in _CATEGORY_MAP
108 + if rx.search(probe)), "Leadership")
109 + f.tags = sorted({f.category, "Leadership"})
110 + out.append(f)
111 + return out
112 +
113 + # -- fiche ----------------------------------------------------------------
114 + def _fetch_detail(self, slug: str) -> dict:
115 + resp = self.get(API_URL, params={
116 + "slug": slug,
117 + "_fields": "slug,link,title,content,excerpt,yoast_head_json.og_image",
118 + })
119 + pages = _json_lenient(resp.text)
120 + if not pages:
121 + return {}
122 + page = pages[0]
123 + payload: dict = {
124 + "title": clean_text(BeautifulSoup(
125 + page.get("title", {}).get("rendered", ""),
126 + "html.parser").get_text(" ")),
127 + "url": page.get("link", ""),
128 + }
129 + soup = BeautifulSoup(page.get("content", {}).get("rendered", ""),
130 + "html.parser")
131 + text = soup.get_text("\n")
132 + lines = [clean_text(l) for l in text.split("\n") if clean_text(l)]
133 +
134 + # description : paragraphes avant la première grande section (h3)
135 + first_h3 = soup.find("h3")
136 + desc_parts = []
137 + for p in soup.find_all("p"):
138 + if first_h3 and p.sourceline and first_h3.sourceline \
139 + and p.sourceline >= first_h3.sourceline:
140 + break
141 + txt = clean_text(p.get_text(" "))
142 + if len(txt) > 60:
143 + desc_parts.append(txt)
144 + if len(" ".join(desc_parts)) > 900:
145 + break
146 + payload["description"] = "\n\n".join(desc_parts)
147 + if not payload["description"]:
148 + exc = BeautifulSoup(page.get("excerpt", {}).get("rendered", ""),
149 + "html.parser").get_text(" ")
150 + payload["description"] = clean_text(exc)
151 +
152 + # clientèle visée : section « À QUI S'ADRESSE … »
153 + aud = soup.find(["h2", "h3"], string=re.compile(r"à qui s['’]adresse", re.I))
154 + if aud:
155 + parts = []
156 + for sib in aud.find_all_next(["p", "ul", "h2", "h3"], limit=6):
157 + if sib.name in ("h2", "h3"):
158 + break
159 + parts.append(clean_text(sib.get_text(" ")))
160 + payload["audience"] = " ".join(x for x in parts if x)[:600]
161 +
162 + # objectifs : liste qui suit « Objectifs de la formation »
163 + obj = soup.find(string=re.compile(r"objectifs de la (formation|certification)",
164 + re.I))
165 + if obj:
166 + ul = obj.find_parent().find_next("ul")
167 + if ul:
168 + payload["objectives"] = [clean_text(li.get_text(" "))
169 + for li in ul.find_all("li")]
170 +
171 + # plan : modules h4 (hors sections administratives)
172 + program = []
173 + for h in soup.find_all("h4"):
174 + txt = clean_text(h.get_text(" "))
175 + if txt and not re.search(r"à lire aussi|témoignages", txt, re.I):
176 + program.append(txt)
177 + if program:
178 + payload["program"] = program
179 +
180 + # tarifs : « 5595$ + taxes » (minimum = tarif d'appel)
181 + prices = []
182 + labels = []
183 + for line in lines:
184 + for m in _PRICE_RE.finditer(line):
185 + val = float(re.sub(r"[\s ]", "", m.group(1)))
186 + if val >= 100: # ignore les frais annexes
187 + prices.append(val)
188 + if line not in labels:
189 + labels.append(line)
190 + if prices:
191 + payload["price"] = min(prices)
192 + payload["price_label"] = " | ".join(labels[:3])
193 +
194 + # cohortes datées : « Automne 2026 – divisé : 30 septembre, … »
195 + sessions = []
196 + cohortes = []
197 + for line in lines:
198 + my = _YEAR_RE.search(line)
199 + md = _DAY_MONTH_RE.search(line)
200 + if md and (my or re.search(r"20\d{2}", line)):
201 + cohortes.append(line)
202 + year = my.group(1) if my else ""
203 + iso = parse_date_fr(f"{md.group(1)} {md.group(2)} {year}")
204 + if iso:
205 + sessions.append(iso)
206 + if sessions:
207 + sessions = sorted(set(sessions))
208 + payload["sessions"] = sessions
209 + payload["start_date"] = sessions[0]
210 + if cohortes:
211 + payload["schedule_label"] = cohortes[0]
212 + payload["details"] = {"cohortes": cohortes[:12]}
213 +
214 + # mode et ville
215 + online = re.search(r"en ligne", text, re.I)
216 + onsite = re.search(r"pr[ée]sentiel", text, re.I)
217 + if online and onsite:
218 + payload["mode"] = "hybride"
219 + elif online:
220 + payload["mode"] = "en ligne"
221 + elif onsite:
222 + payload["mode"] = "présentiel"
223 + if onsite and re.search(r"pr[ée]sentiel à Montréal|à Montréal", text, re.I):
224 + payload["city"] = "Montréal"
225 +
226 + # durée : « de 8h30 à 13h00 » (demi-journée) ou « 6 jours »
227 + mh = _HOURS_RE.search(text)
228 + md = _DAYS_RE.search(text)
229 + if mh:
230 + start = int(mh.group(1)) + int(mh.group(2) or 0) / 60
231 + end = int(mh.group(3)) + int(mh.group(4) or 0) / 60
232 + if end > start:
233 + payload["duration_hours"] = round(end - start, 2)
234 + payload["duration"] = f"{end - start:g} h"
235 + elif md:
236 + payload["duration"] = f"{md.group(1)} jours"
237 +
238 + # formateur(s) : « La formation est animée par … »
239 + mi = _INSTRUCTOR_RE.search(text)
240 + if mi:
241 + payload["instructor"] = clean_text(mi.group(1))
242 +
243 + # image Yoast (og:image)
244 + ogs = (page.get("yoast_head_json") or {}).get("og_image") or []
245 + payload["images"] = [o["url"] for o in ogs if isinstance(o, dict)
246 + and o.get("url")]
247 + return payload
added formaka/connectors/isarta.py +200 −0
@@ -0,0 +1,200 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/isarta.py : connecteur Isarta Formations (formations.isarta.com)
5 +# Catalogue de formations professionnelles en marketing, communication,
6 +# médias sociaux, IA, RH et gestion (Montréal — surtout en virtuel).
7 +# Site rendu serveur : la page catalogue liste TOUTES les fiches dans des
8 +# cartes <article class="training-card"> très riches (titre, formateur,
9 +# durée, niveau, catégories en data-attributes, sommaire, offres EN DIRECT
10 +# avec date/mode/prix et EN PRIVÉ sur mesure). Les libellés français des
11 +# catégories sont repris des filtres de la page. Les fiches /cours/<slug>/<id>
12 +# ajoutent description et objectifs, plan de formation et public cible.
13 +# Fiches en cache, rafraîchies chaque semaine (clé « AAAA-WSS »).
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import datetime
18 +import re
19 +from urllib.parse import urljoin
20 +
21 +from bs4 import BeautifulSoup
22 +
23 +from ..schema import Formation, clean_text
24 +from .base import BaseConnector
25 +
26 +BASE = "https://formations.isarta.com"
27 +LIST_URL = f"{BASE}/"
28 +
29 +_DURATION_RE = re.compile(r"\b(\d{1,2})\s*h\s*(\d{2})?\b")
30 +_ID_RE = re.compile(r"/cours/[^/]+/(\d+)")
31 +
32 +# type de contenu Isarta -> type de formation Forma-Ka
33 +_TYPE_MAP = {"training": "Formation continue", "conference": "Conférence",
34 + "workshop": "Atelier"}
35 +# libellé du mode de diffusion -> mode canonique
36 +_MODE_MAP = {"virtuel interactif": "en ligne", "vidéo enregistrée": "asynchrone",
37 + "en présentiel": "présentiel"}
38 +
39 +
40 +def _category_labels(soup: BeautifulSoup) -> dict[str, str]:
41 + """Code de catégorie -> libellé français, depuis les filtres de la page."""
42 + labels: dict[str, str] = {}
43 + for inp in soup.find_all("input", attrs={"name": "categories[]"}):
44 + span = inp.find_next("span", class_="catalog-filters__option-text")
45 + if inp.get("value") and span:
46 + labels[inp["value"]] = clean_text(span.get_text(" "))
47 + return labels
48 +
49 +
50 +def _heading_section(soup: BeautifulSoup, heading_rx: str):
51 + """Conteneur (parent direct) du <h3 class="gray-title"> correspondant."""
52 + for h in soup.find_all("h3"):
53 + if re.search(heading_rx, clean_text(h.get_text(" ")), re.I):
54 + return h.parent
55 + return None
56 +
57 +
58 +class IsartaConnector(BaseConnector):
59 + source_id = "isarta"
60 + request_delay = 0.5
61 + limit: int | None = None # borne optionnelle (tests/débogage)
62 +
63 + def fetch(self) -> list[Formation]:
64 + html = self.fetch_html(LIST_URL)
65 + soup = BeautifulSoup(html, "html.parser")
66 + cat_labels = _category_labels(soup)
67 + cards = soup.find_all("article", class_="training-card")
68 + if self.limit:
69 + cards = cards[: self.limit]
70 +
71 + week = datetime.date.today().strftime("%G-W%V")
72 + out: list[Formation] = []
73 + for card in cards:
74 + link = card.find("h2", class_="training-card__title")
75 + link = link.find("a") if link else None
76 + if link is None or not link.get("href"):
77 + continue
78 + url = urljoin(BASE, link["href"])
79 + m = _ID_RE.search(url)
80 + external_id = m.group(1) if m else url.rsplit("/", 1)[-1]
81 +
82 + f = Formation(
83 + source=self.source_id,
84 + external_id=str(external_id),
85 + url=url,
86 + title=clean_text(link.get_text(" ")),
87 + training_type=_TYPE_MAP.get(card.get("data-content-type", ""),
88 + "Formation continue"),
89 + language="fr",
90 + )
91 +
92 + # catégories (codes -> libellés français des filtres)
93 + codes = [c for c in card.get("data-categories", "").split(",") if c]
94 + names = [cat_labels.get(c, c.replace("_", " ").capitalize())
95 + for c in codes]
96 + if names:
97 + f.category = names[0]
98 + f.tags = names
99 +
100 + trainers = card.find("p", class_="training-card__trainers")
101 + if trainers:
102 + f.instructor = clean_text(
103 + trainers.get_text(" ")).removeprefix("par ").strip()
104 +
105 + # méta : durée (« 3h30 ») + niveau (« Débutant-Intermédiaire »)
106 + meta = card.find(class_="training-card__meta")
107 + if meta:
108 + txt = clean_text(meta.get_text(" "))
109 + dm = _DURATION_RE.search(txt)
110 + if dm:
111 + f.duration = dm.group(0)
112 + f.duration_hours = (int(dm.group(1))
113 + + int(dm.group(2) or 0) / 60.0)
114 + txt = txt.replace(dm.group(0), "")
115 + level = clean_text(txt)
116 + if level and "pour tous" not in level.lower():
117 + f.level = level
118 + elif level:
119 + f.details["niveau"] = level
120 +
121 + summary = card.find("p", class_="training-card__summary")
122 + if summary:
123 + f.description = clean_text(summary.get_text(" "))
124 + img = card.find("img", class_="training-card__image")
125 + if img and img.get("src"):
126 + f.images = [urljoin(BASE, img["src"])]
127 +
128 + # offres : EN DIRECT / EN VIDÉO (date, mode, prix) + EN PRIVÉ
129 + modes, sessions = [], []
130 + for offer in card.find_all(class_="training-card__offer"):
131 + classes = " ".join(offer.get("class") or [])
132 + if "offer--private" in classes:
133 + f.details["offre_privee"] = "sur mesure"
134 + continue
135 + label = offer.find(class_="training-card__mode-label")
136 + if label:
137 + mode = _MODE_MAP.get(clean_text(label.get_text(" ")).lower())
138 + if mode:
139 + modes.append(mode)
140 + t = offer.find("time")
141 + if t and t.get("datetime"):
142 + sessions.append(t["datetime"][:10])
143 + price = offer.find(class_="training-card__price")
144 + if price and not f.price_label:
145 + txt = clean_text(price.get_text(" "))
146 + if txt and "demande" not in txt.lower():
147 + f.price_label = txt
148 + modes = list(dict.fromkeys(modes))
149 + if modes:
150 + f.mode = modes[0]
151 + f.details["modes_offerts"] = modes
152 + sessions = sorted(set(sessions))
153 + if sessions:
154 + f.sessions = sessions
155 + f.start_date = sessions[0]
156 +
157 + # fiche détaillée — cache hebdomadaire
158 + key = f"{week}:{f.start_date}:{f.price_label}"
159 + payload = self.detail(external_id, key,
160 + lambda u=url: self._fetch_detail(u))
161 + for k, v in (payload or {}).items():
162 + if k == "details":
163 + f.details.update(v or {})
164 + elif hasattr(f, k) and v not in (None, "", []):
165 + setattr(f, k, v)
166 + out.append(f)
167 + return out
168 +
169 + # -- fiche ----------------------------------------------------------------
170 + def _fetch_detail(self, url: str) -> dict:
171 + html = self.fetch_html(url)
172 + soup = BeautifulSoup(html, "html.parser")
173 + payload: dict = {}
174 +
175 + # « Description et objectifs » : paragraphes + puces (objectifs)
176 + sec = _heading_section(soup, r"description et objectifs")
177 + if sec is not None:
178 + paragraphs = [clean_text(p.get_text(" ")) for p in sec.find_all("p")]
179 + desc = "\n\n".join(dict.fromkeys(p for p in paragraphs if p))
180 + if desc:
181 + payload["description"] = desc
182 + objectives = [clean_text(li.get_text(" "))
183 + for li in sec.find_all("li")]
184 + objectives = [o for o in dict.fromkeys(objectives) if o]
185 + if objectives:
186 + payload["objectives"] = objectives
187 +
188 + sec = _heading_section(soup, r"plan de (formation|cours)")
189 + if sec is not None:
190 + program = [clean_text(li.get_text(" ")) for li in sec.find_all("li")]
191 + program = [p for p in dict.fromkeys(program) if p]
192 + if program:
193 + payload["program"] = program
194 +
195 + sec = _heading_section(soup, r"public cible")
196 + if sec is not None:
197 + txt = clean_text(sec.get_text(" "))
198 + payload["audience"] = re.sub(r"^public cible\s*", "", txt,
199 + flags=re.I)
200 + return payload
added formaka/connectors/ithq.py +233 −0
@@ -0,0 +1,233 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/ithq.py : connecteur ITHQ (ithq.qc.ca)
5 +# Ateliers grand public (cours SAQ par ITHQ : vins, spiritueux, dégustation)
6 +# et formations continues professionnelles (tourisme, hôtellerie,
7 +# restauration) de l'Institut de tourisme et d'hôtellerie du Québec.
8 +# Site WordPress : l'API REST publique expose deux types de contenus
9 +# (« workshop » et « formation ») avec le HTML Gutenberg complet de chaque
10 +# fiche + taxonomies (_embed) — AUCUNE page à visiter individuellement :
11 +# - caractéristiques : Durée, Formule (en présence/en ligne), Langue, Coût
12 +# - blocs libres : présentation, « Ce que vous découvrirez/apprendrez »
13 +# (objectifs), Contenu, Préalable, Coût, Modalités (attestation)
14 +# - bloc de dates : séances datées par ville (Montréal, Québec, Brossard…)
15 +# - taxonomies : catégorie, langue, niveau, domaine, clientèle, formule.
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import json
20 +import re
21 +
22 +from bs4 import BeautifulSoup
23 +
24 +from ..schema import Formation, clean_text, parse_date_fr
25 +from .base import BaseConnector
26 +
27 +BASE = "https://www.ithq.qc.ca"
28 +API_URL = f"{BASE}/wp-json/wp/v2/{{cpt}}?per_page=100&page={{page}}&_embed=1"
29 +
30 +# type de contenu WordPress -> type de formation Forma-Ka
31 +_CPT_TYPES = {"workshop": "Atelier", "formation": "Formation continue"}
32 +
33 +# titres de sections (h2/h3/h4 Gutenberg) -> rôle dans le schéma
34 +_SECTION_ROLES = [
35 + (re.compile(r"d[ée]couvrirez|apprendrez|objectifs?", re.I), "objectives"),
36 + (re.compile(r"^contenu|programme|au menu|d[ée]roulement", re.I), "program"),
37 + (re.compile(r"pr[ée]alable", re.I), "prerequisites"),
38 + (re.compile(r"^co[ûu]t", re.I), "price"),
39 + (re.compile(r"modalit[ée]s?", re.I), "modalites"),
40 + (re.compile(r"s[’']adresse|client[èe]le|à qui", re.I), "audience"),
41 + (re.compile(r"^dur[ée]e", re.I), "duration"),
42 + (re.compile(r"^pr[ée]sentation", re.I), ""),
43 + (re.compile(r"^dates?$|information compl[ée]mentaire", re.I), "skip"),
44 +]
45 +
46 +# « Formule » / taxonomie activity-type -> mode canonique Forma-Ka
47 +_FORMULE_MAP = {"en présence": "présentiel", "en ligne": "en ligne",
48 + "hybride": "hybride"}
49 +
50 +
51 +def _terms_by_taxonomy(item: dict) -> dict[str, list[str]]:
52 + """Noms des termes de taxonomie ({'workshop-category': ['Cours vins']…})."""
53 + out: dict[str, list[str]] = {}
54 + for group in (item.get("_embedded", {}).get("wp:term") or []):
55 + for term in group or []:
56 + if isinstance(term, dict) and term.get("name"):
57 + out.setdefault(term.get("taxonomy", ""), []).append(term["name"])
58 + return out
59 +
60 +
61 +def _section_role(title: str) -> str | None:
62 + """Rôle d'un titre de section, ou None si ce n'est pas un titre connu."""
63 + for rx, role in _SECTION_ROLES:
64 + if rx.search(title):
65 + return role
66 + return None
67 +
68 +
69 +class IthqConnector(BaseConnector):
70 + source_id = "ithq"
71 + request_delay = 0.5
72 +
73 + def fetch(self) -> list[Formation]:
74 + out: list[Formation] = []
75 + for cpt, training_type in _CPT_TYPES.items():
76 + for item in self._fetch_items(cpt):
77 + out.append(self._build(item, cpt, training_type))
78 + return out
79 +
80 + # -- API REST -----------------------------------------------------------------
81 + def _get_json(self, url: str) -> list:
82 + """JSON de l'API avec repli : requests direct -> Scrapfly (le site
83 + refuse certains agents utilisateurs, mais l'API reste publique)."""
84 + try:
85 + return self.get(url).json()
86 + except Exception:
87 + pass
88 + text = self.get_scrapfly(url).strip()
89 + if text.startswith("<"): # réponse enrobée de HTML
90 + text = BeautifulSoup(text, "html.parser").get_text()
91 + return json.loads(text)
92 +
93 + def _fetch_items(self, cpt: str) -> list[dict]:
94 + """Tous les contenus publiés d'un type donné (pagination REST)."""
95 + items: list[dict] = []
96 + page = 1
97 + while True:
98 + batch = self._get_json(API_URL.format(cpt=cpt, page=page))
99 + items.extend(batch)
100 + if len(batch) < 100:
101 + break
102 + page += 1
103 + return items
104 +
105 + # -- construction ---------------------------------------------------------------
106 + def _build(self, item: dict, cpt: str, training_type: str) -> Formation:
107 + taxes = _terms_by_taxonomy(item)
108 + title = BeautifulSoup(item.get("title", {}).get("rendered", ""),
109 + "html.parser").get_text()
110 + f = Formation(
111 + source=self.source_id,
112 + external_id=f"{cpt}:{item.get('slug', item.get('id'))}",
113 + url=item.get("link", ""),
114 + title=clean_text(title),
115 + training_type=training_type,
116 + category=(taxes.get(f"{cpt}-category") or [""])[0],
117 + mode=_FORMULE_MAP.get(
118 + (taxes.get("activity-type") or [""])[0].lower(),
119 + (taxes.get("activity-type") or [""])[0]),
120 + language=(taxes.get(f"{cpt}-language") or [""])[0],
121 + level=(taxes.get("workshop-level") or [""])[0],
122 + audience=", ".join(taxes.get("formation-clientele") or []),
123 + tags=(taxes.get("formation-domain") or [])
124 + + (taxes.get("formation-family") or []),
125 + )
126 + media = item.get("_embedded", {}).get("wp:featuredmedia") or []
127 + if media and isinstance(media[0], dict) and media[0].get("source_url"):
128 + f.images = [media[0]["source_url"]]
129 + self._parse_content(f, item.get("content", {}).get("rendered", ""))
130 + return f
131 +
132 + # -- HTML Gutenberg de la fiche ---------------------------------------------------
133 + def _parse_content(self, f: Formation, html: str) -> None:
134 + soup = BeautifulSoup(html, "html.parser")
135 +
136 + # 1) bloc de caractéristiques (Durée / Formule / Langue / Coût / Domaine)
137 + for it in soup.find_all(class_="activity-characteristics__item"):
138 + label = clean_text((it.find(class_="label") or it).get_text(" "))
139 + content = it.find(class_="content")
140 + value = clean_text(content.get_text(" ")) if content else ""
141 + if not value:
142 + continue
143 + low = label.lower()
144 + if low.startswith("dur"):
145 + f.duration = value
146 + elif low.startswith("formule") and not f.mode:
147 + f.mode = _FORMULE_MAP.get(value.lower(), value)
148 + elif low.startswith("langue") and not f.language:
149 + f.language = value
150 + elif low.startswith("co"):
151 + f.price_label = value
152 + elif low.startswith("domaine") and not f.category:
153 + f.category = value
154 +
155 + # 2) blocs libres au premier niveau : description + sections titrées
156 + # (les titres de section connus — Présentation, Objectifs, Contenu,
157 + # Coût, Modalités… — routent les blocs qui les suivent ; les autres
158 + # titres sont des sous-titres qui restent dans la section courante)
159 + desc_parts: list[str] = []
160 + role = ""
161 + for el in soup.find_all(recursive=False):
162 + cls = " ".join(el.get("class") or [])
163 + if el.name in ("h2", "h3", "h4") and "wp-block-heading" in cls:
164 + title = clean_text(el.get_text(" "))
165 + known = _section_role(title)
166 + if known is not None:
167 + role = known
168 + else: # sous-titre descriptif
169 + role = ""
170 + desc_parts.append(title)
171 + continue
172 + if "activity-dates-block" in cls or "activity-related-block" in cls:
173 + role = "skip"
174 + if role == "skip" or el.name in ("section", "hr", "figure"):
175 + continue
176 + if el.name not in ("p", "ul", "ol", "div"):
177 + continue
178 + items = [clean_text(li.get_text(" ")) for li in el.find_all("li")]
179 + text = clean_text(el.get_text(" "))
180 + if not text:
181 + continue
182 + if role in ("objectives", "program"):
183 + target = f.objectives if role == "objectives" else f.program
184 + if items:
185 + target += items
186 + elif not text.endswith(":"): # phrase d'amorce ignorée
187 + target.append(text)
188 + elif role == "prerequisites":
189 + f.prerequisites = (f.prerequisites + " " + text).strip()
190 + elif role == "price":
191 + if not f.price_label:
192 + f.price_label = text
193 + f.details.setdefault("cout_details", text)
194 + elif role == "duration":
195 + m = re.search(r"\d+\s*h(?:\s*\d{1,2})?\b|\d+\s*min\w*", text)
196 + if m and not f.duration:
197 + f.duration = m.group(0)
198 + elif role == "modalites":
199 + f.details.setdefault("modalites", []).extend(items or [text])
200 + if re.search(r"attestation", text, re.I):
201 + f.credential = "Attestation de participation"
202 + elif role == "audience":
203 + f.audience = clean_text(f"{f.audience} {text}").strip()
204 + elif role == "":
205 + # « Cette formation s'adresse : … » (paragraphe ou bloc dédié)
206 + m = re.search(r"s[’']adresse\s*:?", text)
207 + if m:
208 + rest = clean_text(text[m.end():])
209 + if rest:
210 + f.audience = clean_text(f"{f.audience} {rest}").strip()
211 + else:
212 + role = "audience"
213 + continue
214 + desc_parts += items or [text]
215 + f.description = "\n\n".join(desc_parts)
216 +
217 + # 3) séances datées par ville
218 + sessions, cities = [], []
219 + for it in soup.find_all(class_="activity-dates-block__date-item"):
220 + t = it.find(class_="activity-dates-block__date-item__title")
221 + iso = parse_date_fr(clean_text(t.get_text(" "))) if t else None
222 + if iso:
223 + sessions.append(iso)
224 + city = clean_text(it.get("data-location", ""))
225 + if city and city not in cities:
226 + cities.append(city)
227 + if sessions:
228 + f.sessions = sorted(set(sessions))
229 + f.start_date = f.sessions[0]
230 + if cities:
231 + f.city = cities[0]
232 + if len(cities) > 1:
233 + f.details["villes"] = cities
added formaka/connectors/lesaffaires.py +208 −0
@@ -0,0 +1,208 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/lesaffaires.py : connecteur Événements Les Affaires
5 +# (evenements.lesaffaires.com) — conférences, formations et webinaires
6 +# d'affaires du média Les Affaires (Montréal/Québec/en ligne).
7 +# Boutique Shopify : l'API publique /products.json expose TOUT le catalogue
8 +# (un produit = un événement) avec des tags très structurés :
9 +# - Format_Conférence / Formation / Webinaire / Démos du midi…
10 +# - Thématique_Technologies de l'information / Finances / RH…
11 +# - start-date_/end-date_AAAA-MM-JJ HH:MM:SS, Emplacement_Montréal/En ligne,
12 +# Expert_Prénom Nom (conférenciers), edition-Gratuit, « En différé ».
13 +# Prix par variante (Présentiel / Virtuel), description dans body_html.
14 +# Aucune page détail nécessaire : la liste JSON contient tout.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import re
19 +
20 +from bs4 import BeautifulSoup
21 +
22 +from ..schema import Formation, clean_text
23 +from .base import BaseConnector
24 +
25 +BASE = "https://evenements.lesaffaires.com"
26 +PRODUCTS_URL = f"{BASE}/products.json"
27 +
28 +# préfixe de date dans les titres : « 2026-11-19***Ambition »
29 +# (parfois double : « 2026-12-09 & 2026-12-10***Masterclass Défense »)
30 +_TITLE_DATE_RE = re.compile(r"^\s*20\d{2}-\d{2}-\d{2}[^*]*\*+\s*")
31 +_DATETIME_RE = re.compile(r"(20\d{2}-\d{2}-\d{2})[ T](\d{2}:\d{2})")
32 +
33 +# tag Format_… -> type de formation canonique Forma-Ka
34 +_FORMAT_MAP = {
35 + "conférence": "Conférence",
36 + "inédit : souper-conférence": "Conférence",
37 + "salon": "Conférence",
38 + "webinaire": "Webinaire",
39 + "démos du midi": "Webinaire",
40 + "formation": "Formation continue",
41 + "formation am": "Formation continue",
42 + "formation pm": "Formation continue",
43 +}
44 +
45 +
46 +def _parse_tags(tags: list[str]) -> dict:
47 + """Décompose les tags Shopify structurés (Format_, Expert_, start-date_…)."""
48 + out: dict = {"experts": [], "formats": [], "themes": [], "flags": []}
49 + for tag in tags:
50 + if "_" in tag:
51 + prefix, value = tag.split("_", 1)
52 + prefix = prefix.strip().lower()
53 + value = value.strip()
54 + if prefix == "format":
55 + out["formats"].append(value)
56 + elif prefix in ("thématique", "thematique"):
57 + out["themes"].append(value)
58 + elif prefix == "expert":
59 + out["experts"].append(value)
60 + elif prefix == "emplacement":
61 + out["emplacement"] = value
62 + elif prefix == "start-date":
63 + out["start"] = value
64 + elif prefix == "end-date":
65 + out["end"] = value
66 + else:
67 + out["flags"].append(tag)
68 + else:
69 + out["flags"].append(tag)
70 + return out
71 +
72 +
73 +class LesAffairesConnector(BaseConnector):
74 + source_id = "lesaffaires"
75 + request_delay = 0.5
76 +
77 + def fetch(self) -> list[Formation]:
78 + # 1) catalogue complet via l'API Shopify (paginée par sécurité)
79 + products: list[dict] = []
80 + page = 1
81 + while True:
82 + resp = self.get(PRODUCTS_URL, params={"limit": 250, "page": page})
83 + batch = resp.json().get("products", [])
84 + products.extend(batch)
85 + if len(batch) < 250:
86 + break
87 + page += 1
88 +
89 + out: list[Formation] = []
90 + for prod in products:
91 + tags = _parse_tags(prod.get("tags") or [])
92 + if "hide" in [f.lower() for f in tags["flags"]]:
93 + continue
94 + f = self._build(prod, tags)
95 + if f is not None:
96 + out.append(f)
97 + return out
98 +
99 + # -- construction d'une formation depuis un produit Shopify ----------------
100 + def _build(self, prod: dict, tags: dict) -> Formation | None:
101 + handle = prod.get("handle") or ""
102 + raw_title = prod.get("title") or ""
103 + if not handle or not raw_title:
104 + return None
105 + title = _TITLE_DATE_RE.sub("", raw_title).strip()
106 +
107 + f = Formation(
108 + source=self.source_id,
109 + external_id=handle,
110 + url=f"{BASE}/products/{handle}",
111 + title=title,
112 + language="fr",
113 + )
114 +
115 + # type d'événement depuis le tag Format_
116 + fmt = (tags["formats"][0] if tags["formats"] else "").lower()
117 + f.training_type = _FORMAT_MAP.get(fmt, "Conférence")
118 + if tags["formats"]:
119 + f.details["format"] = tags["formats"][0]
120 +
121 + # thématique -> catégorie + tags
122 + if tags["themes"]:
123 + f.category = tags["themes"][0]
124 + f.tags = tags["themes"] + tags["formats"]
125 +
126 + # conférenciers / experts
127 + if tags["experts"]:
128 + f.instructor = ", ".join(dict.fromkeys(tags["experts"]))
129 +
130 + # description : body_html du produit
131 + body = prod.get("body_html") or ""
132 + if body:
133 + f.description = BeautifulSoup(body, "html.parser").get_text("\n").strip()
134 +
135 + # dates précises (tags start-date_/end-date_)
136 + start = tags.get("start")
137 + end = tags.get("end")
138 + ms = _DATETIME_RE.search(start or "")
139 + me = _DATETIME_RE.search(end or "")
140 + if ms:
141 + f.start_date = ms.group(1)
142 + f.sessions = [ms.group(1)]
143 + f.schedule_label = f"{ms.group(1)} {ms.group(2)}"
144 + f.details["start_datetime"] = f"{ms.group(1)} {ms.group(2)}"
145 + if me:
146 + f.details["end_datetime"] = f"{me.group(1)} {me.group(2)}"
147 + if ms and me:
148 + if ms.group(1) == me.group(1): # même jour -> durée en heures
149 + h1, m1 = map(int, ms.group(2).split(":"))
150 + h2, m2 = map(int, me.group(2).split(":"))
151 + hours = (h2 * 60 + m2 - h1 * 60 - m1) / 60.0
152 + if hours > 0:
153 + f.duration_hours = round(hours, 2)
154 + f.duration = f"{hours:g} h"
155 + else: # plusieurs jours
156 + import datetime
157 + d1 = datetime.date.fromisoformat(ms.group(1))
158 + d2 = datetime.date.fromisoformat(me.group(1))
159 + days = (d2 - d1).days + 1
160 + f.duration = f"{days} jours"
161 +
162 + # événement en différé (rediffusion à la demande)
163 + if any(fl.lower().startswith("en différé") for fl in tags["flags"]):
164 + f.tags.append("En différé")
165 + f.details["en_differe"] = True
166 +
167 + # mode / ville : Emplacement_ + variantes Présentiel/Virtuel
168 + variants = [v for v in (prod.get("variants") or []) if isinstance(v, dict)]
169 + vtitles = {clean_text(v.get("title", "")).lower() for v in variants}
170 + emplacement = tags.get("emplacement", "")
171 + has_pres = "présentiel" in vtitles
172 + has_virt = "virtuel" in vtitles
173 + if has_pres and has_virt:
174 + f.mode = "hybride"
175 + elif emplacement:
176 + f.mode = "en ligne" if emplacement.lower() == "en ligne" else "présentiel"
177 + if emplacement and emplacement.lower() != "en ligne":
178 + f.city = emplacement
179 +
180 + # prix : minimum des variantes (le 1 $ des gratuits est un artefact)
181 + prices = []
182 + labels = []
183 + for v in variants:
184 + try:
185 + p = float(v.get("price") or 0)
186 + except (TypeError, ValueError):
187 + continue
188 + prices.append(p)
189 + vt = clean_text(v.get("title", ""))
190 + if vt and vt.lower() != "default title":
191 + labels.append(f"{p:g} $ ({vt})")
192 + gratuit = any(fl.lower() == "edition-gratuit" for fl in tags["flags"]) \
193 + or "edition-Gratuit" in (prod.get("tags") or [])
194 + if gratuit or (prices and min(prices) == 0.0):
195 + f.price = 0.0
196 + f.is_free = True
197 + f.price_label = "Gratuit"
198 + elif prices and min(prices) > 1.0: # 1 $ = placeholder Shopify
199 + f.price = min(prices)
200 + f.price_label = " / ".join(labels) if labels else f"{min(prices):g} $"
201 +
202 + # images du produit
203 + for img in prod.get("images") or []:
204 + src = img.get("src") if isinstance(img, dict) else None
205 + if src:
206 + f.images.append(src)
207 +
208 + return f
added formaka/connectors/lewagon_mtl.py +214 −0
@@ -0,0 +1,214 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/lewagon_mtl.py : connecteur Le Wagon — campus de Montréal
5 +# (lewagon.com/fr/montreal) — bootcamps intensifs en développement web /
6 +# IA / data (Web Development, Data Analytics, Data Science, Data Engineering).
7 +# Site Rails rendu serveur (Hotwire/Turbo) :
8 +# - liste : liens /fr/montreal/<slug>-course sur la page campus
9 +# - fiche : HTML riche (h1, og:description, modules du cursus #curriculum,
10 +# pré-requis de la section Admission, « X semaines »)
11 +# - séances : turbo-frame /fr/local_courses/<id>/sections/upcoming_sessions
12 +# (requiert l'en-tête « Turbo-Frame ») — rythme, dates, format campus/en
13 +# ligne, prix en CAD et langue (onglets Anglais/Français) par cohorte.
14 +# Fiches en cache, rafraîchies chaque semaine (clé ISO AAAA-WSS).
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import datetime
19 +import json
20 +import re
21 +
22 +from bs4 import BeautifulSoup
23 +
24 +from ..schema import Formation, clean_text
25 +from .base import BaseConnector
26 +
27 +BASE = "https://www.lewagon.com"
28 +LIST_URL = f"{BASE}/fr/montreal"
29 +
30 +# secours si la page campus change : les 4 formations connues de Montréal
31 +_KNOWN_SLUGS = ["web-development-course", "data-analytics-course",
32 + "data-science-course", "data-engineering-course"]
33 +
34 +_FRAME_RE = re.compile(r"/fr/local_courses/(\d+)/sections/upcoming_sessions")
35 +_PRICE_RE = re.compile(r"([\d][\d\s  ]*)\s*CAD")
36 +_WEEKS_RE = re.compile(r"(\d+)\s*semaines", re.I)
37 +# dates du tableau des séances : « oct. 12, 2026 » (mois abrégé français)
38 +_SESSION_DATE_RE = re.compile(r"([a-zûé]+)\.?\s+(\d{1,2}),\s*(20\d{2})", re.I)
39 +
40 +_MONTHS_FR = {"jan": 1, "fév": 2, "fev": 2, "mar": 3, "avr": 4, "mai": 5,
41 + "jui": 6, "juin": 6, "juil": 7, "aoû": 8, "aou": 8, "sep": 9,
42 + "oct": 10, "nov": 11, "déc": 12, "dec": 12}
43 +
44 +
45 +def _parse_session_date(text: str) -> str | None:
46 + """« oct. 12, 2026 » -> « 2026-10-12 » (attention : juin/juil)."""
47 + m = _SESSION_DATE_RE.search(text or "")
48 + if not m:
49 + return None
50 + raw = m.group(1).lower()
51 + mo = _MONTHS_FR.get(raw[:4]) or _MONTHS_FR.get(raw[:3])
52 + if not mo:
53 + return None
54 + return f"{int(m.group(3)):04d}-{mo:02d}-{int(m.group(2)):02d}"
55 +
56 +
57 +class LeWagonMtlConnector(BaseConnector):
58 + source_id = "lewagon_mtl"
59 + request_delay = 0.5
60 +
61 + def fetch(self) -> list[Formation]:
62 + # 1) liste des formations offertes au campus de Montréal
63 + html = self.fetch_html(LIST_URL)
64 + slugs = list(dict.fromkeys(
65 + m.rsplit("/", 1)[-1]
66 + for m in re.findall(r'href="/fr/montreal/([\w-]+-course)"', html)))
67 + if not slugs:
68 + slugs = _KNOWN_SLUGS
69 +
70 + # 2) fiche + séances par formation — cache hebdomadaire
71 + week = datetime.date.today().strftime("%G-W%V")
72 + out: list[Formation] = []
73 + for slug in slugs:
74 + url = f"{BASE}/fr/montreal/{slug}"
75 + payload = self.detail(slug, week, lambda u=url: self._fetch_detail(u))
76 + f = Formation(
77 + source=self.source_id,
78 + external_id=slug,
79 + url=url,
80 + training_type="Bootcamp",
81 + category="Informatique",
82 + city="Montréal",
83 + credential="Certificat Le Wagon",
84 + )
85 + for k, v in (payload or {}).items():
86 + if hasattr(f, k) and v not in (None, "", []):
87 + setattr(f, k, v)
88 + out.append(f)
89 + return out
90 +
91 + # -- fiche ----------------------------------------------------------------
92 + def _fetch_detail(self, url: str) -> dict:
93 + html = self.fetch_html(url)
94 + soup = BeautifulSoup(html, "html.parser")
95 + payload: dict = {}
96 +
97 + # titre : og:title (« Bootcamp Développement Web à Montréal | Le Wagon »)
98 + og = soup.find("meta", property="og:title")
99 + title = (og.get("content", "") if og else "").split("|")[0]
100 + if not title:
101 + h1 = soup.find("h1")
102 + title = h1.get_text(" ", strip=True) if h1 else ""
103 + payload["title"] = clean_text(title)
104 +
105 + # description : og:description + phrase d'accroche du héros
106 + ogd = soup.find("meta", property="og:description")
107 + if ogd and ogd.get("content"):
108 + payload["description"] = clean_text(ogd["content"])
109 +
110 + # durée : « Formez-vous en 9 semaines… »
111 + m = _WEEKS_RE.search(html)
112 + if m:
113 + payload["duration"] = f"{m.group(1)} semaines (temps plein)"
114 +
115 + # plan de cours : modules de la section #curriculum
116 + cur = soup.find(id="curriculum")
117 + if cur:
118 + program = []
119 + for h in cur.find_all("h3"):
120 + txt = clean_text(h.get_text(" "))
121 + if (txt and not txt.endswith(":") and txt not in program
122 + and not _WEEKS_RE.search(txt)): # titre de section
123 + program.append(txt)
124 + if program:
125 + payload["program"] = program
126 +
127 + # pré-requis : bloc « Pré-requis » de la section Admission
128 + pre = soup.find(string=lambda s: s and s.strip().lower()
129 + in ("pré-requis", "prérequis"))
130 + if pre:
131 + for p in pre.find_parent().find_all_next("p", limit=5):
132 + txt = clean_text(p.get_text(" "))
133 + if (len(txt) < 60 or txt.lower().startswith("en france")
134 + or txt.lower().startswith("découvrez")):
135 + continue # bruit / mention hors Québec
136 + payload["prerequisites"] = txt
137 + break
138 +
139 + # séances : turbo-frame des prochaines sessions
140 + mf = _FRAME_RE.search(html)
141 + if mf:
142 + payload.update(self._fetch_sessions(mf.group(0)))
143 + return payload
144 +
145 + # -- séances (turbo-frame) --------------------------------------------------
146 + def _fetch_sessions(self, frame_path: str) -> dict:
147 + try:
148 + resp = self.get(f"{BASE}{frame_path}",
149 + headers={"Turbo-Frame": "local_course_upcoming_sessions"})
150 + except Exception:
151 + return {}
152 + soup = BeautifulSoup(resp.text, "html.parser")
153 +
154 + sessions: list[dict] = []
155 + seen: set = set()
156 + for pane_id, lang in (("en-tab-pane", "en"), ("fr-tab-pane", "fr")):
157 + pane = soup.find(id=pane_id)
158 + if pane is None:
159 + continue
160 + for art in pane.find_all("article", class_="upcoming-sessions-grid"):
161 + cells = [clean_text(d.get_text(" "))
162 + for d in art.find_all("div", recursive=False)]
163 + if len(cells) < 4:
164 + continue
165 + pace, dates, fmt, price_txt = cells[0], cells[1], cells[2], cells[3]
166 + # métadonnées GTM du bouton (batch_id, format, campus/en ligne)
167 + gtm = {}
168 + btn = art.find("a", attrs={"data-gtm-data-layer-attributes-value": True})
169 + if btn:
170 + try:
171 + gtm = json.loads(btn["data-gtm-data-layer-attributes-value"])
172 + except (ValueError, KeyError):
173 + gtm = {}
174 + key = (gtm.get("batch_id"), lang) if gtm.get("batch_id") \
175 + else (pace, dates, lang)
176 + if key in seen:
177 + continue
178 + seen.add(key)
179 + found = _SESSION_DATE_RE.findall(dates)
180 + start = _parse_session_date(dates)
181 + end = _parse_session_date(dates[dates.find("->") + 2:]) \
182 + if "->" in dates else (
183 + _parse_session_date(" ".join(
184 + f"{a} {b}, {c}" for a, b, c in found[1:2])) or None)
185 + mprice = _PRICE_RE.search(price_txt)
186 + sessions.append({
187 + "rythme": pace,
188 + "debut": start,
189 + "fin": end,
190 + "format": fmt,
191 + "langue": lang,
192 + "prix": float(re.sub(r"[\s  ]", "", mprice.group(1)))
193 + if mprice else None,
194 + "batch_id": gtm.get("batch_id"),
195 + })
196 +
197 + if not sessions:
198 + return {}
199 + payload: dict = {"details": {"cohortes": sessions}}
200 + starts = sorted({s["debut"] for s in sessions if s["debut"]})
201 + if starts:
202 + payload["sessions"] = starts
203 + payload["start_date"] = starts[0]
204 + payload["schedule_label"] = "Rentrées : " + ", ".join(starts)
205 + modes = {("en ligne" if "ligne" in s["format"].lower() else "présentiel")
206 + for s in sessions}
207 + payload["mode"] = modes.pop() if len(modes) == 1 else "hybride"
208 + langs = {s["langue"] for s in sessions}
209 + payload["language"] = "fr/en" if len(langs) > 1 else langs.pop()
210 + prices = [s["prix"] for s in sessions if s["prix"]]
211 + if prices:
212 + payload["price"] = min(prices)
213 + payload["price_label"] = f"{min(prices):,.0f} CAD".replace(",", " ")
214 + return payload
added formaka/connectors/mcgill_scs.py +269 −0
@@ -0,0 +1,269 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/mcgill_scs.py : connecteur McGill School of Continuing Studies
5 +# (continuingstudies.mcgill.ca). Formation continue de l'Université McGill :
6 +# cours de certificats de perfectionnement professionnel (PDC), ateliers
7 +# publics, cours en ligne, webinaires… (~200-300 activités).
8 +# Le site vitrine (www.mcgill.ca/continuingstudies) est derrière un WAF
9 +# Azure, mais le portail d'inscription Destiny One est librement accessible :
10 +# - liste : recherche avancée /search/publicCourseAdvancedSearch.do
11 +# interrogée catégorie par catégorie (avec pagination de session
12 +# « displaytag ») — titre, sigle, lieu, formats de diffusion
13 +# - fiche : /search/publicCourseSearchDetails.do?courseId=… — Description,
14 +# Topics Covered, Learning Outcomes, Who Should Attend?, Course Fee,
15 +# Duration (hours), CEU et programmes auxquels le cours contribue.
16 +# Fiches en cache, rafraîchies chaque semaine (clé ISO « AAAA-WSS »).
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import datetime
21 +import re
22 +from urllib.parse import urlencode
23 +
24 +from bs4 import BeautifulSoup
25 +
26 +from ..schema import Formation, clean_text
27 +
28 +from .base import BaseConnector
29 +
30 +BASE = "https://continuingstudies.mcgill.ca"
31 +SEARCH_URL = f"{BASE}/search/publicCourseAdvancedSearch.do"
32 +DETAIL_URL = f"{BASE}/search/publicCourseSearchDetails.do"
33 +
34 +# catégories Destiny One de type « formation » -> type de formation Forma-Ka
35 +# (les catégories de tests de classement, services, séances d'information…
36 +# sont volontairement exclues)
37 +CATEGORIES = {
38 + "21539211": ("Transcript PDC Course", "Formation continue"),
39 + "21539210": ("Transcript Non-PDC Course", "Formation continue"),
40 + "21539205": ("Public Workshop", "Atelier"),
41 + "22158260": ("Short Program Course", "Formation continue"),
42 + "21539087": ("Asynchronous Online Course", "Cours en ligne"),
43 + "17116": ("Courses", "Formation continue"),
44 + "17117": ("Online courses", "Cours en ligne"),
45 + "17114": ("Workshops", "Atelier"),
46 + "17120": ("Intensive courses", "Formation continue"),
47 + "17119": ("Summer courses", "Formation continue"),
48 + "17122": ("Lecture Series", "Conférence"),
49 + "17124": ("Webinars", "Webinaire"),
50 +}
51 +
52 +_FEE_RE = re.compile(r"Course Fee\s*:?\s*\$?\s*([\d,]+(?:\.\d{2})?)", re.I)
53 +_HOURS_RE = re.compile(r"Duration\s*\(hours\)\s*:?\s*(\d+(?:\.\d+)?)", re.I)
54 +_CEU_RE = re.compile(r"(\d+(?:\.\d+)?)\s*Continuing Education Units?", re.I)
55 +
56 +# formats de diffusion Destiny -> mode canonique Forma-Ka
57 +_MODE_MAP = [
58 + (re.compile(r"blended", re.I), "hybride"),
59 + (re.compile(r"asynchronous|self[- ]paced", re.I), "asynchrone"),
60 + (re.compile(r"online|remote|virtual", re.I), "en ligne"),
61 + (re.compile(r"in[- ]class|in[- ]person|classroom", re.I), "présentiel"),
62 +]
63 +
64 +
65 +def _mode_from(formats: list[str]) -> str:
66 + modes = []
67 + for fmt in formats:
68 + for rx, canon in _MODE_MAP:
69 + if rx.search(fmt):
70 + modes.append(canon)
71 + break
72 + modes = list(dict.fromkeys(modes))
73 + if not modes:
74 + return ""
75 + if len(modes) == 1:
76 + return modes[0]
77 + return "hybride" # plusieurs formats offerts (en classe et en ligne)
78 +
79 +
80 +def _section_content(h2) -> tuple[list[str], list[str]]:
81 + """(paragraphes, items) qui suivent un titre h2 de la fiche Destiny."""
82 + paras: list[str] = []
83 + items: list[str] = []
84 + for sib in h2.find_next_siblings():
85 + if sib.name in ("h1", "h2"):
86 + break
87 + if sib.name in ("ul", "ol"):
88 + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")]
89 + elif sib.name == "p":
90 + paras.append(clean_text(sib.get_text(" ")))
91 + elif sib.name == "div":
92 + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")]
93 + paras += [clean_text(p.get_text(" ")) for p in sib.find_all("p")]
94 + if not paras and not items:
95 + # certains gabarits mettent le texte directement dans le conteneur du h2
96 + # (ex. div#courseProfileRecommendedPrerequisites)
97 + parent = h2.parent
98 + if parent is not None:
99 + items = [clean_text(li.get_text(" ")) for li in parent.find_all("li")]
100 + txt = clean_text(parent.get_text(" "))
101 + txt = re.sub(r"^" + re.escape(clean_text(h2.get_text(" "))) + r"\s*",
102 + "", txt)
103 + for i in items:
104 + txt = txt.replace(i, "")
105 + txt = clean_text(txt)
106 + if txt:
107 + paras = [txt]
108 + return [p for p in paras if p], [i for i in items if i]
109 +
110 +
111 +class McgillScsConnector(BaseConnector):
112 + source_id = "mcgill_scs"
113 + request_delay = 0.6
114 +
115 + def fetch(self) -> list[Formation]:
116 + # amorce de session Destiny (cookie JSESSIONID pour la pagination)
117 + self.get(f"{SEARCH_URL}?method=load")
118 +
119 + # 1) Liste : une recherche par catégorie, pagination de session
120 + found: dict[str, dict] = {} # courseId -> infos de la liste
121 + for cat_id, (cat_label, ttype) in CATEGORIES.items():
122 + params = {
123 + "method": "doPaginatedSearch",
124 + "showInternal": "false",
125 + "courseSearch.courseCategoryStringArray": cat_id,
126 + "courseSearch.filterString": "all",
127 + }
128 + html = self.get(f"{SEARCH_URL}?{urlencode(params)}").text
129 + pages = [html]
130 + # liens « displaytag » de pagination (d-XXXX-p=N), propres à la session
131 + page_links = sorted(set(re.findall(
132 + r'href="(/search/publicCourseAdvancedSearch\.do\?method=doPagination'
133 + r'[^"]*?-p=(\d+))"', html)), key=lambda t: int(t[1]))
134 + for link, _n in page_links:
135 + pages.append(self.get(BASE + link.replace("&amp;", "&")).text)
136 +
137 + for page_html in pages:
138 + for row in self._parse_rows(page_html):
139 + cid = row["course_id"]
140 + if cid not in found:
141 + row["category_label"] = cat_label
142 + row["training_type"] = ttype
143 + found[cid] = row
144 +
145 + # 2) Fiche détaillée par cours — cache hebdomadaire
146 + week = datetime.date.today().strftime("%G-W%V")
147 + out: list[Formation] = []
148 + for cid, row in found.items():
149 + url = f"{DETAIL_URL}?method=load&courseId={cid}"
150 + key = f"{week}:{row['title']}"
151 + payload = self.detail(cid, key, lambda u=url: self._fetch_detail(u))
152 + f = Formation(
153 + source=self.source_id,
154 + external_id=cid,
155 + url=url,
156 + title=row["title"],
157 + training_type=row["training_type"],
158 + language="en",
159 + code=row.get("code", ""),
160 + mode=_mode_from(row.get("formats", [])),
161 + )
162 + if row.get("formats"):
163 + f.details["formats_offerts"] = row["formats"]
164 + f.details["categorie_scs"] = row["category_label"]
165 + if row.get("location"):
166 + f.details["lieu"] = row["location"]
167 + if "montreal" in row["location"].lower():
168 + f.city = "Montréal"
169 + if row.get("availability"):
170 + f.details["disponibilite"] = row["availability"]
171 + for k, v in (payload or {}).items():
172 + if k == "details":
173 + f.details = {**f.details, **v}
174 + elif hasattr(f, k) and v not in (None, "", []):
175 + setattr(f, k, v)
176 + out.append(f)
177 + return out
178 +
179 + # -- liste ------------------------------------------------------------------
180 + @staticmethod
181 + def _parse_rows(html: str) -> list[dict]:
182 + soup = BeautifulSoup(html, "html.parser")
183 + rows: list[dict] = []
184 + for td in soup.select("td.firstColumn"):
185 + a = td.select_one(".courseName a[href]")
186 + if a is None:
187 + continue
188 + m = re.search(r"courseId=(\d+)", a["href"])
189 + if not m:
190 + continue
191 + tr = td.find_parent("tr")
192 + code_el = td.select_one(".courseCode")
193 + tds = tr.find_all("td") if tr else []
194 + location = clean_text(tds[1].get_text(" ")) if len(tds) > 1 else ""
195 + formats = [clean_text(x.get_text(" "))
196 + for x in tr.select(".courseDelivery a")] if tr else []
197 + avail_el = tr.select_one(".courseAvailability") if tr else None
198 + rows.append({
199 + "course_id": m.group(1),
200 + "title": clean_text(a.get_text(" ")),
201 + "code": clean_text(code_el.get_text(" ")) if code_el else "",
202 + "location": re.sub(r"\s*,\s*", ", ", location),
203 + "formats": [f for f in formats if f],
204 + "availability": clean_text(avail_el.get_text(" ")) if avail_el else "",
205 + })
206 + return rows
207 +
208 + # -- fiche ------------------------------------------------------------------
209 + def _fetch_detail(self, url: str) -> dict:
210 + html = self.fetch_html(url)
211 + soup = BeautifulSoup(html, "html.parser")
212 + payload: dict = {}
213 + details: dict = {}
214 +
215 + for h2 in soup.find_all("h2"):
216 + heading = clean_text(h2.get_text(" ")).lower()
217 + if heading == "description":
218 + paras, items = _section_content(h2)
219 + if paras:
220 + desc = "\n\n".join(paras)
221 + payload["description"] = re.sub(
222 + r"^Official Description\s*", "", desc)
223 + ceu = _CEU_RE.search(" ".join(paras))
224 + if ceu:
225 + payload["credits"] = f"{ceu.group(1)} CEU"
226 + elif heading.startswith("topics covered"):
227 + _, items = _section_content(h2)
228 + if items:
229 + payload["program"] = items
230 + elif heading.startswith("learning outcomes"):
231 + paras, items = _section_content(h2)
232 + objs = [o for o in (items or paras) if not o.endswith(":")]
233 + if objs:
234 + payload["objectives"] = objs
235 + elif heading.startswith("who should attend"):
236 + paras, items = _section_content(h2)
237 + aud = " ".join(paras) or "; ".join(items)
238 + if aud:
239 + payload["audience"] = aud
240 + elif heading.startswith("applies towards"):
241 + ul = h2.find_next("ul")
242 + progs = [clean_text(a.get_text(" "))
243 + for a in (ul.find_all("a") if ul else [])]
244 + progs = list(dict.fromkeys(p for p in progs if p))
245 + if progs:
246 + details["programmes_lies"] = progs
247 + # catégorie lisible : « PDC in Business Analysis » -> sujet
248 + cat = re.sub(r"^(Professional Development |Graduate )?"
249 + r"Certificate in ", "", progs[0]).strip(" :")
250 + if cat:
251 + payload["category"] = cat
252 +
253 + body_txt = clean_text(soup.get_text(" "))
254 + m = _FEE_RE.search(body_txt)
255 + if m:
256 + payload["price"] = float(m.group(1).replace(",", ""))
257 + payload["price_label"] = f"Course Fee: ${m.group(1)}"
258 + m = _HOURS_RE.search(body_txt)
259 + if m:
260 + payload["duration_hours"] = float(m.group(1))
261 + payload["duration"] = f"{m.group(1)} hours"
262 + m = re.search(r"Discounts?\s*:\s*(.{10,220}?)(?:Thank you|Required fields|$)",
263 + body_txt)
264 + if m:
265 + details["rabais"] = clean_text(m.group(1))
266 +
267 + if details:
268 + payload["details"] = details
269 + return payload
added formaka/connectors/technologia.py +228 −0
@@ -0,0 +1,228 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/technologia.py : connecteur Technologia (technologia.com)
5 +# Grande firme de formation professionnelle (Montréal/Québec) — ~600
6 +# formations en TI, IA, gestion de projets, leadership, manufacturier…
7 +# Site Umbraco + Vue, mais très bien outillé côté données :
8 +# - liste : API JSON interne /Api/Catalog/Browse (code, titre, thématique,
9 +# sous-thématique, durée en jours ou en heures, prix régulier et
10 +# préférentiel, formats ClasseVirtuelle/ELearning/EnClasse)
11 +# - fiche : JSON complet embarqué (<script id="training-data"> : sommaire,
12 +# objectif, plan de cours « syllabusHtml », clientèle visée, préalables,
13 +# méthode pédagogique, formateurs) + EducationEvent JSON-LD (séances
14 +# datées avec villes). Fiches en cache, rafraîchies chaque semaine.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import datetime
19 +import json
20 +import re
21 +
22 +from bs4 import BeautifulSoup
23 +
24 +from ..schema import Formation, clean_text
25 +from .base import BaseConnector
26 +
27 +BASE = "https://www.technologia.com"
28 +API_BROWSE = f"{BASE}/Api/Catalog/Browse"
29 +LIST_URL = f"{BASE}/formations"
30 +
31 +# le JSON riche embarqué dans chaque fiche
32 +_TRAINING_DATA_RE = re.compile(
33 + r'<script id="training-data" type="application/json">(.*?)</script>', re.S)
34 +# les scripts JSON-LD ont un attribut type HTML-encodé (« ld&#x2B;json »)
35 +_LDJSON_RE = re.compile(
36 + r'<script[^>]*type="application/ld(?:\+|&#x2B;)json"[^>]*>(.*?)</script>',
37 + re.S | re.I)
38 +
39 +# formats Technologia -> mode canonique Forma-Ka
40 +_FORMAT_MAP = {
41 + "classevirtuelle": "en ligne",
42 + "elearning": "asynchrone",
43 + "enclasse": "présentiel",
44 +}
45 +
46 +
47 +def _strip_html(fragment: str) -> str:
48 + """Texte propre d'un fragment HTML (paragraphes séparés)."""
49 + if not fragment:
50 + return ""
51 + soup = BeautifulSoup(fragment, "html.parser")
52 + parts = [clean_text(p.get_text(" ")) for p in soup.find_all(["p", "li"])]
53 + parts = [p for p in parts if p]
54 + return "\n".join(dict.fromkeys(parts)) or clean_text(soup.get_text(" "))
55 +
56 +
57 +def _item_mode(formats: list[str] | None) -> tuple[str, list[str]]:
58 + """(mode canonique, tous les modes offerts) depuis les formats de l'API."""
59 + modes = [_FORMAT_MAP[f.lower()] for f in (formats or [])
60 + if f.lower() in _FORMAT_MAP]
61 + modes = list(dict.fromkeys(modes))
62 + if not modes:
63 + return "", []
64 + return (modes[0] if len(modes) == 1 else "hybride"), modes
65 +
66 +
67 +class TechnologiaConnector(BaseConnector):
68 + source_id = "technologia"
69 + request_delay = 0.5
70 + limit: int | None = None # borne optionnelle (tests/débogage)
71 +
72 + def fetch(self) -> list[Formation]:
73 + # 1) Catalogue complet via l'API JSON interne
74 + resp = self.get(API_BROWSE,
75 + params={"Count": 5000, "Page": 1, "Culture": "fr-CA"})
76 + items = resp.json().get("Items") or []
77 + if self.limit:
78 + items = items[: self.limit]
79 +
80 + # 2) Fiche détaillée par formation — cache hebdomadaire
81 + week = datetime.date.today().strftime("%G-W%V")
82 + out: list[Formation] = []
83 + for item in items:
84 + code = item.get("Code") or item.get("ID") or ""
85 + url = BASE + (item.get("Uri") or "")
86 + key = f"{week}:{item.get('Title', '')}:{item.get('RegularPrice')}"
87 + payload = self.detail(code, key, lambda u=url: self._fetch_detail(u))
88 +
89 + days = item.get("Duration")
90 + hours = item.get("DurationInHours")
91 + duration = (f"{hours:g} h" if hours
92 + else f"{days:g} jour{'s' if days and days > 1 else ''}"
93 + if days else "")
94 + price = item.get("RegularPrice")
95 + tags = [t for t in (item.get("SubThematic"),) if t]
96 + if "NouveauCours" in (item.get("Tags") or []):
97 + tags.append("Nouveau")
98 +
99 + f = Formation(
100 + source=self.source_id,
101 + external_id=str(code),
102 + url=url,
103 + title=clean_text(item.get("Title", "")),
104 + training_type="Formation continue",
105 + category=item.get("Thematic", ""),
106 + language="fr",
107 + code=item.get("Code", ""),
108 + duration=duration,
109 + duration_hours=float(hours) if hours else None,
110 + price=float(price) if price is not None else None,
111 + price_label=f"{price:g} $ + tx" if price is not None else "",
112 + tags=tags,
113 + )
114 + mode, modes = _item_mode(item.get("Formats"))
115 + if mode:
116 + f.mode = mode
117 + f.details["modes_offerts"] = modes
118 + if item.get("IsOnDemand"):
119 + f.mode = f.mode or "asynchrone"
120 + pref = item.get("PreferentialPrice")
121 + if pref is not None:
122 + f.details["prix_preferentiel"] = pref
123 + for k, v in (payload or {}).items():
124 + if k == "details":
125 + f.details.update(v or {})
126 + elif hasattr(f, k) and v not in (None, "", []):
127 + setattr(f, k, v)
128 + out.append(f)
129 + return out
130 +
131 + # -- fiche ----------------------------------------------------------------
132 + def _fetch_detail(self, url: str) -> dict:
133 + if not url:
134 + return {}
135 + html = self.fetch_html(url)
136 + payload: dict = {}
137 + details: dict = {}
138 +
139 + m = _TRAINING_DATA_RE.search(html)
140 + if m:
141 + try:
142 + data = json.loads(m.group(1))
143 + except ValueError:
144 + data = {}
145 + # description : sommaire + corps de la fiche
146 + desc = [clean_text(data.get("summary", ""))]
147 + body = BeautifulSoup(data.get("content") or "", "html.parser")
148 + desc += [clean_text(p.get_text(" ")) for p in body.find_all("p")]
149 + payload["description"] = "\n\n".join(
150 + dict.fromkeys(d for d in desc if d and d != "\xa0"))
151 +
152 + # objectifs : objectif général + « Ce que vous saurez faire »
153 + objectives = []
154 + if data.get("goal"):
155 + objectives.append(clean_text(data["goal"]))
156 + arch = BeautifulSoup(data.get("courseArchitecture") or "",
157 + "html.parser")
158 + objectives += [clean_text(p.get_text(" "))
159 + for p in arch.find_all(["p", "li"])]
160 + payload["objectives"] = [o for o in dict.fromkeys(objectives)
161 + if o and o != "\xa0"]
162 +
163 + if data.get("prerequisite"):
164 + payload["prerequisites"] = _strip_html(str(data["prerequisite"]))
165 + if data.get("targetCustomer"):
166 + payload["audience"] = clean_text(str(data["targetCustomer"]))
167 + if data.get("subTitle"):
168 + details["sous_titre"] = clean_text(data["subTitle"])
169 + if data.get("teachingMethod"):
170 + details["methode_pedagogique"] = clean_text(data["teachingMethod"])
171 + if data.get("satisfaction"):
172 + details["satisfaction_pct"] = data["satisfaction"]
173 + if data.get("pduCount"):
174 + details["pdu"] = data["pduCount"]
175 +
176 + # plan de cours : section « Contenu de la formation » du syllabus
177 + syllabus = BeautifulSoup(data.get("syllabusHtml") or "",
178 + "html.parser")
179 + program, in_content = [], False
180 + for h in syllabus.find_all(["h2", "h3"]):
181 + txt = clean_text(h.get_text(" "))
182 + if h.name == "h2":
183 + in_content = bool(re.search(r"contenu", txt, re.I))
184 + elif in_content and txt:
185 + program.append(re.sub(r"^\d+\s*", "", txt))
186 + if not program: # repli : puces du corps de la fiche
187 + program = [clean_text(li.get_text(" "))
188 + for li in body.find_all("li")]
189 + payload["program"] = [p for p in dict.fromkeys(program) if p]
190 +
191 + teachers = [t.get("name") for t in data.get("teachers") or []
192 + if isinstance(t, dict) and t.get("name")]
193 + if teachers:
194 + payload["instructor"] = ", ".join(dict.fromkeys(teachers))
195 + images = [t.get("image") for t in data.get("teachers") or []
196 + if isinstance(t, dict) and t.get("image")]
197 + if images:
198 + payload["images"] = list(dict.fromkeys(images))
199 +
200 + # séances datées + villes via les EducationEvent JSON-LD
201 + sessions, cities = [], []
202 + for mm in _LDJSON_RE.finditer(html):
203 + try:
204 + obj = json.loads(mm.group(1))
205 + except ValueError:
206 + continue
207 + events = obj if isinstance(obj, list) else [obj]
208 + for ev in events:
209 + if not isinstance(ev, dict) or ev.get("@type") != "EducationEvent":
210 + continue
211 + start = str(ev.get("startDate", ""))[:10]
212 + if re.match(r"20\d{2}-\d{2}-\d{2}", start):
213 + sessions.append(start)
214 + loc = ev.get("location") or {}
215 + addr = loc.get("address") if isinstance(loc, dict) else {}
216 + city = (addr.get("addressLocality", "")
217 + if isinstance(addr, dict) else "")
218 + if city and city.lower() != "virtuelle":
219 + cities.append(city)
220 + sessions = sorted(set(sessions))
221 + if sessions:
222 + payload["sessions"] = sessions
223 + payload["start_date"] = sessions[0]
224 + if cities:
225 + payload["city"] = cities[0]
226 + if details:
227 + payload["details"] = details
228 + return payload
added formaka/connectors/teluq.py +187 −0
@@ -0,0 +1,187 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/teluq.py : connecteur Université TÉLUQ (teluq.ca)
5 +# Université publique entièrement à distance — ~540 cours en ligne, tous
6 +# crédités (1er, 2e et 3e cycles), inscription à la carte ou par programme.
7 +# Site rendu serveur, très propre :
8 +# - liste : /etudes/cours — un <li> par cours avec sigle
9 +# (.listing-offre__code), titre (.listing-offre__titre a) et étiquettes
10 +# (département, discipline, cycle, crédits)
11 +# - fiche : sections « En bref » (h3 Objectifs / Contenu / Évaluation /
12 +# Particularités d'inscription…), encadré « Préalables » (.messagebox),
13 +# bloc .info-offre (crédits, cycle, département) et section
14 +# « Responsable » (professeur). Fiches en cache, rafraîchies chaque
15 +# semaine (clé ISO « AAAA-WSS »).
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import datetime
20 +import re
21 +
22 +from bs4 import BeautifulSoup
23 +
24 +from ..schema import Formation, clean_text
25 +from .base import BaseConnector
26 +
27 +BASE = "https://www.teluq.ca"
28 +LIST_URL = f"{BASE}/etudes/cours"
29 +
30 +_CYCLE_RE = re.compile(r"\bcycle\b", re.I)
31 +_CREDITS_RE = re.compile(r"cr[ée]dits?", re.I)
32 +
33 +
34 +def _clean_cycle(txt: str) -> str:
35 + """« 1 er cycle » (espace introduit par le <sup>) -> « 1er cycle »."""
36 + return re.sub(r"(\d)\s+(er|e)\b", r"\1\2", txt)
37 +
38 +
39 +def _section_texts(h3) -> tuple[list[str], list[str]]:
40 + """Contenu (paragraphes, items de liste) qui suit un <h3> de la fiche,
41 + jusqu'au prochain titre de section. Retourne (paragraphes, items)."""
42 + paras: list[str] = []
43 + items: list[str] = []
44 + for sib in h3.find_next_siblings():
45 + if sib.name in ("h1", "h2", "h3"):
46 + break
47 + if sib.name in ("ul", "ol"):
48 + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")]
49 + elif sib.name == "p":
50 + paras.append(clean_text(sib.get_text(" ")))
51 + return [p for p in paras if p], [i for i in items if i]
52 +
53 +
54 +class TeluqConnector(BaseConnector):
55 + source_id = "teluq"
56 + request_delay = 0.5
57 +
58 + def fetch(self) -> list[Formation]:
59 + html = self.fetch_html(LIST_URL)
60 + soup = BeautifulSoup(html, "html.parser")
61 +
62 + # 1) Liste complète : un <li> par cours (sigle + titre + étiquettes)
63 + week = datetime.date.today().strftime("%G-W%V")
64 + out: list[Formation] = []
65 + seen: set[str] = set()
66 + for p_titre in soup.find_all("p", class_="listing-offre__titre"):
67 + a = p_titre.find("a", href=True)
68 + if a is None:
69 + continue
70 + url = a["href"] if a["href"].startswith("http") else BASE + a["href"]
71 + card = p_titre.find_parent("li")
72 + code_el = card.find("p", class_="listing-offre__code") if card else None
73 + code = clean_text(code_el.get_text(" ")) if code_el else ""
74 + external_id = code or url.rstrip("/").rsplit("/", 1)[-1]
75 + if external_id in seen:
76 + continue
77 + seen.add(external_id)
78 +
79 + f = Formation(
80 + source=self.source_id,
81 + external_id=external_id,
82 + url=url,
83 + title=clean_text(a.get_text(" ")),
84 + training_type="Cours universitaire",
85 + mode="en ligne",
86 + language="fr",
87 + code=code,
88 + )
89 +
90 + # étiquettes de la carte : département, discipline, cycle, crédits
91 + for tag in (card.select("ul.tags li.tags__item") if card else []):
92 + txt = clean_text(tag.get_text(" "))
93 + if not txt:
94 + continue
95 + if _CREDITS_RE.search(txt):
96 + f.credits = txt
97 + elif _CYCLE_RE.search(txt):
98 + f.details["cycle"] = _clean_cycle(txt)
99 + elif any(c.startswith("tags--") for c in tag.get("class", [])):
100 + f.details["departement"] = txt
101 + else:
102 + f.category = txt
103 +
104 + # 2) Fiche détaillée — cache hebdomadaire
105 + key = f"{week}:{f.title}"
106 + payload = self.detail(external_id, key,
107 + lambda u=url: self._fetch_detail(u))
108 + for k, v in (payload or {}).items():
109 + if k == "details":
110 + f.details = {**f.details, **v}
111 + elif hasattr(f, k) and v not in (None, "", []):
112 + setattr(f, k, v)
113 + out.append(f)
114 + return out
115 +
116 + # -- fiche ----------------------------------------------------------------
117 + def _fetch_detail(self, url: str) -> dict:
118 + if not url:
119 + return {}
120 + html = self.fetch_html(url)
121 + soup = BeautifulSoup(html, "html.parser")
122 + payload: dict = {}
123 + details: dict = {}
124 +
125 + # bloc .info-offre : crédits, cycle, département
126 + for info in soup.select(".info-offre .info"):
127 + titre = info.find("p", class_="info-titre")
128 + valeur = info.find("p", class_="info-valeur")
129 + if titre is None or valeur is None:
130 + continue
131 + t = clean_text(titre.get_text(" ")).lower()
132 + v = clean_text(valeur.get_text(" "))
133 + if "crédit" in t and v:
134 + if v.isdigit():
135 + payload["credits"] = f"{v} crédit" + ("s" if int(v) > 1 else "")
136 + else:
137 + payload["credits"] = v
138 + elif "cycle" in t:
139 + details["cycle"] = _clean_cycle(v)
140 + elif "département" in t:
141 + details["departement"] = v
142 +
143 + # sections « En bref » : Objectifs, Contenu, Évaluation…
144 + for h3 in soup.find_all("h3"):
145 + heading = clean_text(h3.get_text(" ")).lower()
146 + paras, items = _section_texts(h3)
147 + if heading.startswith("objectifs"):
148 + payload["objectives"] = items or paras
149 + elif heading.startswith("contenu"):
150 + if items:
151 + payload["program"] = items
152 + # certains cours décrivent le contenu en liste plutôt qu'en
153 + # paragraphes — on garde quand même une description lisible
154 + payload["description"] = ("\n\n".join(paras) or
155 + "\n".join(f"– {i}" for i in items))
156 + elif heading.startswith("évaluation"):
157 + if paras:
158 + details["evaluation"] = " ".join(paras)
159 + elif heading.startswith("particularités"):
160 + if paras:
161 + details["particularites_inscription"] = " ".join(paras)
162 +
163 + # encadré « Préalables »
164 + for box in soup.select(".messagebox"):
165 + title = box.find(["h2", "h3"])
166 + if title and re.search(r"pr[ée]alable", title.get_text(), re.I):
167 + txt = clean_text(" ".join(
168 + p.get_text(" ") for p in box.find_all("p")))
169 + if txt:
170 + payload["prerequisites"] = txt
171 +
172 + # section « Responsable » : professeur(s) responsable(s) du cours
173 + resp = soup.find("h2", string=re.compile(r"^\s*Responsables?\s*$", re.I))
174 + if resp:
175 + names = []
176 + for h3 in resp.find_all_next("h3", limit=4):
177 + name = clean_text(h3.get_text(" "))
178 + # on s'arrête dès qu'on retombe sur un titre de section
179 + if not name or re.search(r"inscrire|programme|commencer", name, re.I):
180 + break
181 + names.append(name)
182 + if names:
183 + payload["instructor"] = ", ".join(dict.fromkeys(names))
184 +
185 + if details:
186 + payload["details"] = details
187 + return payload
added formaka/connectors/ulaval_distance.py +202 −0
@@ -0,0 +1,202 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/ulaval_distance.py : connecteur Formation à distance — Université
5 +# Laval (distance.ulaval.ca). ~1 700 cours crédités offerts à distance,
6 +# hybrides ou comodaux (tous cycles, toutes facultés).
7 +# Site rendu serveur, très propre :
8 +# - liste : /etudes/cours — un lien .list__link par cours avec sigle + titre
9 +# (.oe-title), cycle (.oe-cycle) et formule d'enseignement (aria-label)
10 +# - fiche : Description (h2 + paragraphes), Responsables (faculté,
11 +# département), bloc .credits-and-cycles, section Horaire (.sessions :
12 +# sessions offertes, formule, section-groupe, NRC, enseignants) et
13 +# programmes auxquels le cours contribue. Fiches en cache, rafraîchies
14 +# chaque semaine (clé ISO « AAAA-WSS »).
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import datetime
19 +import re
20 +
21 +from bs4 import BeautifulSoup
22 +
23 +from ..schema import Formation, clean_text
24 +
25 +from .base import BaseConnector
26 +
27 +BASE = "https://www.distance.ulaval.ca"
28 +LIST_URL = f"{BASE}/etudes/cours"
29 +
30 +_CODE_TITLE_RE = re.compile(r"^([A-Z]{2,4}-\d{4}[A-Z]?)\s+(.*)$")
31 +_FORMULE_RE = re.compile(r"formules? d'enseignement\s*:\s*(.+)", re.I)
32 +
33 +# formule d'enseignement ULaval -> mode canonique Forma-Ka
34 +_MODE_MAP = [
35 + (re.compile(r"hybride|comodal", re.I), "hybride"),
36 + (re.compile(r"distance|en ligne", re.I), "en ligne"),
37 + (re.compile(r"pr[ée]sentiel|en classe", re.I), "présentiel"),
38 +]
39 +
40 +
41 +def _mode_from(formule: str) -> str:
42 + for rx, canon in _MODE_MAP:
43 + if rx.search(formule or ""):
44 + return canon
45 + return ""
46 +
47 +
48 +class UlavalDistanceConnector(BaseConnector):
49 + source_id = "ulaval_distance"
50 + request_delay = 0.5
51 +
52 + def fetch(self) -> list[Formation]:
53 + html = self.fetch_html(LIST_URL)
54 + soup = BeautifulSoup(html, "html.parser")
55 +
56 + # 1) Liste complète (une seule page rendue serveur, ~1 700 cours)
57 + week = datetime.date.today().strftime("%G-W%V")
58 + out: list[Formation] = []
59 + seen: set[str] = set()
60 + for a in soup.select("a.list__link[href]"):
61 + title_el = a.find(class_="oe-title")
62 + if title_el is None:
63 + continue
64 + raw_title = clean_text(title_el.get_text(" "))
65 + m = _CODE_TITLE_RE.match(raw_title)
66 + code = m.group(1) if m else ""
67 + title = m.group(2) if m else raw_title
68 + href = a["href"]
69 + url = href if href.startswith("http") else BASE + href
70 + external_id = code or url.rstrip("/").rsplit("/", 1)[-1]
71 + if external_id in seen:
72 + continue
73 + seen.add(external_id)
74 +
75 + f = Formation(
76 + source=self.source_id,
77 + external_id=external_id,
78 + url=url,
79 + title=title,
80 + training_type="Cours universitaire",
81 + language="fr",
82 + code=code,
83 + )
84 + cycle_el = a.find(class_="oe-cycle")
85 + if cycle_el:
86 + f.details["cycle"] = clean_text(cycle_el.get_text(" "))
87 + formula_el = a.find(class_="oe-teaching-formulas")
88 + fm = _FORMULE_RE.search(formula_el.get("aria-label", "")) \
89 + if formula_el else None
90 + if fm:
91 + formule = clean_text(fm.group(1))
92 + f.details["formule_enseignement"] = formule
93 + f.mode = _mode_from(formule)
94 +
95 + # 2) Fiche détaillée — cache hebdomadaire
96 + key = f"{week}:{raw_title}"
97 + payload = self.detail(external_id, key,
98 + lambda u=url: self._fetch_detail(u))
99 + for k, v in (payload or {}).items():
100 + if k == "details":
101 + f.details = {**f.details, **v}
102 + elif hasattr(f, k) and v not in (None, "", []):
103 + setattr(f, k, v)
104 + out.append(f)
105 + return out
106 +
107 + # -- fiche ----------------------------------------------------------------
108 + def _fetch_detail(self, url: str) -> dict:
109 + html = self.fetch_html(url)
110 + soup = BeautifulSoup(html, "html.parser")
111 + payload: dict = {}
112 + details: dict = {}
113 +
114 + # description : paragraphes qui suivent le h2 « Description »
115 + h2 = soup.find("h2", string=re.compile(r"^\s*Description\s*$", re.I))
116 + if h2:
117 + paras = []
118 + for sib in h2.find_next_siblings():
119 + if sib.name in ("h1", "h2", "h3"):
120 + break
121 + if sib.name == "p":
122 + t = clean_text(sib.get_text(" "))
123 + # on écarte le lien « Consulter la description officielle… »
124 + if t and not t.lower().startswith("consulter la description"):
125 + paras.append(t)
126 + if paras:
127 + payload["description"] = "\n\n".join(paras)
128 +
129 + # crédits et cycle (bloc latéral)
130 + cred = soup.select_one(".credits-and-cycles .credits")
131 + if cred:
132 + n = clean_text(cred.find("p").get_text(" ")) if cred.find("p") else ""
133 + if n:
134 + if n.isdigit():
135 + payload["credits"] = f"{n} crédit" + ("s" if int(n) > 1 else "")
136 + else:
137 + payload["credits"] = n
138 + cyc = soup.select_one(".credits-and-cycles .cycles")
139 + if cyc:
140 + details["cycle"] = clean_text(cyc.get_text(" "))
141 +
142 + # responsables : faculté (-> catégorie) et département
143 + resp = soup.find("h2", string=re.compile(r"^\s*Responsables?\s*$", re.I))
144 + if resp:
145 + block = resp.find_parent(["section", "div"])
146 + txt = clean_text(block.get_text(" ")) if block else ""
147 + m = re.search(r"Faculté\s*:\s*([^:]+?)(?:\s+Courriel|\s+Département|$)", txt)
148 + if m:
149 + payload["category"] = clean_text(m.group(1))
150 + m = re.search(r"Département\s*:\s*([^:]+?)(?:\s+Courriel|$)", txt)
151 + if m:
152 + details["departement"] = clean_text(m.group(1))
153 +
154 + # horaire : sessions offertes, formules, sections-groupes, enseignants
155 + sched = soup.select_one(".schedule .sessions")
156 + if sched:
157 + sessions: list[dict] = []
158 + instructors: list[str] = []
159 + current: dict | None = None
160 + for el in sched.find_all(["h3", "div"], recursive=False):
161 + if el.name == "h3":
162 + current = {"session": clean_text(el.get_text(" ")),
163 + "sections": []}
164 + sessions.append(current)
165 + elif current is not None and "session" in (el.get("class") or []):
166 + sec: dict = {}
167 + formule = el.select_one(".teaching-formulas .text")
168 + if formule:
169 + sec["formule"] = clean_text(formule.get_text(" "))
170 + sigle = el.select_one(".sigle-tag")
171 + if sigle:
172 + sec["groupe"] = clean_text(sigle.get_text(" "))
173 + nrc = el.select_one(".c-tag")
174 + if nrc:
175 + sec["nrc"] = clean_text(nrc.get_text(" "))
176 + ens = el.find("p")
177 + if ens:
178 + names = clean_text(ens.get_text(" "))
179 + names = re.sub(r"^Enseignant\(e\)s?\s*:\s*", "", names)
180 + if names:
181 + sec["enseignants"] = names
182 + instructors.append(names)
183 + current["sections"].append(sec)
184 + if sessions:
185 + details["sessions_offertes"] = sessions
186 + payload["schedule_label"] = ", ".join(s["session"] for s in sessions)
187 + if instructors:
188 + payload["instructor"] = ", ".join(dict.fromkeys(instructors))
189 +
190 + # programmes auxquels le cours contribue
191 + contrib = soup.find("h2", string=re.compile(r"contributoire", re.I))
192 + if contrib:
193 + block = contrib.find_parent(["section", "div"])
194 + progs = [clean_text(a.get_text(" ")) for a in block.find_all("a")] \
195 + if block else []
196 + progs = [p for p in progs if p]
197 + if progs:
198 + details["programmes_lies"] = progs
199 +
200 + if details:
201 + payload["details"] = details
202 + return payload
added formaka/connectors/uqam_perfectionnement.py +225 −0
@@ -0,0 +1,225 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/uqam_perfectionnement.py : connecteur Formation continue UQAM
5 +# (formation.uqam.ca — remplace l'ancien perfectionnement.uqam.ca, dont le
6 +# domaine ne résout plus). ~50 formations publiques courtes (6-14 h) avec
7 +# UEC, animées par le corps enseignant de l'UQAM.
8 +# Site WordPress (WooCommerce + LMS) rendu serveur :
9 +# - liste : /formations/ — grille de liens /courses/<slug>/
10 +# - fiche : h1 titre, h2 sous-titre, encadré Tarif, liste « Modalités »
11 +# (Durée, Horaire, Mode de diffusion, UEC), sections h3 (Objectifs,
12 +# Principaux éléments de contenu, Approches pédagogiques, Évaluation des
13 +# apprentissages), « Prochaine séance » (dates) et biographie de la
14 +# personne formatrice. Fiches en cache, rafraîchies chaque semaine
15 +# (clé ISO « AAAA-WSS »).
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import datetime
20 +import re
21 +
22 +from bs4 import BeautifulSoup
23 +
24 +from ..schema import Formation, clean_text, parse_date_fr
25 +
26 +from .base import BaseConnector
27 +
28 +BASE = "https://formation.uqam.ca"
29 +LIST_URL = f"{BASE}/formations/"
30 +
31 +_COURSE_HREF_RE = re.compile(r"https?://formation\.uqam\.ca/courses/([^/?#]+)/?")
32 +_UEC_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*unité", re.I)
33 +_BIO_RE = re.compile(r"Biographie\s+(?:de\s+la|du|de\s+l['’])\s*"
34 + r"(?:formatrice|formateur|personne formatrice)?\s*:?\s*(.*)", re.I)
35 +_DATE_LINE_RE = re.compile(r"\b(1er|\d{1,2})\s+[a-zéûôî]+\.?\s+20\d{2}", re.I)
36 +
37 +# sections h3 de la fiche -> champ Formation
38 +_SECTIONS = [
39 + (re.compile(r"^objectifs?", re.I), "objectives"),
40 + (re.compile(r"principaux [ée]l[ée]ments de contenu|^contenu", re.I), "program"),
41 + (re.compile(r"approches? p[ée]dagogiques?", re.I), "_approches"),
42 + (re.compile(r"[ée]valuation des apprentissages", re.I), "_evaluation"),
43 + (re.compile(r"client[èe]le", re.I), "audience"),
44 +]
45 +
46 +
47 +def _section_content(h3) -> tuple[list[str], list[str]]:
48 + """(paragraphes, items) qui suivent un titre de section, jusqu'au suivant."""
49 + paras: list[str] = []
50 + items: list[str] = []
51 + for sib in h3.find_next_siblings():
52 + if sib.name in ("h1", "h2", "h3"):
53 + break
54 + if sib.name in ("ul", "ol"):
55 + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")]
56 + elif sib.name == "p":
57 + paras.append(clean_text(sib.get_text(" ")))
58 + return [p for p in paras if p], [i for i in items if i]
59 +
60 +
61 +class UqamPerfectionnementConnector(BaseConnector):
62 + source_id = "uqam_perfectionnement"
63 + request_delay = 0.6
64 +
65 + def fetch(self) -> list[Formation]:
66 + html = self.fetch_html(LIST_URL)
67 + soup = BeautifulSoup(html, "html.parser")
68 +
69 + # 1) Liste : liens /courses/<slug>/ de la grille (dédupliqués)
70 + cards: dict[str, str] = {} # slug -> titre affiché
71 + for a in soup.find_all("a", href=_COURSE_HREF_RE):
72 + slug = _COURSE_HREF_RE.search(a["href"]).group(1)
73 + title = clean_text(a.get_text(" "))
74 + if slug not in cards or (title and not cards[slug]):
75 + cards[slug] = title
76 +
77 + # 2) Fiche détaillée par formation — cache hebdomadaire
78 + week = datetime.date.today().strftime("%G-W%V")
79 + out: list[Formation] = []
80 + for slug, title in cards.items():
81 + url = f"{BASE}/courses/{slug}/"
82 + payload = self.detail(slug, f"{week}:{title}",
83 + lambda u=url: self._fetch_detail(u))
84 + f = Formation(
85 + source=self.source_id,
86 + external_id=slug,
87 + url=url,
88 + title=title,
89 + training_type="Formation continue",
90 + language="fr",
91 + )
92 + for k, v in (payload or {}).items():
93 + if k == "details":
94 + f.details = {**f.details, **v}
95 + elif hasattr(f, k) and v not in (None, "", []):
96 + setattr(f, k, v)
97 + out.append(f)
98 + return out
99 +
100 + # -- fiche ----------------------------------------------------------------
101 + def _fetch_detail(self, url: str) -> dict:
102 + html = self.fetch_html(url)
103 + soup = BeautifulSoup(html, "html.parser")
104 + payload: dict = {}
105 + details: dict = {}
106 +
107 + h1 = soup.find("h1")
108 + if h1:
109 + payload["title"] = clean_text(h1.get_text(" "))
110 +
111 + # tarif (WooCommerce) : « Tarif » suivi du montant
112 + tarif = soup.find(string=re.compile(r"^\s*Tarif\s*$"))
113 + if tarif:
114 + block = tarif.find_parent(["div", "p", "li", "td"])
115 + label = clean_text(block.get_text(" ")) if block else ""
116 + label = re.sub(r"^Tarif\s*", "", label)
117 + if label:
118 + label = label.replace("$CA", "$").strip()
119 + payload["price_label"] = label
120 + # produit à prix variable (« 26,09 $ – 490 $ ») : le premier
121 + # montant est un acompte — le vrai tarif est le plus élevé
122 + amounts = [float(a.replace(",", "").replace(" ", ""))
123 + for a in re.findall(r"\$\s*([\d, ]+(?:\.\d{2})?)", label)]
124 + if amounts:
125 + payload["price"] = max(amounts)
126 +
127 + # liste « Modalités » : Durée, Horaire, Mode de diffusion, UEC…
128 + h_mod = soup.find(["h2", "h3"], string=re.compile(r"^\s*Modalit[ée]s?", re.I))
129 + desc_start = None
130 + if h_mod:
131 + for sib in h_mod.find_next_siblings():
132 + if sib.name in ("h1", "h2", "h3"):
133 + break
134 + if sib.name in ("ul", "ol"):
135 + for li in sib.find_all("li"):
136 + txt = clean_text(li.get_text(" "))
137 + low = txt.lower()
138 + if low.startswith("durée"):
139 + payload["duration"] = txt.split(":", 1)[-1].strip()
140 + elif low.startswith("horaire"):
141 + details["horaire"] = txt.split(":", 1)[-1].strip()
142 + elif low.startswith("mode de diffusion"):
143 + payload["mode"] = txt.split(":", 1)[-1].strip()
144 + elif "uec" in low or "unité" in low:
145 + payload["credential"] = txt.split(":", 1)[-1].strip()
146 + m = _UEC_RE.search(txt)
147 + if m:
148 + uec = m.group(1).replace(",", ".")
149 + payload["credits"] = f"{uec} UEC".replace(".", ",")
150 + elif low.startswith("nombre de personnes"):
151 + details["taille_groupe"] = txt.split(":", 1)[-1].strip()
152 + desc_start = sib
153 + break
154 +
155 + # description : paragraphes entre la liste des modalités et « Objectifs »
156 + if desc_start is not None:
157 + paras = []
158 + for sib in desc_start.find_next_siblings():
159 + if sib.name in ("h1", "h2", "h3"):
160 + break
161 + if sib.name == "p":
162 + t = clean_text(sib.get_text(" "))
163 + if t:
164 + paras.append(t)
165 + if paras:
166 + payload["description"] = "\n\n".join(paras)
167 +
168 + # sous-titre (h2 sous le h1) : complète la description
169 + if h1:
170 + h2 = h1.find_next("h2")
171 + if h2:
172 + sub = clean_text(h2.get_text(" "))
173 + if sub and not re.search(r"modalit|séance|tarif|attestation|statut",
174 + sub, re.I):
175 + details["sous_titre"] = sub
176 +
177 + # sections h3 : objectifs, contenu, approches, évaluation, clientèle
178 + for h3 in soup.find_all("h3"):
179 + heading = clean_text(h3.get_text(" "))
180 + for rx, field in _SECTIONS:
181 + if not rx.search(heading):
182 + continue
183 + paras, items = _section_content(h3)
184 + if field == "objectives":
185 + payload["objectives"] = items or paras
186 + elif field == "program":
187 + payload["program"] = items or paras
188 + elif field == "audience":
189 + payload["audience"] = " ".join(paras) or "; ".join(items)
190 + elif field == "_approches":
191 + if items or paras:
192 + details["approches_pedagogiques"] = items or paras
193 + elif field == "_evaluation":
194 + if items:
195 + details["evaluation"] = items
196 + break
197 +
198 + # biographie -> personne formatrice
199 + m = _BIO_RE.match(heading)
200 + if m and m.group(1):
201 + payload["instructor"] = clean_text(m.group(1))
202 +
203 + # « Prochaine séance » : dates des prochaines cohortes (si affichées)
204 + h_seance = soup.find(string=re.compile(r"Prochaines? s[ée]ances?", re.I))
205 + if h_seance:
206 + block = h_seance.find_parent(["div", "section"])
207 + if block:
208 + txt = clean_text(block.get_text(" "))
209 + dates = []
210 + for dm in _DATE_LINE_RE.finditer(txt):
211 + iso = parse_date_fr(dm.group(0))
212 + if iso:
213 + dates.append(iso)
214 + if dates:
215 + payload["sessions"] = sorted(set(dates))
216 + payload["start_date"] = payload["sessions"][0]
217 + payload["schedule_label"] = txt[:200]
218 +
219 + # lieu : les formations en personne se donnent au campus de l'UQAM
220 + if re.search(r"en personne|présentiel", str(payload.get("mode", "")), re.I):
221 + payload["city"] = "Montréal"
222 +
223 + if details:
224 + payload["details"] = details
225 + return payload
added formaka/connectors/versalys.py +201 −0
@@ -0,0 +1,201 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/versalys.py : connecteur Versalys (versalys.com)
5 +# Firme de formation bureautique, TI, langues et développement professionnel
6 +# (Montréal, Québec, Laval, Brossard + classes virtuelles) — ~260 fiches
7 +# françaises. Site WordPress rendu serveur :
8 +# - liste : sitemap dédié formation-sitemap1.xml (URLs /formation/<slug>/
9 +# avec lastmod — utilisé comme clé de cache : la fiche n'est revisitée
10 +# que si elle a été modifiée chez la source)
11 +# - fiche : sections HTML (Description, Prérequis, Éléments du contenu de
12 +# la formation en <h3>), encadré Durée / Tarif régulier / Tarif
13 +# préférentiel, prochaines dates par ville (blocs event-date-item avec
14 +# JSON encodé), fil d'Ariane BreadcrumbList JSON-LD pour la catégorie.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import datetime
19 +import json
20 +import re
21 +from urllib.parse import unquote_plus
22 +
23 +from bs4 import BeautifulSoup
24 +
25 +from ..schema import Formation, clean_text, parse_price
26 +from .base import BaseConnector, ldjson_objects
27 +
28 +BASE = "https://www.versalys.com"
29 +SITEMAP_URL = f"{BASE}/formation-sitemap1.xml"
30 +LIST_URL = f"{BASE}/formations/"
31 +
32 +_URL_RE = re.compile(r"<url>\s*<loc>(https://www\.versalys\.com/formation/"
33 + r"([^<]+?)/?)</loc>(?:\s*<lastmod>([^<]+)</lastmod>)?")
34 +# sigle du cours à la fin du slug (« …-wo-039 » -> « WO-039 »)
35 +_CODE_RE = re.compile(r"-([a-z]{2})-?(\d{3})$")
36 +_SIDEBAR_RE = re.compile(
37 + r"<strong>\s*(Durée|Tarif régulier|Tarif préférentiel)\s*:?\s*</strong>"
38 + r"\s*:?\s*([^<]*)", re.I)
39 +
40 +
41 +def _section_text(soup: BeautifulSoup, heading_rx: str) -> str:
42 + """Paragraphes qui suivent un <h2> donné, jusqu'au <h2> suivant."""
43 + h = soup.find("h2", string=re.compile(heading_rx, re.I))
44 + if h is None:
45 + return ""
46 + parts: list[str] = []
47 + for sib in h.find_all_next(["p", "li", "h2"]):
48 + if sib.name == "h2":
49 + break
50 + txt = clean_text(sib.get_text(" "))
51 + if txt:
52 + parts.append(txt)
53 + return "\n".join(dict.fromkeys(parts))
54 +
55 +
56 +class VersalysConnector(BaseConnector):
57 + source_id = "versalys"
58 + request_delay = 0.5
59 + limit: int | None = None # borne optionnelle (tests/débogage)
60 +
61 + def fetch(self) -> list[Formation]:
62 + # 1) Fiches françaises du sitemap dédié (avec date de modification)
63 + xml = self.get(SITEMAP_URL).text
64 + pages = [(url, slug.strip("/"), lastmod)
65 + for url, slug, lastmod in _URL_RE.findall(xml)]
66 + if self.limit:
67 + pages = pages[: self.limit]
68 +
69 + # 2) Fiche par formation — cache invalidé par lastmod du sitemap
70 + week = datetime.date.today().strftime("%G-W%V")
71 + out: list[Formation] = []
72 + for url, slug, lastmod in pages:
73 + payload = self.detail(slug, lastmod or week,
74 + lambda u=url: self._fetch_detail(u))
75 + m = _CODE_RE.search(slug)
76 + f = Formation(
77 + source=self.source_id,
78 + external_id=slug,
79 + url=url,
80 + training_type="Formation continue",
81 + language="fr",
82 + code=f"{m.group(1).upper()}-{m.group(2)}" if m else "",
83 + )
84 + for k, v in (payload or {}).items():
85 + if hasattr(f, k) and v not in (None, "", []):
86 + setattr(f, k, v)
87 + out.append(f)
88 + return out
89 +
90 + # -- fiche ----------------------------------------------------------------
91 + def _fetch_detail(self, url: str) -> dict:
92 + html = self.fetch_html(url)
93 + soup = BeautifulSoup(html, "html.parser")
94 + payload: dict = {}
95 + details: dict = {}
96 +
97 + h1 = soup.find("h1")
98 + if h1:
99 + payload["title"] = clean_text(h1.get_text(" "))
100 +
101 + # sections rédactionnelles de la fiche
102 + desc = _section_text(soup, r"^\s*description\s*$")
103 + if desc:
104 + payload["description"] = desc
105 + prereq = _section_text(soup, r"pr[ée]requis")
106 + if prereq:
107 + payload["prerequisites"] = prereq
108 +
109 + # plan de cours : <h3> entre « Éléments du contenu » et l'encadré dates
110 + h = soup.find("h2", string=re.compile(r"[ée]l[ée]ments du contenu", re.I))
111 + if h:
112 + program = []
113 + for sib in h.find_all_next("h3"):
114 + txt = clean_text(sib.get_text(" "))
115 + if not txt or re.search(r"PROCHAINE", txt):
116 + break
117 + program.append(txt.rstrip(" :;"))
118 + if program:
119 + payload["program"] = program
120 +
121 + # encadré : durée + tarifs
122 + for label, value in _SIDEBAR_RE.findall(html):
123 + value = clean_text(value)
124 + if not value:
125 + continue
126 + key = label.lower()
127 + if "durée" in key:
128 + payload["duration"] = value
129 + elif "régulier" in key:
130 + payload["price"] = parse_price(value)
131 + payload["price_label"] = value
132 + elif "préférentiel" in key:
133 + details["prix_preferentiel"] = parse_price(value)
134 +
135 + # prochaines dates par ville (JSON encodé dans des champs cachés)
136 + sessions, cities = [], []
137 + for item in soup.find_all("div", class_="event-date-item"):
138 + inp = item.find("input", attrs={"name": "event_date_time[]"})
139 + try:
140 + slots = json.loads(unquote_plus(inp["value"])) if inp else {}
141 + except (ValueError, KeyError):
142 + slots = {}
143 + for slot in slots.values():
144 + start = str(slot.get("startDate", ""))[:10]
145 + if re.match(r"20\d{2}-\d{2}-\d{2}", start):
146 + sessions.append(start)
147 + h3 = item.find("h3")
148 + if h3:
149 + city = clean_text(h3.get_text(" "))
150 + if city:
151 + cities.append(city)
152 + sessions = sorted(set(sessions))
153 + if sessions:
154 + payload["sessions"] = sessions
155 + payload["start_date"] = sessions[0]
156 + cities = list(dict.fromkeys(cities))
157 + modes = ["en ligne" if "virtuelle" in c.lower() else "présentiel"
158 + for c in cities]
159 + modes = list(dict.fromkeys(modes))
160 + real_cities = [c for c in cities if "virtuelle" not in c.lower()]
161 + if real_cities:
162 + payload["city"] = real_cities[0]
163 +
164 + # fil d'Ariane JSON-LD : catégorie + mode (eLearning, classe virtuelle)
165 + for obj in ldjson_objects(html):
166 + if obj.get("@type") == "BreadcrumbList":
167 + names = [clean_text(it.get("name", ""))
168 + for it in obj.get("itemListElement", [])
169 + if isinstance(it, dict)]
170 + names = [n for n in names[1:] if n] # sans « Accueil »
171 + if names:
172 + names.pop() # le dernier item = la page courante
173 + if names:
174 + payload["category"] = names[-1]
175 + if len(names) > 1:
176 + payload["tags"] = names[:-1]
177 + crumb = " ".join(names).lower()
178 + if "conférence" in crumb:
179 + payload["training_type"] = "Conférence"
180 + elif "atelier" in crumb:
181 + payload["training_type"] = "Atelier"
182 + if "elearning" in crumb or "e-learning" in crumb:
183 + modes = ["asynchrone"]
184 + elif not modes and "virtuelle" in crumb:
185 + modes = ["en ligne"]
186 + elif obj.get("@type") == "Course":
187 + if (len(payload.get("description", "")) < 30
188 + and obj.get("description")):
189 + payload["description"] = clean_text(obj["description"])
190 + if obj.get("inLanguage", "").lower().startswith("en"):
191 + payload["language"] = "en"
192 + if modes:
193 + payload["mode"] = modes[0] if len(modes) == 1 else "hybride"
194 + details["modes_offerts"] = modes
195 +
196 + og = soup.find("meta", property="og:image")
197 + if og and og.get("content"):
198 + payload["images"] = [og["content"]]
199 + if details:
200 + payload["details"] = details
201 + return payload
added formaka/db.py +325 −0
@@ -0,0 +1,325 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# db.py : persistance SQLite — upsert avec détection de changements,
5 +# cycle de vie avec délai de grâce (2 syncs), détection de dérive,
6 +# cache des pages détail, historique de prix (quand un prix existe).
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import json
11 +import sqlite3
12 +import statistics
13 +import time
14 +from pathlib import Path
15 +
16 +from .schema import Formation
17 +
18 +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "formaka.db"
19 +
20 +# Nombre d'exécutions consécutives où une formation doit être absente de la
21 +# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).
22 +MISS_GRACE = 2
23 +
24 +# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique
25 +# (médiane >= DRIFT_MIN_BASE formations), on alerte et on suspend les retraits.
26 +DRIFT_RATIO = 0.25
27 +DRIFT_MIN_BASE = 8
28 +DRIFT_HISTORY = 5
29 +
30 +_SCHEMA = """
31 +CREATE TABLE IF NOT EXISTS formations (
32 + uid TEXT PRIMARY KEY,
33 + source TEXT NOT NULL,
34 + external_id TEXT NOT NULL,
35 + url TEXT,
36 + title TEXT,
37 + training_type TEXT,
38 + category TEXT,
39 + mode TEXT,
40 + city TEXT,
41 + language TEXT,
42 + price REAL,
43 + price_label TEXT,
44 + is_free INTEGER,
45 + duration TEXT,
46 + duration_hours REAL,
47 + start_date TEXT,
48 + schedule_label TEXT,
49 + sessions TEXT, -- JSON (dates ISO)
50 + level TEXT,
51 + credits TEXT,
52 + credential TEXT,
53 + instructor TEXT,
54 + code TEXT,
55 + description TEXT,
56 + objectives TEXT, -- JSON
57 + prerequisites TEXT,
58 + audience TEXT,
59 + program TEXT, -- JSON (plan de cours)
60 + tags TEXT, -- JSON
61 + details TEXT, -- JSON (champs structurés : uec, crédits, niveau…)
62 + images TEXT, -- JSON
63 + content_hash TEXT,
64 + first_seen REAL,
65 + last_seen REAL,
66 + updated_at REAL,
67 + miss_count INTEGER DEFAULT 0,
68 + active INTEGER DEFAULT 1
69 +);
70 +CREATE INDEX IF NOT EXISTS idx_formations_source ON formations(source);
71 +CREATE INDEX IF NOT EXISTS idx_formations_type ON formations(training_type);
72 +CREATE INDEX IF NOT EXISTS idx_formations_active ON formations(active);
73 +
74 +CREATE TABLE IF NOT EXISTS sync_log (
75 + id INTEGER PRIMARY KEY AUTOINCREMENT,
76 + source TEXT,
77 + ts REAL,
78 + found INTEGER,
79 + added INTEGER,
80 + updated INTEGER,
81 + removed INTEGER,
82 + ok INTEGER,
83 + message TEXT,
84 + stats TEXT -- JSON : taux de champs null, missed, alerte…
85 +);
86 +
87 +CREATE TABLE IF NOT EXISTS detail_cache (
88 + source TEXT NOT NULL,
89 + external_id TEXT NOT NULL,
90 + key TEXT, -- hash du contenu « liste » de la formation
91 + payload TEXT, -- JSON opaque propre au connecteur
92 + fetched_at REAL,
93 + PRIMARY KEY (source, external_id)
94 +);
95 +
96 +CREATE TABLE IF NOT EXISTS price_log (
97 + uid TEXT NOT NULL,
98 + ts REAL NOT NULL,
99 + price REAL -- prix observé (NULL = retiré de l'affichage)
100 +);
101 +CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);
102 +"""
103 +
104 +# Colonnes ajoutées après la v1 — migration automatique des bases existantes.
105 +_MIGRATIONS: dict[str, dict[str, str]] = {
106 + "formations": {},
107 + "sync_log": {},
108 +}
109 +
110 +
111 +def connect() -> sqlite3.Connection:
112 + DB_PATH.parent.mkdir(parents=True, exist_ok=True)
113 + con = sqlite3.connect(DB_PATH)
114 + con.row_factory = sqlite3.Row
115 + con.executescript(_SCHEMA)
116 + for table, cols in _MIGRATIONS.items():
117 + if not cols:
118 + continue
119 + existing = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")}
120 + for col, decl in cols.items():
121 + if col not in existing:
122 + con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}")
123 + con.commit()
124 + return con
125 +
126 +
127 +# ---------------------------------------------------------------------------
128 +# Synchronisation d'une source
129 +# ---------------------------------------------------------------------------
130 +
131 +def _drift_alert(con: sqlite3.Connection, source: str, found: int,
132 + null_desc_rate: float) -> str | None:
133 + """Détecte une dérive du connecteur (chute du volume ou des descriptions).
134 +
135 + Le prix étant optionnel dans le domaine de la formation, la dérive se
136 + mesure sur le volume et sur la richesse des fiches (description vide).
137 + """
138 + hist = con.execute(
139 + "SELECT found, stats FROM sync_log WHERE source=? AND ok=1"
140 + " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall()
141 + if len(hist) < 3:
142 + return None
143 + med_found = statistics.median(r["found"] for r in hist)
144 + if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:
145 + return (f"dérive: {found} formation(s) trouvée(s) contre une médiane de "
146 + f"{med_found:.0f} — retraits suspendus, vérifier le connecteur")
147 + if found >= DRIFT_MIN_BASE and null_desc_rate >= 0.8:
148 + rates = []
149 + for r in hist:
150 + try:
151 + rates.append(json.loads(r["stats"] or "{}")["null_desc_rate"])
152 + except (KeyError, ValueError, TypeError):
153 + continue
154 + if rates and statistics.median(rates) <= 0.3:
155 + return (f"dérive: {null_desc_rate:.0%} des formations sans description "
156 + f"(habituellement {statistics.median(rates):.0%}) — "
157 + "le format de la source a probablement changé")
158 + return None
159 +
160 +
161 +def sync_source(con: sqlite3.Connection, source: str,
162 + formations: list[Formation]) -> dict:
163 + """Synchronise les formations d'une source.
164 +
165 + - nouvelle formation -> insertion
166 + - formation modifiée -> mise à jour (comparaison de content_hash)
167 + - formation disparue -> miss_count += 1, puis active=0 après MISS_GRACE
168 + exécutions consécutives (délai de grâce)
169 + - dérive détectée -> alerte consignée, retraits suspendus
170 + """
171 + now = time.time()
172 + added = updated = 0
173 + seen_uids = set()
174 +
175 + n = len(formations)
176 + null_desc = sum(1 for f in formations if not f.description)
177 + null_title = sum(1 for f in formations if not f.title)
178 + null_desc_rate = round(null_desc / n, 3) if n else 0.0
179 +
180 + alert = _drift_alert(con, source, n, null_desc_rate)
181 +
182 + for fmt in formations:
183 + seen_uids.add(fmt.uid)
184 + h = fmt.content_hash()
185 + row = con.execute("SELECT content_hash, price FROM formations WHERE uid=?",
186 + (fmt.uid,)).fetchone()
187 + params = dict(
188 + uid=fmt.uid, source=fmt.source, external_id=fmt.external_id,
189 + url=fmt.url, title=fmt.title, training_type=fmt.training_type,
190 + category=fmt.category, mode=fmt.mode, city=fmt.city,
191 + language=fmt.language, price=fmt.price, price_label=fmt.price_label,
192 + is_free=(None if fmt.is_free is None else int(fmt.is_free)),
193 + duration=fmt.duration, duration_hours=fmt.duration_hours,
194 + start_date=fmt.start_date, schedule_label=fmt.schedule_label,
195 + sessions=json.dumps(fmt.sessions, ensure_ascii=False),
196 + level=fmt.level, credits=fmt.credits, credential=fmt.credential,
197 + instructor=fmt.instructor, code=fmt.code,
198 + description=fmt.description,
199 + objectives=json.dumps(fmt.objectives, ensure_ascii=False),
200 + prerequisites=fmt.prerequisites, audience=fmt.audience,
201 + program=json.dumps(fmt.program, ensure_ascii=False),
202 + tags=json.dumps(fmt.tags, ensure_ascii=False),
203 + details=json.dumps(fmt.details, ensure_ascii=False),
204 + images=json.dumps(fmt.images, ensure_ascii=False),
205 + content_hash=h, now=now,
206 + )
207 + if row is None:
208 + con.execute(
209 + """INSERT INTO formations (uid, source, external_id, url, title,
210 + training_type, category, mode, city, language, price,
211 + price_label, is_free, duration, duration_hours, start_date,
212 + schedule_label, sessions, level, credits, credential,
213 + instructor, code, description, objectives, prerequisites,
214 + audience, program, tags, details, images, content_hash,
215 + first_seen, last_seen, updated_at, miss_count, active)
216 + VALUES (:uid,:source,:external_id,:url,:title,:training_type,
217 + :category,:mode,:city,:language,:price,:price_label,:is_free,
218 + :duration,:duration_hours,:start_date,:schedule_label,
219 + :sessions,:level,:credits,:credential,:instructor,:code,
220 + :description,:objectives,:prerequisites,:audience,:program,
221 + :tags,:details,:images,:content_hash,:now,:now,:now,0,1)""",
222 + params)
223 + if fmt.price is not None: # prix initial = départ de l'historique
224 + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
225 + (fmt.uid, now, fmt.price))
226 + added += 1
227 + elif row["content_hash"] != h:
228 + con.execute(
229 + """UPDATE formations SET url=:url, title=:title,
230 + training_type=:training_type, category=:category, mode=:mode,
231 + city=:city, language=:language, price=:price,
232 + price_label=:price_label, is_free=:is_free,
233 + duration=:duration, duration_hours=:duration_hours,
234 + start_date=:start_date, schedule_label=:schedule_label,
235 + sessions=:sessions, level=:level, credits=:credits,
236 + credential=:credential, instructor=:instructor, code=:code,
237 + description=:description, objectives=:objectives,
238 + prerequisites=:prerequisites, audience=:audience,
239 + program=:program, tags=:tags, details=:details,
240 + images=:images, content_hash=:content_hash, last_seen=:now,
241 + updated_at=:now, miss_count=0, active=1
242 + WHERE uid=:uid""", params)
243 + if fmt.price != row["price"]: # changement de prix -> historique
244 + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
245 + (fmt.uid, now, fmt.price))
246 + updated += 1
247 + else:
248 + con.execute(
249 + "UPDATE formations SET last_seen=?, miss_count=0, active=1 WHERE uid=?",
250 + (now, fmt.uid))
251 +
252 + # Formations de cette source qui n'apparaissent plus : délai de grâce,
253 + # puis désactivation. Suspendu si une dérive est détectée.
254 + removed = missed = 0
255 + if not alert:
256 + for r in con.execute(
257 + "SELECT uid, miss_count FROM formations WHERE source=? AND active=1",
258 + (source,)).fetchall():
259 + if r["uid"] in seen_uids:
260 + continue
261 + missed += 1
262 + if r["miss_count"] + 1 >= MISS_GRACE:
263 + con.execute(
264 + "UPDATE formations SET active=0, miss_count=?, updated_at=?"
265 + " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"]))
266 + removed += 1
267 + else:
268 + con.execute("UPDATE formations SET miss_count=miss_count+1 WHERE uid=?",
269 + (r["uid"],))
270 +
271 + stats = {
272 + "null_desc_rate": null_desc_rate,
273 + "null_title_rate": round(null_title / n, 3) if n else 0.0,
274 + "missed": missed,
275 + }
276 + if alert:
277 + stats["alert"] = alert
278 + con.execute(
279 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,"
280 + " message, stats) VALUES (?,?,?,?,?,?,1,?,?)",
281 + (source, now, n, added, updated, removed, alert or "ok",
282 + json.dumps(stats, ensure_ascii=False)))
283 + con.commit()
284 + out = {"source": source, "found": n, "added": added,
285 + "updated": updated, "removed": removed}
286 + if alert:
287 + out["alert"] = alert
288 + return out
289 +
290 +
291 +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
292 + con.execute(
293 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"
294 + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message))
295 + con.commit()
296 +
297 +
298 +# ---------------------------------------------------------------------------
299 +# Cache des pages détail (« détail si nouveau/modifié »)
300 +# ---------------------------------------------------------------------------
301 +
302 +def get_cached_detail(con: sqlite3.Connection, source: str,
303 + external_id: str, key: str) -> dict | None:
304 + """Payload détail mis en cache si la clé (hash liste) n'a pas changé."""
305 + row = con.execute(
306 + "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?",
307 + (source, external_id)).fetchone()
308 + if row and row["key"] == key and row["payload"]:
309 + try:
310 + return json.loads(row["payload"])
311 + except ValueError:
312 + return None
313 + return None
314 +
315 +
316 +def put_cached_detail(con: sqlite3.Connection, source: str,
317 + external_id: str, key: str, payload: dict) -> None:
318 + con.execute(
319 + "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)"
320 + " VALUES (?,?,?,?,?)"
321 + " ON CONFLICT(source, external_id) DO UPDATE SET"
322 + " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at",
323 + (source, external_id, key, json.dumps(payload, ensure_ascii=False),
324 + time.time()))
325 + con.commit()
added formaka/ingest.py +62 −0
@@ -0,0 +1,62 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# ingest.py : pipeline d'ingestion — exécute les connecteurs et synchronise
5 +# la base (ajouts / mises à jour / retraits) = contenu toujours à jour
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import sys
10 +import time
11 +import traceback
12 +
13 +from . import db
14 +from .connectors import CONNECTORS
15 +
16 +
17 +def run(sources: list[str] | None = None) -> list[dict]:
18 + """Exécute l'ingestion pour toutes les sources (ou celles demandées)."""
19 + con = db.connect()
20 + results = []
21 + targets = sources or list(CONNECTORS.keys())
22 + for sid in targets:
23 + cls = CONNECTORS.get(sid)
24 + if cls is None:
25 + print(f"[forma-ka] connecteur inconnu : {sid}", file=sys.stderr)
26 + continue
27 + t0 = time.time()
28 + print(f"[forma-ka] sync {sid} ...")
29 + try:
30 + formations = cls().fetch()
31 + finalized, dropped = [], 0
32 + for fmt in formations:
33 + try:
34 + finalized.append(fmt.finalize())
35 + except Exception: # une fiche malformée ne bloque pas la source
36 + dropped += 1
37 + stats = db.sync_source(con, sid, finalized)
38 + stats["seconds"] = round(time.time() - t0, 1)
39 + if dropped:
40 + stats["dropped"] = dropped
41 + if stats.get("alert"):
42 + print(f"[forma-ka] ⚠ ALERTE {sid} : {stats['alert']}")
43 + print(f"[forma-ka] {stats}")
44 + results.append(stats)
45 + except Exception as exc: # robustesse : une source ne bloque pas les autres
46 + db.log_failure(con, sid, f"{exc}")
47 + traceback.print_exc()
48 + results.append({"source": sid, "error": str(exc)})
49 + con.close()
50 + return results
51 +
52 +
53 +def watch(interval_seconds: int = 3600) -> None:
54 + """Boucle de rafraîchissement périodique (pseudo-webhook par sondage)."""
55 + while True:
56 + run()
57 + print(f"[forma-ka] prochaine synchronisation dans {interval_seconds}s")
58 + time.sleep(interval_seconds)
59 +
60 +
61 +if __name__ == "__main__":
62 + run(sys.argv[1:] or None)
added formaka/normalize.py +258 −0
@@ -0,0 +1,258 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# normalize.py : couche de normalisation commune — chaque connecteur remplit
5 +# des champs bruts, cette couche les canonise (prix optionnel, durées en
6 +# heures, modes de diffusion, types de formation, dates ISO, langues…).
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import re
11 +import unicodedata
12 +
13 +__all__ = [
14 + "strip_accents", "clean_text", "parse_price", "price_is_from",
15 + "parse_duration_hours", "normalize_mode", "normalize_type",
16 + "normalize_language", "parse_date_fr", "extract_details", "merge_details",
17 +]
18 +
19 +
20 +def strip_accents(s: str) -> str:
21 + return "".join(c for c in unicodedata.normalize("NFD", s or "")
22 + if unicodedata.category(c) != "Mn")
23 +
24 +
25 +def clean_text(s: str) -> str:
26 + """Espaces multiples/insécables -> simple espace, trim."""
27 + return re.sub(r"[\s   ]+", " ", s or "").strip()
28 +
29 +
30 +# ---------------------------------------------------------------------------
31 +# Prix — OPTIONNEL : beaucoup de formations (cours universitaires, catalogue
32 +# de cégep…) n'affichent aucun prix ; None est une valeur normale, pas un bug.
33 +# ---------------------------------------------------------------------------
34 +
35 +_FREE_RE = re.compile(r"\b(gratuit|gratuite|free|sans frais|0\s*\$)\b", re.I)
36 +_PRICE_RE = re.compile(r"(\d{1,3}(?:[  ,]\d{3})*(?:[.,]\d{2})?)\s*\$"
37 + r"|\$\s*(\d{1,3}(?:[, ]\d{3})*(?:[.,]\d{2})?)")
38 +
39 +
40 +def parse_price(label: str) -> float | None:
41 + """« 1 295,00 $ + tx » -> 1295.0 ; « Gratuit » -> 0.0 ; sinon None."""
42 + if not label:
43 + return None
44 + if _FREE_RE.search(label):
45 + return 0.0
46 + m = _PRICE_RE.search(label)
47 + if not m:
48 + return None
49 + raw = (m.group(1) or m.group(2) or "")
50 + raw = raw.replace(" ", "").replace(" ", "").replace(" ", "")
51 + # « 1,295.00 » (anglais) vs « 1295,00 » (français)
52 + if "," in raw and "." in raw:
53 + raw = raw.replace(",", "")
54 + elif "," in raw:
55 + raw = raw.replace(",", ".") if re.search(r",\d{2}$", raw) else raw.replace(",", "")
56 + try:
57 + return float(raw)
58 + except ValueError:
59 + return None
60 +
61 +
62 +def price_is_from(label: str) -> bool:
63 + return bool(re.search(r"à partir de|a partir de|from|dès|des\s+\d", label or "", re.I))
64 +
65 +
66 +# ---------------------------------------------------------------------------
67 +# Durée -> heures (1 jour = 7 h, 1 semaine laissée telle quelle sauf mention)
68 +# ---------------------------------------------------------------------------
69 +
70 +_H_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:h(?:eures?|res?)?|hours?)\b", re.I)
71 +_D_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:jours?|journ[ée]es?|days?)\b", re.I)
72 +_HALF_DAY_RE = re.compile(r"demi[- ]journ[ée]e", re.I)
73 +_MIN_RE = re.compile(r"(\d+)\s*(?:min(?:utes?)?)\b", re.I)
74 +
75 +HOURS_PER_DAY = 7.0
76 +
77 +
78 +def parse_duration_hours(text: str) -> float | None:
79 + """« 2 jours », « 14 h », « 90 minutes », « demi-journée » -> heures."""
80 + if not text:
81 + return None
82 + m = _H_RE.search(text)
83 + if m:
84 + return float(m.group(1).replace(",", "."))
85 + m = _D_RE.search(text)
86 + if m:
87 + return float(m.group(1).replace(",", ".")) * HOURS_PER_DAY
88 + if _HALF_DAY_RE.search(text):
89 + return HOURS_PER_DAY / 2
90 + m = _MIN_RE.search(text)
91 + if m:
92 + return round(int(m.group(1)) / 60, 2)
93 + return None
94 +
95 +
96 +# ---------------------------------------------------------------------------
97 +# Mode de diffusion
98 +# ---------------------------------------------------------------------------
99 +
100 +_MODE_MAP = [
101 + (re.compile(r"hybride|comodal|bimodal|mixte|blended", re.I), "hybride"),
102 + (re.compile(r"asynchrone|à votre rythme|a votre rythme|autoportant|"
103 + r"self[- ]paced|autorythmé", re.I), "asynchrone"),
104 + (re.compile(r"en ligne|à distance|a distance|virtuel|webinaire|webdiffusion|"
105 + r"online|distanciel|remote|zoom|teams|classe virtuelle", re.I), "en ligne"),
106 + (re.compile(r"présentiel|presentiel|en salle|en classe|sur place|in[- ]person|"
107 + r"en personne|campus", re.I), "présentiel"),
108 +]
109 +
110 +
111 +def normalize_mode(raw: str) -> str:
112 + for rx, canon in _MODE_MAP:
113 + if rx.search(raw or ""):
114 + return canon
115 + return clean_text(raw).lower()
116 +
117 +
118 +# ---------------------------------------------------------------------------
119 +# Type de formation — vocabulaire canonique Forma-Ka
120 +# ---------------------------------------------------------------------------
121 +
122 +TYPES = ("Cours universitaire", "Cours collégial", "Formation continue",
123 + "Cours en ligne", "Séminaire", "Atelier", "Webinaire", "Conférence",
124 + "Certification", "Bootcamp", "Programme", "Formation en entreprise")
125 +
126 +_TYPE_MAP = [
127 + (re.compile(r"universitaire|university|1er cycle|premier cycle|2e cycle|"
128 + r"cycles? sup|bachelor|baccalaur|maîtrise|maitrise|MBA", re.I),
129 + "Cours universitaire"),
130 + (re.compile(r"coll[ée]gial|c[ée]gep|AEC\b|DEC\b", re.I), "Cours collégial"),
131 + (re.compile(r"bootcamp|camp d'entraînement", re.I), "Bootcamp"),
132 + (re.compile(r"webinaire|webinar|webdiffusion", re.I), "Webinaire"),
133 + (re.compile(r"certification|certificat professionnel|badge", re.I), "Certification"),
134 + (re.compile(r"s[ée]minaire", re.I), "Séminaire"),
135 + (re.compile(r"atelier|workshop", re.I), "Atelier"),
136 + (re.compile(r"conf[ée]rence|sommet|colloque|symposium", re.I), "Conférence"),
137 + (re.compile(r"programme|parcours|cohorte", re.I), "Programme"),
138 + (re.compile(r"en ligne|online|à distance|a distance", re.I), "Cours en ligne"),
139 + (re.compile(r"formation|cours|perfectionnement", re.I), "Formation continue"),
140 +]
141 +
142 +
143 +def normalize_type(raw: str) -> str:
144 + raw = clean_text(raw)
145 + for t in TYPES: # déjà canonique
146 + if raw.lower() == t.lower():
147 + return t
148 + for rx, canon in _TYPE_MAP:
149 + if rx.search(raw):
150 + return canon
151 + return raw
152 +
153 +
154 +# ---------------------------------------------------------------------------
155 +# Langue
156 +# ---------------------------------------------------------------------------
157 +
158 +def normalize_language(raw: str) -> str:
159 + s = strip_accents((raw or "").lower())
160 + fr = bool(re.search(r"\bfr|francais|french", s))
161 + en = bool(re.search(r"\ben\b|anglais|english", s))
162 + if fr and en:
163 + return "fr/en"
164 + if en:
165 + return "en"
166 + if fr:
167 + return "fr"
168 + return clean_text(raw).lower()
169 +
170 +
171 +# ---------------------------------------------------------------------------
172 +# Dates françaises -> ISO
173 +# ---------------------------------------------------------------------------
174 +
175 +_MONTHS = {
176 + "janvier": 1, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6,
177 + "juillet": 7, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11,
178 + "decembre": 12,
179 + # anglais
180 + "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6,
181 + "july": 7, "august": 8, "september": 9, "october": 10, "november": 11,
182 + "december": 12,
183 + # abréviations courantes
184 + "janv": 1, "fevr": 2, "fev": 2, "avr": 4, "juil": 7, "sept": 9, "oct": 10,
185 + "nov": 11, "dec": 12, "jan": 1, "feb": 2, "mar": 3, "apr": 4, "jun": 6,
186 + "jul": 7, "aug": 8, "sep": 9,
187 +}
188 +
189 +_ISO_RE = re.compile(r"\b(20\d{2})-(\d{1,2})-(\d{1,2})\b")
190 +_FR_RE = re.compile(r"\b(1er|\d{1,2})\s+([a-zéûî]+)\.?\s+(20\d{2})", re.I)
191 +_SLASH_RE = re.compile(r"\b(\d{1,2})/(\d{1,2})/(20\d{2})\b")
192 +
193 +
194 +def parse_date_fr(text: str) -> str | None:
195 + """« 14 octobre 2026 », « 2026-10-14 », « 14/10/2026 » -> « 2026-10-14 »."""
196 + if not text:
197 + return None
198 + m = _ISO_RE.search(text)
199 + if m:
200 + y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
201 + if 1 <= mo <= 12 and 1 <= d <= 31:
202 + return f"{y:04d}-{mo:02d}-{d:02d}"
203 + m = _FR_RE.search(strip_accents(text).lower())
204 + if m:
205 + d = 1 if m.group(1) == "1er" else int(m.group(1))
206 + mo = _MONTHS.get(m.group(2).rstrip("."))
207 + if mo and 1 <= d <= 31:
208 + return f"{int(m.group(3)):04d}-{mo:02d}-{d:02d}"
209 + m = _SLASH_RE.search(text)
210 + if m: # convention canadienne-française jj/mm/aaaa
211 + d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
212 + if mo > 12 and d <= 12:
213 + d, mo = mo, d
214 + if 1 <= mo <= 12 and 1 <= d <= 31:
215 + return f"{y:04d}-{mo:02d}-{d:02d}"
216 + return None
217 +
218 +
219 +# ---------------------------------------------------------------------------
220 +# Extraction de détails structurés depuis les textes libres
221 +# ---------------------------------------------------------------------------
222 +
223 +_UEC_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*UEC", re.I)
224 +_CREDITS_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*cr[ée]dits?", re.I)
225 +_LEVEL_MAP = [
226 + (re.compile(r"d[ée]butant|introduction|initiation|de base|niveau 1|beginner", re.I),
227 + "débutant"),
228 + (re.compile(r"interm[ée]diaire|niveau 2|intermediate", re.I), "intermédiaire"),
229 + (re.compile(r"avanc[ée]|expert|niveau 3|advanced|perfectionnement avanc", re.I),
230 + "avancé"),
231 +]
232 +
233 +
234 +def extract_details(*texts: str) -> dict:
235 + """Détails dérivés des textes libres (jamais prioritaire sur le connecteur)."""
236 + blob = " ".join(t for t in texts if t)
237 + out: dict = {}
238 + m = _UEC_RE.search(blob)
239 + if m:
240 + out["uec"] = float(m.group(1).replace(",", "."))
241 + m = _CREDITS_RE.search(blob)
242 + if m:
243 + out["credits"] = float(m.group(1).replace(",", "."))
244 + for rx, lvl in _LEVEL_MAP:
245 + if rx.search(blob):
246 + out["level"] = lvl
247 + break
248 + h = parse_duration_hours(blob)
249 + if h is not None:
250 + out["duration_hours"] = h
251 + return out
252 +
253 +
254 +def merge_details(explicit: dict, derived: dict) -> dict:
255 + """Fusion : les valeurs explicites du connecteur ont toujours priorité."""
256 + out = dict(derived)
257 + out.update({k: v for k, v in (explicit or {}).items() if v is not None})
258 + return out
added formaka/schema.py +130 −0
@@ -0,0 +1,130 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# schema.py : modèle de données standardisé (Formation) + normalisation finale
5 +# -----------------------------------------------------------------------------
6 +"""Schéma standard d'une formation et normalisation des champs.
7 +
8 +Chaque connecteur, peu importe le site source (université, cégep, firme de
9 +formation, plateforme…), produit des objets `Formation` conformes à ce schéma.
10 +`finalize()` applique la couche commune (formaka/normalize.py).
11 +
12 +Philosophie : le PRIX EST OPTIONNEL (un cours universitaire n'affiche pas de
13 +prix) — ce qui compte, ce sont les DÉTAILS de la formation : description,
14 +objectifs, préalables, plan de cours, durée, crédits/UEC, mode, clientèle…
15 +"""
16 +from __future__ import annotations
17 +
18 +import hashlib
19 +import json
20 +from dataclasses import dataclass, field, asdict
21 +
22 +from .normalize import ( # ré-exportés pour les connecteurs
23 + clean_text,
24 + extract_details,
25 + merge_details,
26 + normalize_language,
27 + normalize_mode,
28 + normalize_type,
29 + parse_date_fr,
30 + parse_duration_hours,
31 + parse_price,
32 + price_is_from,
33 + strip_accents,
34 +)
35 +
36 +__all__ = [
37 + "Formation", "clean_text", "normalize_type", "normalize_mode",
38 + "normalize_language", "parse_price", "parse_duration_hours",
39 + "parse_date_fr", "strip_accents",
40 +]
41 +
42 +
43 +@dataclass
44 +class Formation:
45 + """Formation standardisée Forma-Ka."""
46 +
47 + source: str # id de la source (voir data/sources.json)
48 + external_id: str # identifiant chez la source
49 + url: str # page de la formation chez la source
50 + title: str = "" # ex. « Gestion de projet agile »
51 + training_type: str = "" # Cours universitaire, Séminaire, Atelier…
52 + category: str = "" # domaine : Informatique, Gestion, RH…
53 + mode: str = "" # en ligne | présentiel | hybride | asynchrone
54 + city: str = "" # ville (si présentiel/hybride)
55 + language: str = "" # fr | en | fr/en
56 + price: float | None = None # $ CAD — None = non affiché (normal !)
57 + price_label: str = "" # texte original (« 1 295 $ + tx »)
58 + is_free: bool | None = None # gratuit (None = inconnu)
59 + duration: str = "" # texte original (« 2 jours », « 45 h »)
60 + duration_hours: float | None = None
61 + start_date: str | None = None # ISO — prochaine séance/session
62 + schedule_label: str = "" # texte original des dates/horaires
63 + sessions: list[str] = field(default_factory=list) # toutes les dates offertes
64 + level: str = "" # débutant | intermédiaire | avancé
65 + credits: str = "" # « 3 crédits », « 1,4 UEC »
66 + credential: str = "" # attestation, certificat, diplôme, UEC…
67 + instructor: str = "" # formateur / professeur
68 + code: str = "" # sigle du cours (ex. « GSF-1020 »)
69 + description: str = "" # description complète
70 + objectives: list[str] = field(default_factory=list) # objectifs d'apprentissage
71 + prerequisites: str = "" # préalables / conditions d'admission
72 + audience: str = "" # clientèle visée
73 + program: list[str] = field(default_factory=list) # plan / contenu détaillé
74 + tags: list[str] = field(default_factory=list)
75 + details: dict = field(default_factory=dict) # champs structurés (JSON)
76 + images: list[str] = field(default_factory=list) # URLs absolues
77 +
78 + @property
79 + def uid(self) -> str:
80 + return f"{self.source}:{self.external_id}"
81 +
82 + def content_hash(self) -> str:
83 + """Hash du contenu pour la détection de changements (pseudo-webhook)."""
84 + payload = asdict(self)
85 + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
86 + return hashlib.sha256(blob.encode("utf-8")).hexdigest()
87 +
88 + def finalize(self) -> "Formation":
89 + """Applique la normalisation commune. Appelé par le pipeline d'ingestion.
90 +
91 + Idempotent ; ne remplace jamais une valeur explicite du connecteur.
92 + """
93 + self.title = clean_text(self.title)
94 + self.description = (self.description or "").strip()
95 + self.training_type = normalize_type(self.training_type)
96 + self.mode = normalize_mode(self.mode)
97 + self.language = normalize_language(self.language)
98 + self.objectives = [clean_text(o) for o in self.objectives if clean_text(o)]
99 + self.program = [clean_text(p) for p in self.program if clean_text(p)]
100 + self.tags = sorted({clean_text(t) for t in self.tags if clean_text(t)})
101 +
102 + if self.price is None:
103 + self.price = parse_price(self.price_label)
104 + if self.is_free is None and self.price is not None:
105 + self.is_free = self.price == 0.0
106 + if self.duration_hours is None:
107 + self.duration_hours = parse_duration_hours(self.duration)
108 + if self.start_date is None:
109 + self.start_date = parse_date_fr(self.schedule_label)
110 + if not self.sessions and self.start_date:
111 + self.sessions = [self.start_date]
112 +
113 + # détails dérivés des textes libres (le connecteur garde priorité)
114 + derived = extract_details(self.duration, self.description,
115 + self.credits, self.schedule_label)
116 + if self.price_label and price_is_from(self.price_label):
117 + derived["price_from"] = True
118 + self.details = merge_details(self.details, derived)
119 +
120 + if not self.level:
121 + self.level = self.details.get("level", "")
122 + if self.duration_hours is None:
123 + dh = self.details.get("duration_hours")
124 + self.duration_hours = float(dh) if dh is not None else None
125 + if not self.credits:
126 + if self.details.get("uec") is not None:
127 + self.credits = f"{self.details['uec']:g} UEC".replace(".", ",")
128 + elif self.details.get("credits") is not None:
129 + self.credits = f"{self.details['credits']:g} crédits"
130 + return self
added formaka/web.py +237 −0
@@ -0,0 +1,237 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# web.py : API FastAPI (JSON) + service du frontend React (frontend/dist)
5 +# -----------------------------------------------------------------------------
6 +from __future__ import annotations
7 +
8 +import json
9 +import threading
10 +from pathlib import Path
11 +
12 +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
13 +from fastapi.middleware.cors import CORSMiddleware
14 +from fastapi.responses import FileResponse
15 +from fastapi.staticfiles import StaticFiles
16 +
17 +from . import db, ingest
18 +
19 +ROOT = Path(__file__).resolve().parent.parent
20 +SOURCES_PATH = ROOT / "data" / "sources.json"
21 +FRONTEND_DIST = ROOT / "frontend" / "dist"
22 +
23 +app = FastAPI(title="Forma-Ka API", version="1.0",
24 + description="Agrégateur de formations — province de Québec")
25 +app.add_middleware(CORSMiddleware, allow_origins=["*"],
26 + allow_methods=["*"], allow_headers=["*"])
27 +
28 +_sync_lock = threading.Lock()
29 +
30 +_JSON_COLS = ("sessions", "objectives", "program", "tags", "details", "images")
31 +
32 +
33 +def _row_to_dict(row) -> dict:
34 + d = dict(row)
35 + for col in _JSON_COLS:
36 + default = "{}" if col == "details" else "[]"
37 + d[col] = json.loads(d.get(col) or default)
38 + if d.get("is_free") is not None:
39 + d["is_free"] = bool(d["is_free"])
40 + return d
41 +
42 +
43 +def _apply_filters(sql: str, args: list,
44 + training_type: str | None, category: str | None,
45 + mode: str | None, city: str | None, language: str | None,
46 + level: str | None, source: str | None,
47 + free: int | None, price_max: float | None,
48 + credential: str | None, starts_after: str | None,
49 + q: str | None) -> str:
50 + if training_type:
51 + sql += " AND training_type=?"; args.append(training_type)
52 + if category:
53 + sql += " AND category LIKE ?"; args.append(f"%{category}%")
54 + if mode:
55 + sql += " AND mode=?"; args.append(mode)
56 + if city:
57 + sql += " AND city LIKE ?"; args.append(f"%{city}%")
58 + if language:
59 + sql += " AND language=?"; args.append(language)
60 + if level:
61 + sql += " AND level=?"; args.append(level)
62 + if source:
63 + sql += " AND source=?"; args.append(source)
64 + if free == 1:
65 + sql += " AND is_free=1"
66 + elif free == 0:
67 + sql += " AND is_free=0"
68 + if price_max is not None:
69 + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)
70 + if credential:
71 + sql += " AND credential LIKE ?"; args.append(f"%{credential}%")
72 + if starts_after:
73 + sql += " AND start_date IS NOT NULL AND start_date>=?"
74 + args.append(starts_after)
75 + if q:
76 + sql += (" AND (title LIKE ? OR description LIKE ? OR category LIKE ?"
77 + " OR code LIKE ? OR tags LIKE ?)")
78 + args += [f"%{q}%"] * 5
79 + return sql
80 +
81 +
82 +@app.get("/api/formations")
83 +def list_formations(
84 + training_type: str | None = None, # Cours universitaire, Séminaire…
85 + category: str | None = None, # domaine (LIKE)
86 + mode: str | None = None, # en ligne | présentiel | hybride | asynchrone
87 + city: str | None = None,
88 + language: str | None = None, # fr | en | fr/en
89 + level: str | None = None, # débutant | intermédiaire | avancé
90 + source: str | None = None,
91 + free: int | None = None, # 1 = gratuites seulement
92 + price_max: float | None = None,
93 + credential: str | None = None, # attestation, UEC, certificat…
94 + starts_after: str | None = None, # ISO : prochaine séance à partir de
95 + q: str | None = None,
96 + active: int = 1,
97 + sort: str = "recent", # recent | price | title | start
98 + limit: int = Query(500, le=2000),
99 + offset: int = 0,
100 +):
101 + con = db.connect()
102 + sql = "SELECT * FROM formations WHERE 1=1"
103 + args: list = []
104 + if active in (0, 1):
105 + sql += " AND active=?"; args.append(active)
106 + sql = _apply_filters(sql, args, training_type, category, mode, city,
107 + language, level, source, free, price_max,
108 + credential, starts_after, q)
109 + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
110 + order = {
111 + "price": " ORDER BY price IS NULL, price ASC",
112 + "title": " ORDER BY title COLLATE NOCASE ASC",
113 + "start": " ORDER BY start_date IS NULL, start_date ASC",
114 + "recent": " ORDER BY first_seen DESC",
115 + }.get(sort, " ORDER BY first_seen DESC")
116 + sql += order + " LIMIT ? OFFSET ?"
117 + args += [limit, offset]
118 + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]
119 + con.close()
120 + return {"total": total, "count": len(rows), "formations": rows}
121 +
122 +
123 +@app.get("/api/formations/{uid:path}")
124 +def get_formation(uid: str):
125 + con = db.connect()
126 + row = con.execute("SELECT * FROM formations WHERE uid=?", (uid,)).fetchone()
127 + d = None
128 + if row is not None:
129 + d = _row_to_dict(row)
130 + d["price_history"] = [dict(r) for r in con.execute(
131 + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",
132 + (uid,)).fetchall()]
133 + # formations similaires : même source ou même catégorie
134 + d["similar"] = [
135 + {"uid": r["uid"], "title": r["title"], "training_type": r["training_type"],
136 + "source": r["source"], "price": r["price"], "mode": r["mode"],
137 + "duration": r["duration"]}
138 + for r in con.execute(
139 + "SELECT uid, title, training_type, source, price, mode, duration"
140 + " FROM formations WHERE active=1 AND uid<>? AND"
141 + " (category=? OR source=?) ORDER BY RANDOM() LIMIT 6",
142 + (uid, d.get("category") or "-", d["source"])).fetchall()]
143 + con.close()
144 + if d is None:
145 + raise HTTPException(404, "Formation introuvable")
146 + return d
147 +
148 +
149 +@app.get("/api/facets")
150 +def facets(training_type: str | None = None):
151 + """Valeurs distinctes pour construire les filtres du frontend."""
152 + con = db.connect()
153 + cat_sql = "SELECT category, COUNT(*) n FROM formations WHERE active=1 AND category<>''"
154 + cat_args: list = []
155 + if training_type:
156 + cat_sql += " AND training_type=?"
157 + cat_args.append(training_type)
158 + out = {
159 + "types": [dict(r) for r in con.execute(
160 + "SELECT training_type t, COUNT(*) n FROM formations"
161 + " WHERE active=1 AND training_type<>'' GROUP BY training_type ORDER BY n DESC")],
162 + "categories": [dict(r) for r in con.execute(
163 + cat_sql + " GROUP BY category ORDER BY n DESC LIMIT 60", cat_args)],
164 + "modes": [r["mode"] for r in con.execute(
165 + "SELECT DISTINCT mode FROM formations WHERE active=1 AND mode<>'' ORDER BY mode")],
166 + "cities": [r["city"] for r in con.execute(
167 + "SELECT DISTINCT city FROM formations WHERE active=1 AND city<>'' ORDER BY city")],
168 + "languages": [r["language"] for r in con.execute(
169 + "SELECT DISTINCT language FROM formations WHERE active=1 AND language<>'' ORDER BY language")],
170 + "levels": [r["level"] for r in con.execute(
171 + "SELECT DISTINCT level FROM formations WHERE active=1 AND level<>'' ORDER BY level")],
172 + "sources": [dict(r) for r in con.execute(
173 + "SELECT source, COUNT(*) n FROM formations WHERE active=1"
174 + " GROUP BY source ORDER BY n DESC")],
175 + }
176 + con.close()
177 + return out
178 +
179 +
180 +@app.get("/api/sources")
181 +def sources():
182 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
183 + con = db.connect()
184 + counts = {r["source"]: r["n"] for r in con.execute(
185 + "SELECT source, COUNT(*) n FROM formations WHERE active=1 GROUP BY source")}
186 + last = {r["source"]: r["ts"] for r in con.execute(
187 + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}
188 + con.close()
189 + for s in registry:
190 + s["active_formations"] = counts.get(s["id"], 0)
191 + s["last_sync"] = last.get(s["id"])
192 + return {"sources": registry}
193 +
194 +
195 +@app.get("/api/stats")
196 +def stats():
197 + con = db.connect()
198 + row = con.execute(
199 + """SELECT COUNT(*) total,
200 + SUM(CASE WHEN is_free=1 THEN 1 ELSE 0 END) gratuites,
201 + SUM(CASE WHEN mode='en ligne' OR mode='asynchrone'
202 + THEN 1 ELSE 0 END) en_ligne,
203 + SUM(CASE WHEN training_type='Cours universitaire'
204 + THEN 1 ELSE 0 END) universitaires,
205 + COUNT(DISTINCT source) sources,
206 + AVG(price) avg_price,
207 + AVG(duration_hours) avg_hours
208 + FROM formations WHERE active=1""").fetchone()
209 + par_type = [dict(r) for r in con.execute(
210 + "SELECT training_type t, COUNT(*) n FROM formations WHERE active=1"
211 + " GROUP BY training_type ORDER BY n DESC")]
212 + log = [dict(r) for r in con.execute(
213 + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
214 + con.close()
215 + return {**dict(row), "par_type": par_type, "recent_syncs": log}
216 +
217 +
218 +@app.post("/api/sync")
219 +def trigger_sync(background: BackgroundTasks, source: str | None = None):
220 + """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
221 + def _job():
222 + with _sync_lock:
223 + ingest.run([source] if source else None)
224 + background.add_task(_job)
225 + return {"status": "démarré", "source": source or "toutes"}
226 +
227 +
228 +# --- Frontend React (build Vite) --------------------------------------------
229 +if FRONTEND_DIST.exists():
230 + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
231 +
232 + @app.get("/{full_path:path}")
233 + def spa(full_path: str):
234 + target = FRONTEND_DIST / full_path
235 + if full_path and target.is_file():
236 + return FileResponse(target)
237 + return FileResponse(FRONTEND_DIST / "index.html")
added frontend/index.html +27 −0
@@ -0,0 +1,27 @@
1 +<!doctype html>
2 +<!-- ---------------------------------------------------------------------------
3 + Forma-Ka — Agrégateur de formations (province de Québec)
4 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +---------------------------------------------------------------------------- -->
6 +<html lang="fr">
7 + <head>
8 + <meta charset="UTF-8" />
9 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 + <title>Forma-Ka — Toutes les formations du Québec</title>
11 + <meta name="description" content="Forma-Ka agrège les cours en ligne, cours universitaires et collégiaux, séminaires, ateliers et certifications offerts au Québec, toujours à jour." />
12 + <meta name="theme-color" content="#f5f3ee" />
13 + <meta name="mobile-web-app-capable" content="yes" />
14 + <meta name="apple-mobile-web-app-capable" content="yes" />
15 + <meta name="apple-mobile-web-app-status-bar-style" content="default" />
16 + <meta name="apple-mobile-web-app-title" content="Forma-Ka" />
17 + <link rel="manifest" href="/manifest.webmanifest" />
18 + <link rel="preconnect" href="https://fonts.googleapis.com" />
19 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
20 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
21 + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23141814'/%3E%3Ctext x='32' y='45' font-family='Arial Black,sans-serif' font-size='32' font-weight='900' fill='%23ffd54d' text-anchor='middle'%3EFK%3C/text%3E%3C/svg%3E" />
22 + </head>
23 + <body>
24 + <div id="root"></div>
25 + <script type="module" src="/src/main.tsx"></script>
26 + </body>
27 +</html>
added frontend/package-lock.json +1834 −0
@@ -0,0 +1,1834 @@
1 +{
2 + "name": "forma-ka-frontend",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "forma-ka-frontend",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "react": "^18.3.1",
12 + "react-dom": "^18.3.1",
13 + "react-router-dom": "^6.26.0"
14 + },
15 + "devDependencies": {
16 + "@types/react": "^18.3.3",
17 + "@types/react-dom": "^18.3.0",
18 + "@vitejs/plugin-react": "^4.3.1",
19 + "typescript": "^5.5.4",
20 + "vite": "^5.4.0"
21 + }
22 + },
23 + "node_modules/@babel/code-frame": {
24 + "version": "7.29.7",
25 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
26 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
27 + "dev": true,
28 + "license": "MIT",
29 + "dependencies": {
30 + "@babel/helper-validator-identifier": "^7.29.7",
31 + "js-tokens": "^4.0.0",
32 + "picocolors": "^1.1.1"
33 + },
34 + "engines": {
35 + "node": ">=6.9.0"
36 + }
37 + },
38 + "node_modules/@babel/compat-data": {
39 + "version": "7.29.7",
40 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
41 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
42 + "dev": true,
43 + "license": "MIT",
44 + "engines": {
45 + "node": ">=6.9.0"
46 + }
47 + },
48 + "node_modules/@babel/core": {
49 + "version": "7.29.7",
50 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
51 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
52 + "dev": true,
53 + "license": "MIT",
54 + "dependencies": {
55 + "@babel/code-frame": "^7.29.7",
56 + "@babel/generator": "^7.29.7",
57 + "@babel/helper-compilation-targets": "^7.29.7",
58 + "@babel/helper-module-transforms": "^7.29.7",
59 + "@babel/helpers": "^7.29.7",
60 + "@babel/parser": "^7.29.7",
61 + "@babel/template": "^7.29.7",
62 + "@babel/traverse": "^7.29.7",
63 + "@babel/types": "^7.29.7",
64 + "@jridgewell/remapping": "^2.3.5",
65 + "convert-source-map": "^2.0.0",
66 + "debug": "^4.1.0",
67 + "gensync": "^1.0.0-beta.2",
68 + "json5": "^2.2.3",
69 + "semver": "^6.3.1"
70 + },
71 + "engines": {
72 + "node": ">=6.9.0"
73 + },
74 + "funding": {
75 + "type": "opencollective",
76 + "url": "https://opencollective.com/babel"
77 + }
78 + },
79 + "node_modules/@babel/generator": {
80 + "version": "7.29.8",
81 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
82 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
83 + "dev": true,
84 + "license": "MIT",
85 + "dependencies": {
86 + "@babel/parser": "^7.29.8",
87 + "@babel/types": "^7.29.8",
88 + "@jridgewell/gen-mapping": "^0.3.12",
89 + "@jridgewell/trace-mapping": "^0.3.28",
90 + "jsesc": "^3.0.2"
91 + },
92 + "engines": {
93 + "node": ">=6.9.0"
94 + }
95 + },
96 + "node_modules/@babel/helper-compilation-targets": {
97 + "version": "7.29.7",
98 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
99 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
100 + "dev": true,
101 + "license": "MIT",
102 + "dependencies": {
103 + "@babel/compat-data": "^7.29.7",
104 + "@babel/helper-validator-option": "^7.29.7",
105 + "browserslist": "^4.24.0",
106 + "lru-cache": "^5.1.1",
107 + "semver": "^6.3.1"
108 + },
109 + "engines": {
110 + "node": ">=6.9.0"
111 + }
112 + },
113 + "node_modules/@babel/helper-globals": {
114 + "version": "7.29.7",
115 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
116 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
117 + "dev": true,
118 + "license": "MIT",
119 + "engines": {
120 + "node": ">=6.9.0"
121 + }
122 + },
123 + "node_modules/@babel/helper-module-imports": {
124 + "version": "7.29.7",
125 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
126 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
127 + "dev": true,
128 + "license": "MIT",
129 + "dependencies": {
130 + "@babel/traverse": "^7.29.7",
131 + "@babel/types": "^7.29.7"
132 + },
133 + "engines": {
134 + "node": ">=6.9.0"
135 + }
136 + },
137 + "node_modules/@babel/helper-module-transforms": {
138 + "version": "7.29.7",
139 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
140 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
141 + "dev": true,
142 + "license": "MIT",
143 + "dependencies": {
144 + "@babel/helper-module-imports": "^7.29.7",
145 + "@babel/helper-validator-identifier": "^7.29.7",
146 + "@babel/traverse": "^7.29.7"
147 + },
148 + "engines": {
149 + "node": ">=6.9.0"
150 + },
151 + "peerDependencies": {
152 + "@babel/core": "^7.0.0"
153 + }
154 + },
155 + "node_modules/@babel/helper-plugin-utils": {
156 + "version": "7.29.7",
157 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
158 + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
159 + "dev": true,
160 + "license": "MIT",
161 + "engines": {
162 + "node": ">=6.9.0"
163 + }
164 + },
165 + "node_modules/@babel/helper-string-parser": {
166 + "version": "7.29.7",
167 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
168 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
169 + "dev": true,
170 + "license": "MIT",
171 + "engines": {
172 + "node": ">=6.9.0"
173 + }
174 + },
175 + "node_modules/@babel/helper-validator-identifier": {
176 + "version": "7.29.7",
177 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
178 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
179 + "dev": true,
180 + "license": "MIT",
181 + "engines": {
182 + "node": ">=6.9.0"
183 + }
184 + },
185 + "node_modules/@babel/helper-validator-option": {
186 + "version": "7.29.7",
187 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
188 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
189 + "dev": true,
190 + "license": "MIT",
191 + "engines": {
192 + "node": ">=6.9.0"
193 + }
194 + },
195 + "node_modules/@babel/helpers": {
196 + "version": "7.29.7",
197 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
198 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
199 + "dev": true,
200 + "license": "MIT",
201 + "dependencies": {
202 + "@babel/template": "^7.29.7",
203 + "@babel/types": "^7.29.7"
204 + },
205 + "engines": {
206 + "node": ">=6.9.0"
207 + }
208 + },
209 + "node_modules/@babel/parser": {
210 + "version": "7.29.8",
211 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
212 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
213 + "dev": true,
214 + "license": "MIT",
215 + "dependencies": {
216 + "@babel/types": "^7.29.8"
217 + },
218 + "bin": {
219 + "parser": "bin/babel-parser.js"
220 + },
221 + "engines": {
222 + "node": ">=6.0.0"
223 + }
224 + },
225 + "node_modules/@babel/plugin-transform-react-jsx-self": {
226 + "version": "7.29.7",
227 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
228 + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
229 + "dev": true,
230 + "license": "MIT",
231 + "dependencies": {
232 + "@babel/helper-plugin-utils": "^7.29.7"
233 + },
234 + "engines": {
235 + "node": ">=6.9.0"
236 + },
237 + "peerDependencies": {
238 + "@babel/core": "^7.0.0-0"
239 + }
240 + },
241 + "node_modules/@babel/plugin-transform-react-jsx-source": {
242 + "version": "7.29.7",
243 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
244 + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
245 + "dev": true,
246 + "license": "MIT",
247 + "dependencies": {
248 + "@babel/helper-plugin-utils": "^7.29.7"
249 + },
250 + "engines": {
251 + "node": ">=6.9.0"
252 + },
253 + "peerDependencies": {
254 + "@babel/core": "^7.0.0-0"
255 + }
256 + },
257 + "node_modules/@babel/template": {
258 + "version": "7.29.7",
259 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
260 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
261 + "dev": true,
262 + "license": "MIT",
263 + "dependencies": {
264 + "@babel/code-frame": "^7.29.7",
265 + "@babel/parser": "^7.29.7",
266 + "@babel/types": "^7.29.7"
267 + },
268 + "engines": {
269 + "node": ">=6.9.0"
270 + }
271 + },
272 + "node_modules/@babel/traverse": {
273 + "version": "7.29.8",
274 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
275 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
276 + "dev": true,
277 + "license": "MIT",
278 + "dependencies": {
279 + "@babel/code-frame": "^7.29.7",
280 + "@babel/generator": "^7.29.8",
281 + "@babel/helper-globals": "^7.29.7",
282 + "@babel/parser": "^7.29.8",
283 + "@babel/template": "^7.29.7",
284 + "@babel/types": "^7.29.8",
285 + "debug": "^4.3.1"
286 + },
287 + "engines": {
288 + "node": ">=6.9.0"
289 + }
290 + },
291 + "node_modules/@babel/types": {
292 + "version": "7.29.8",
293 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
294 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
295 + "dev": true,
296 + "license": "MIT",
297 + "dependencies": {
298 + "@babel/helper-string-parser": "^7.29.7",
299 + "@babel/helper-validator-identifier": "^7.29.7"
300 + },
301 + "engines": {
302 + "node": ">=6.9.0"
303 + }
304 + },
305 + "node_modules/@esbuild/aix-ppc64": {
306 + "version": "0.21.5",
307 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
308 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
309 + "cpu": [
310 + "ppc64"
311 + ],
312 + "dev": true,
313 + "license": "MIT",
314 + "optional": true,
315 + "os": [
316 + "aix"
317 + ],
318 + "engines": {
319 + "node": ">=12"
320 + }
321 + },
322 + "node_modules/@esbuild/android-arm": {
323 + "version": "0.21.5",
324 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
325 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
326 + "cpu": [
327 + "arm"
328 + ],
329 + "dev": true,
330 + "license": "MIT",
331 + "optional": true,
332 + "os": [
333 + "android"
334 + ],
335 + "engines": {
336 + "node": ">=12"
337 + }
338 + },
339 + "node_modules/@esbuild/android-arm64": {
340 + "version": "0.21.5",
341 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
342 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
343 + "cpu": [
344 + "arm64"
345 + ],
346 + "dev": true,
347 + "license": "MIT",
348 + "optional": true,
349 + "os": [
350 + "android"
351 + ],
352 + "engines": {
353 + "node": ">=12"
354 + }
355 + },
356 + "node_modules/@esbuild/android-x64": {
357 + "version": "0.21.5",
358 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
359 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
360 + "cpu": [
361 + "x64"
362 + ],
363 + "dev": true,
364 + "license": "MIT",
365 + "optional": true,
366 + "os": [
367 + "android"
368 + ],
369 + "engines": {
370 + "node": ">=12"
371 + }
372 + },
373 + "node_modules/@esbuild/darwin-arm64": {
374 + "version": "0.21.5",
375 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
376 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
377 + "cpu": [
378 + "arm64"
379 + ],
380 + "dev": true,
381 + "license": "MIT",
382 + "optional": true,
383 + "os": [
384 + "darwin"
385 + ],
386 + "engines": {
387 + "node": ">=12"
388 + }
389 + },
390 + "node_modules/@esbuild/darwin-x64": {
391 + "version": "0.21.5",
392 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
393 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
394 + "cpu": [
395 + "x64"
396 + ],
397 + "dev": true,
398 + "license": "MIT",
399 + "optional": true,
400 + "os": [
401 + "darwin"
402 + ],
403 + "engines": {
404 + "node": ">=12"
405 + }
406 + },
407 + "node_modules/@esbuild/freebsd-arm64": {
408 + "version": "0.21.5",
409 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
410 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
411 + "cpu": [
412 + "arm64"
413 + ],
414 + "dev": true,
415 + "license": "MIT",
416 + "optional": true,
417 + "os": [
418 + "freebsd"
419 + ],
420 + "engines": {
421 + "node": ">=12"
422 + }
423 + },
424 + "node_modules/@esbuild/freebsd-x64": {
425 + "version": "0.21.5",
426 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
427 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
428 + "cpu": [
429 + "x64"
430 + ],
431 + "dev": true,
432 + "license": "MIT",
433 + "optional": true,
434 + "os": [
435 + "freebsd"
436 + ],
437 + "engines": {
438 + "node": ">=12"
439 + }
440 + },
441 + "node_modules/@esbuild/linux-arm": {
442 + "version": "0.21.5",
443 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
444 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
445 + "cpu": [
446 + "arm"
447 + ],
448 + "dev": true,
449 + "license": "MIT",
450 + "optional": true,
451 + "os": [
452 + "linux"
453 + ],
454 + "engines": {
455 + "node": ">=12"
456 + }
457 + },
458 + "node_modules/@esbuild/linux-arm64": {
459 + "version": "0.21.5",
460 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
461 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
462 + "cpu": [
463 + "arm64"
464 + ],
465 + "dev": true,
466 + "license": "MIT",
467 + "optional": true,
468 + "os": [
469 + "linux"
470 + ],
471 + "engines": {
472 + "node": ">=12"
473 + }
474 + },
475 + "node_modules/@esbuild/linux-ia32": {
476 + "version": "0.21.5",
477 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
478 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
479 + "cpu": [
480 + "ia32"
481 + ],
482 + "dev": true,
483 + "license": "MIT",
484 + "optional": true,
485 + "os": [
486 + "linux"
487 + ],
488 + "engines": {
489 + "node": ">=12"
490 + }
491 + },
492 + "node_modules/@esbuild/linux-loong64": {
493 + "version": "0.21.5",
494 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
495 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
496 + "cpu": [
497 + "loong64"
498 + ],
499 + "dev": true,
500 + "license": "MIT",
501 + "optional": true,
502 + "os": [
503 + "linux"
504 + ],
505 + "engines": {
506 + "node": ">=12"
507 + }
508 + },
509 + "node_modules/@esbuild/linux-mips64el": {
510 + "version": "0.21.5",
511 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
512 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
513 + "cpu": [
514 + "mips64el"
515 + ],
516 + "dev": true,
517 + "license": "MIT",
518 + "optional": true,
519 + "os": [
520 + "linux"
521 + ],
522 + "engines": {
523 + "node": ">=12"
524 + }
525 + },
526 + "node_modules/@esbuild/linux-ppc64": {
527 + "version": "0.21.5",
528 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
529 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
530 + "cpu": [
531 + "ppc64"
532 + ],
533 + "dev": true,
534 + "license": "MIT",
535 + "optional": true,
536 + "os": [
537 + "linux"
538 + ],
539 + "engines": {
540 + "node": ">=12"
541 + }
542 + },
543 + "node_modules/@esbuild/linux-riscv64": {
544 + "version": "0.21.5",
545 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
546 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
547 + "cpu": [
548 + "riscv64"
549 + ],
550 + "dev": true,
551 + "license": "MIT",
552 + "optional": true,
553 + "os": [
554 + "linux"
555 + ],
556 + "engines": {
557 + "node": ">=12"
558 + }
559 + },
560 + "node_modules/@esbuild/linux-s390x": {
561 + "version": "0.21.5",
562 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
563 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
564 + "cpu": [
565 + "s390x"
566 + ],
567 + "dev": true,
568 + "license": "MIT",
569 + "optional": true,
570 + "os": [
571 + "linux"
572 + ],
573 + "engines": {
574 + "node": ">=12"
575 + }
576 + },
577 + "node_modules/@esbuild/linux-x64": {
578 + "version": "0.21.5",
579 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
580 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
581 + "cpu": [
582 + "x64"
583 + ],
584 + "dev": true,
585 + "license": "MIT",
586 + "optional": true,
587 + "os": [
588 + "linux"
589 + ],
590 + "engines": {
591 + "node": ">=12"
592 + }
593 + },
594 + "node_modules/@esbuild/netbsd-x64": {
595 + "version": "0.21.5",
596 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
597 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
598 + "cpu": [
599 + "x64"
600 + ],
601 + "dev": true,
602 + "license": "MIT",
603 + "optional": true,
604 + "os": [
605 + "netbsd"
606 + ],
607 + "engines": {
608 + "node": ">=12"
609 + }
610 + },
611 + "node_modules/@esbuild/openbsd-x64": {
612 + "version": "0.21.5",
613 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
614 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
615 + "cpu": [
616 + "x64"
617 + ],
618 + "dev": true,
619 + "license": "MIT",
620 + "optional": true,
621 + "os": [
622 + "openbsd"
623 + ],
624 + "engines": {
625 + "node": ">=12"
626 + }
627 + },
628 + "node_modules/@esbuild/sunos-x64": {
629 + "version": "0.21.5",
630 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
631 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
632 + "cpu": [
633 + "x64"
634 + ],
635 + "dev": true,
636 + "license": "MIT",
637 + "optional": true,
638 + "os": [
639 + "sunos"
640 + ],
641 + "engines": {
642 + "node": ">=12"
643 + }
644 + },
645 + "node_modules/@esbuild/win32-arm64": {
646 + "version": "0.21.5",
647 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
648 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
649 + "cpu": [
650 + "arm64"
651 + ],
652 + "dev": true,
653 + "license": "MIT",
654 + "optional": true,
655 + "os": [
656 + "win32"
657 + ],
658 + "engines": {
659 + "node": ">=12"
660 + }
661 + },
662 + "node_modules/@esbuild/win32-ia32": {
663 + "version": "0.21.5",
664 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
665 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
666 + "cpu": [
667 + "ia32"
668 + ],
669 + "dev": true,
670 + "license": "MIT",
671 + "optional": true,
672 + "os": [
673 + "win32"
674 + ],
675 + "engines": {
676 + "node": ">=12"
677 + }
678 + },
679 + "node_modules/@esbuild/win32-x64": {
680 + "version": "0.21.5",
681 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
682 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
683 + "cpu": [
684 + "x64"
685 + ],
686 + "dev": true,
687 + "license": "MIT",
688 + "optional": true,
689 + "os": [
690 + "win32"
691 + ],
692 + "engines": {
693 + "node": ">=12"
694 + }
695 + },
696 + "node_modules/@jridgewell/gen-mapping": {
697 + "version": "0.3.13",
698 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
699 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
700 + "dev": true,
701 + "license": "MIT",
702 + "dependencies": {
703 + "@jridgewell/sourcemap-codec": "^1.5.0",
704 + "@jridgewell/trace-mapping": "^0.3.24"
705 + }
706 + },
707 + "node_modules/@jridgewell/remapping": {
708 + "version": "2.3.5",
709 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
710 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
711 + "dev": true,
712 + "license": "MIT",
713 + "dependencies": {
714 + "@jridgewell/gen-mapping": "^0.3.5",
715 + "@jridgewell/trace-mapping": "^0.3.24"
716 + }
717 + },
718 + "node_modules/@jridgewell/resolve-uri": {
719 + "version": "3.1.2",
720 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
721 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
722 + "dev": true,
723 + "license": "MIT",
724 + "engines": {
725 + "node": ">=6.0.0"
726 + }
727 + },
728 + "node_modules/@jridgewell/sourcemap-codec": {
729 + "version": "1.5.5",
730 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
731 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
732 + "dev": true,
733 + "license": "MIT"
734 + },
735 + "node_modules/@jridgewell/trace-mapping": {
736 + "version": "0.3.31",
737 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
738 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
739 + "dev": true,
740 + "license": "MIT",
741 + "dependencies": {
742 + "@jridgewell/resolve-uri": "^3.1.0",
743 + "@jridgewell/sourcemap-codec": "^1.4.14"
744 + }
745 + },
746 + "node_modules/@napi-rs/lzma-linux-x64-gnu": {
747 + "version": "1.5.1",
748 + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
749 + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
750 + "cpu": [
751 + "x64"
752 + ],
753 + "dev": true,
754 + "libc": [
755 + "glibc"
756 + ],
757 + "license": "MIT",
758 + "optional": true,
759 + "os": [
760 + "linux"
761 + ],
762 + "engines": {
763 + "node": "^22.20 || ^24.12 || >=25"
764 + }
765 + },
766 + "node_modules/@remix-run/router": {
767 + "version": "1.23.3",
768 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
769 + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
770 + "license": "MIT",
771 + "engines": {
772 + "node": ">=14.0.0"
773 + }
774 + },
775 + "node_modules/@rolldown/pluginutils": {
776 + "version": "1.0.0-beta.27",
777 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
778 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
779 + "dev": true,
780 + "license": "MIT"
781 + },
782 + "node_modules/@rollup/rollup-android-arm-eabi": {
783 + "version": "4.62.4",
784 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
785 + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
786 + "cpu": [
787 + "arm"
788 + ],
789 + "dev": true,
790 + "license": "MIT",
791 + "optional": true,
792 + "os": [
793 + "android"
794 + ]
795 + },
796 + "node_modules/@rollup/rollup-android-arm64": {
797 + "version": "4.62.4",
798 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
799 + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
800 + "cpu": [
801 + "arm64"
802 + ],
803 + "dev": true,
804 + "license": "MIT",
805 + "optional": true,
806 + "os": [
807 + "android"
808 + ]
809 + },
810 + "node_modules/@rollup/rollup-darwin-arm64": {
811 + "version": "4.62.4",
812 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
813 + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
814 + "cpu": [
815 + "arm64"
816 + ],
817 + "dev": true,
818 + "license": "MIT",
819 + "optional": true,
820 + "os": [
821 + "darwin"
822 + ]
823 + },
824 + "node_modules/@rollup/rollup-darwin-x64": {
825 + "version": "4.62.4",
826 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
827 + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
828 + "cpu": [
829 + "x64"
830 + ],
831 + "dev": true,
832 + "license": "MIT",
833 + "optional": true,
834 + "os": [
835 + "darwin"
836 + ]
837 + },
838 + "node_modules/@rollup/rollup-freebsd-arm64": {
839 + "version": "4.62.4",
840 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
841 + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
842 + "cpu": [
843 + "arm64"
844 + ],
845 + "dev": true,
846 + "license": "MIT",
847 + "optional": true,
848 + "os": [
849 + "freebsd"
850 + ]
851 + },
852 + "node_modules/@rollup/rollup-freebsd-x64": {
853 + "version": "4.62.4",
854 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
855 + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
856 + "cpu": [
857 + "x64"
858 + ],
859 + "dev": true,
860 + "license": "MIT",
861 + "optional": true,
862 + "os": [
863 + "freebsd"
864 + ]
865 + },
866 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
867 + "version": "4.62.4",
868 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
869 + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
870 + "cpu": [
871 + "arm"
872 + ],
873 + "dev": true,
874 + "libc": [
875 + "glibc"
876 + ],
877 + "license": "MIT",
878 + "optional": true,
879 + "os": [
880 + "linux"
881 + ]
882 + },
883 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
884 + "version": "4.62.4",
885 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
886 + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
887 + "cpu": [
888 + "arm"
889 + ],
890 + "dev": true,
891 + "libc": [
892 + "musl"
893 + ],
894 + "license": "MIT",
895 + "optional": true,
896 + "os": [
897 + "linux"
898 + ]
899 + },
900 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
901 + "version": "4.62.4",
902 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
903 + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
904 + "cpu": [
905 + "arm64"
906 + ],
907 + "dev": true,
908 + "libc": [
909 + "glibc"
910 + ],
911 + "license": "MIT",
912 + "optional": true,
913 + "os": [
914 + "linux"
915 + ]
916 + },
917 + "node_modules/@rollup/rollup-linux-arm64-musl": {
918 + "version": "4.62.4",
919 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
920 + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
921 + "cpu": [
922 + "arm64"
923 + ],
924 + "dev": true,
925 + "libc": [
926 + "musl"
927 + ],
928 + "license": "MIT",
929 + "optional": true,
930 + "os": [
931 + "linux"
932 + ]
933 + },
934 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
935 + "version": "4.62.4",
936 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
937 + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
938 + "cpu": [
939 + "loong64"
940 + ],
941 + "dev": true,
942 + "libc": [
943 + "glibc"
944 + ],
945 + "license": "MIT",
946 + "optional": true,
947 + "os": [
948 + "linux"
949 + ]
950 + },
951 + "node_modules/@rollup/rollup-linux-loong64-musl": {
952 + "version": "4.62.4",
953 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
954 + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
955 + "cpu": [
956 + "loong64"
957 + ],
958 + "dev": true,
959 + "libc": [
960 + "musl"
961 + ],
962 + "license": "MIT",
963 + "optional": true,
964 + "os": [
965 + "linux"
966 + ]
967 + },
968 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
969 + "version": "4.62.4",
970 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
971 + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
972 + "cpu": [
973 + "ppc64"
974 + ],
975 + "dev": true,
976 + "libc": [
977 + "glibc"
978 + ],
979 + "license": "MIT",
980 + "optional": true,
981 + "os": [
982 + "linux"
983 + ]
984 + },
985 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
986 + "version": "4.62.4",
987 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
988 + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
989 + "cpu": [
990 + "ppc64"
991 + ],
992 + "dev": true,
993 + "libc": [
994 + "musl"
995 + ],
996 + "license": "MIT",
997 + "optional": true,
998 + "os": [
999 + "linux"
1000 + ]
1001 + },
1002 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1003 + "version": "4.62.4",
1004 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
1005 + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
1006 + "cpu": [
1007 + "riscv64"
1008 + ],
1009 + "dev": true,
1010 + "libc": [
1011 + "glibc"
1012 + ],
1013 + "license": "MIT",
1014 + "optional": true,
1015 + "os": [
1016 + "linux"
1017 + ]
1018 + },
1019 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
1020 + "version": "4.62.4",
1021 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
1022 + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
1023 + "cpu": [
1024 + "riscv64"
1025 + ],
1026 + "dev": true,
1027 + "libc": [
1028 + "musl"
1029 + ],
1030 + "license": "MIT",
1031 + "optional": true,
1032 + "os": [
1033 + "linux"
1034 + ]
1035 + },
1036 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
1037 + "version": "4.62.4",
1038 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1039 + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1040 + "cpu": [
1041 + "s390x"
1042 + ],
1043 + "dev": true,
1044 + "libc": [
1045 + "glibc"
1046 + ],
1047 + "license": "MIT",
1048 + "optional": true,
1049 + "os": [
1050 + "linux"
1051 + ]
1052 + },
1053 + "node_modules/@rollup/rollup-linux-x64-gnu": {
1054 + "version": "4.62.4",
1055 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1056 + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1057 + "cpu": [
1058 + "x64"
1059 + ],
1060 + "dev": true,
1061 + "libc": [
1062 + "glibc"
1063 + ],
1064 + "license": "MIT",
1065 + "optional": true,
1066 + "os": [
1067 + "linux"
1068 + ]
1069 + },
1070 + "node_modules/@rollup/rollup-linux-x64-musl": {
1071 + "version": "4.62.4",
1072 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1073 + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1074 + "cpu": [
1075 + "x64"
1076 + ],
1077 + "dev": true,
1078 + "libc": [
1079 + "musl"
1080 + ],
1081 + "license": "MIT",
1082 + "optional": true,
1083 + "os": [
1084 + "linux"
1085 + ]
1086 + },
1087 + "node_modules/@rollup/rollup-openbsd-x64": {
1088 + "version": "4.62.4",
1089 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1090 + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1091 + "cpu": [
1092 + "x64"
1093 + ],
1094 + "dev": true,
1095 + "license": "MIT",
1096 + "optional": true,
1097 + "os": [
1098 + "openbsd"
1099 + ]
1100 + },
1101 + "node_modules/@rollup/rollup-openharmony-arm64": {
1102 + "version": "4.62.4",
1103 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1104 + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1105 + "cpu": [
1106 + "arm64"
1107 + ],
1108 + "dev": true,
1109 + "license": "MIT",
1110 + "optional": true,
1111 + "os": [
1112 + "openharmony"
1113 + ]
1114 + },
1115 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
1116 + "version": "4.62.4",
1117 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1118 + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1119 + "cpu": [
1120 + "arm64"
1121 + ],
1122 + "dev": true,
1123 + "license": "MIT",
1124 + "optional": true,
1125 + "os": [
1126 + "win32"
1127 + ]
1128 + },
1129 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
1130 + "version": "4.62.4",
1131 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1132 + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1133 + "cpu": [
1134 + "ia32"
1135 + ],
1136 + "dev": true,
1137 + "license": "MIT",
1138 + "optional": true,
1139 + "os": [
1140 + "win32"
1141 + ]
1142 + },
1143 + "node_modules/@rollup/rollup-win32-x64-gnu": {
1144 + "version": "4.62.4",
1145 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1146 + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1147 + "cpu": [
1148 + "x64"
1149 + ],
1150 + "dev": true,
1151 + "license": "MIT",
1152 + "optional": true,
1153 + "os": [
1154 + "win32"
1155 + ]
1156 + },
1157 + "node_modules/@rollup/rollup-win32-x64-msvc": {
1158 + "version": "4.62.4",
1159 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1160 + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1161 + "cpu": [
1162 + "x64"
1163 + ],
1164 + "dev": true,
1165 + "license": "MIT",
1166 + "optional": true,
1167 + "os": [
1168 + "win32"
1169 + ]
1170 + },
1171 + "node_modules/@types/babel__core": {
1172 + "version": "7.20.5",
1173 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1174 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1175 + "dev": true,
1176 + "license": "MIT",
1177 + "dependencies": {
1178 + "@babel/parser": "^7.20.7",
1179 + "@babel/types": "^7.20.7",
1180 + "@types/babel__generator": "*",
1181 + "@types/babel__template": "*",
1182 + "@types/babel__traverse": "*"
1183 + }
1184 + },
1185 + "node_modules/@types/babel__generator": {
1186 + "version": "7.27.0",
1187 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1188 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1189 + "dev": true,
1190 + "license": "MIT",
1191 + "dependencies": {
1192 + "@babel/types": "^7.0.0"
1193 + }
1194 + },
1195 + "node_modules/@types/babel__template": {
1196 + "version": "7.4.4",
1197 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1198 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1199 + "dev": true,
1200 + "license": "MIT",
1201 + "dependencies": {
1202 + "@babel/parser": "^7.1.0",
1203 + "@babel/types": "^7.0.0"
1204 + }
1205 + },
1206 + "node_modules/@types/babel__traverse": {
1207 + "version": "7.28.0",
1208 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1209 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1210 + "dev": true,
1211 + "license": "MIT",
1212 + "dependencies": {
1213 + "@babel/types": "^7.28.2"
1214 + }
1215 + },
1216 + "node_modules/@types/estree": {
1217 + "version": "1.0.9",
1218 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1219 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1220 + "dev": true,
1221 + "license": "MIT"
1222 + },
1223 + "node_modules/@types/prop-types": {
1224 + "version": "15.7.15",
1225 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1226 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1227 + "dev": true,
1228 + "license": "MIT"
1229 + },
1230 + "node_modules/@types/react": {
1231 + "version": "18.3.31",
1232 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1233 + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1234 + "dev": true,
1235 + "license": "MIT",
1236 + "dependencies": {
1237 + "@types/prop-types": "*",
1238 + "csstype": "^3.2.2"
1239 + }
1240 + },
1241 + "node_modules/@types/react-dom": {
1242 + "version": "18.3.7",
1243 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1244 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1245 + "dev": true,
1246 + "license": "MIT",
1247 + "peerDependencies": {
1248 + "@types/react": "^18.0.0"
1249 + }
1250 + },
1251 + "node_modules/@vitejs/plugin-react": {
1252 + "version": "4.7.0",
1253 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1254 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1255 + "dev": true,
1256 + "license": "MIT",
1257 + "dependencies": {
1258 + "@babel/core": "^7.28.0",
1259 + "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1260 + "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1261 + "@rolldown/pluginutils": "1.0.0-beta.27",
1262 + "@types/babel__core": "^7.20.5",
1263 + "react-refresh": "^0.17.0"
1264 + },
1265 + "engines": {
1266 + "node": "^14.18.0 || >=16.0.0"
1267 + },
1268 + "peerDependencies": {
1269 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1270 + }
1271 + },
1272 + "node_modules/baseline-browser-mapping": {
1273 + "version": "2.11.13",
1274 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
1275 + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
1276 + "dev": true,
1277 + "license": "Apache-2.0",
1278 + "bin": {
1279 + "baseline-browser-mapping": "dist/cli.cjs"
1280 + },
1281 + "engines": {
1282 + "node": ">=6.0.0"
1283 + }
1284 + },
1285 + "node_modules/browserslist": {
1286 + "version": "4.28.8",
1287 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
1288 + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
1289 + "dev": true,
1290 + "funding": [
1291 + {
1292 + "type": "opencollective",
1293 + "url": "https://opencollective.com/browserslist"
1294 + },
1295 + {
1296 + "type": "tidelift",
1297 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1298 + },
1299 + {
1300 + "type": "github",
1301 + "url": "https://github.com/sponsors/ai"
1302 + }
1303 + ],
1304 + "license": "MIT",
1305 + "dependencies": {
1306 + "baseline-browser-mapping": "^2.11.12",
1307 + "caniuse-lite": "^1.0.30001809",
1308 + "electron-to-chromium": "^1.5.402",
1309 + "node-releases": "^2.0.53",
1310 + "update-browserslist-db": "^1.3.0"
1311 + },
1312 + "bin": {
1313 + "browserslist": "cli.js"
1314 + },
1315 + "engines": {
1316 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1317 + }
1318 + },
1319 + "node_modules/caniuse-lite": {
1320 + "version": "1.0.30001809",
1321 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
1322 + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
1323 + "dev": true,
1324 + "funding": [
1325 + {
1326 + "type": "opencollective",
1327 + "url": "https://opencollective.com/browserslist"
1328 + },
1329 + {
1330 + "type": "tidelift",
1331 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1332 + },
1333 + {
1334 + "type": "github",
1335 + "url": "https://github.com/sponsors/ai"
1336 + }
1337 + ],
1338 + "license": "CC-BY-4.0"
1339 + },
1340 + "node_modules/convert-source-map": {
1341 + "version": "2.0.0",
1342 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1343 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1344 + "dev": true,
1345 + "license": "MIT"
1346 + },
1347 + "node_modules/csstype": {
1348 + "version": "3.2.3",
1349 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1350 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1351 + "dev": true,
1352 + "license": "MIT"
1353 + },
1354 + "node_modules/debug": {
1355 + "version": "4.4.3",
1356 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1357 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1358 + "dev": true,
1359 + "license": "MIT",
1360 + "dependencies": {
1361 + "ms": "^2.1.3"
1362 + },
1363 + "engines": {
1364 + "node": ">=6.0"
1365 + },
1366 + "peerDependenciesMeta": {
1367 + "supports-color": {
1368 + "optional": true
1369 + }
1370 + }
1371 + },
1372 + "node_modules/electron-to-chromium": {
1373 + "version": "1.5.405",
1374 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
1375 + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==",
1376 + "dev": true,
1377 + "license": "ISC"
1378 + },
1379 + "node_modules/esbuild": {
1380 + "version": "0.21.5",
1381 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1382 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1383 + "dev": true,
1384 + "hasInstallScript": true,
1385 + "license": "MIT",
1386 + "bin": {
1387 + "esbuild": "bin/esbuild"
1388 + },
1389 + "engines": {
1390 + "node": ">=12"
1391 + },
1392 + "optionalDependencies": {
1393 + "@esbuild/aix-ppc64": "0.21.5",
1394 + "@esbuild/android-arm": "0.21.5",
1395 + "@esbuild/android-arm64": "0.21.5",
1396 + "@esbuild/android-x64": "0.21.5",
1397 + "@esbuild/darwin-arm64": "0.21.5",
1398 + "@esbuild/darwin-x64": "0.21.5",
1399 + "@esbuild/freebsd-arm64": "0.21.5",
1400 + "@esbuild/freebsd-x64": "0.21.5",
1401 + "@esbuild/linux-arm": "0.21.5",
1402 + "@esbuild/linux-arm64": "0.21.5",
1403 + "@esbuild/linux-ia32": "0.21.5",
1404 + "@esbuild/linux-loong64": "0.21.5",
1405 + "@esbuild/linux-mips64el": "0.21.5",
1406 + "@esbuild/linux-ppc64": "0.21.5",
1407 + "@esbuild/linux-riscv64": "0.21.5",
1408 + "@esbuild/linux-s390x": "0.21.5",
1409 + "@esbuild/linux-x64": "0.21.5",
1410 + "@esbuild/netbsd-x64": "0.21.5",
1411 + "@esbuild/openbsd-x64": "0.21.5",
1412 + "@esbuild/sunos-x64": "0.21.5",
1413 + "@esbuild/win32-arm64": "0.21.5",
1414 + "@esbuild/win32-ia32": "0.21.5",
1415 + "@esbuild/win32-x64": "0.21.5"
1416 + }
1417 + },
1418 + "node_modules/escalade": {
1419 + "version": "3.2.0",
1420 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1421 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1422 + "dev": true,
1423 + "license": "MIT",
1424 + "engines": {
1425 + "node": ">=6"
1426 + }
1427 + },
1428 + "node_modules/fsevents": {
1429 + "version": "2.3.3",
1430 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1431 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1432 + "dev": true,
1433 + "hasInstallScript": true,
1434 + "license": "MIT",
1435 + "optional": true,
1436 + "os": [
1437 + "darwin"
1438 + ],
1439 + "engines": {
1440 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1441 + }
1442 + },
1443 + "node_modules/gensync": {
1444 + "version": "1.0.0-beta.2",
1445 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1446 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1447 + "dev": true,
1448 + "license": "MIT",
1449 + "engines": {
1450 + "node": ">=6.9.0"
1451 + }
1452 + },
1453 + "node_modules/js-tokens": {
1454 + "version": "4.0.0",
1455 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1456 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1457 + "license": "MIT"
1458 + },
1459 + "node_modules/jsesc": {
1460 + "version": "3.1.0",
1461 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1462 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1463 + "dev": true,
1464 + "license": "MIT",
1465 + "bin": {
1466 + "jsesc": "bin/jsesc"
1467 + },
1468 + "engines": {
1469 + "node": ">=6"
1470 + }
1471 + },
1472 + "node_modules/json5": {
1473 + "version": "2.2.3",
1474 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1475 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1476 + "dev": true,
1477 + "license": "MIT",
1478 + "bin": {
1479 + "json5": "lib/cli.js"
1480 + },
1481 + "engines": {
1482 + "node": ">=6"
1483 + }
1484 + },
1485 + "node_modules/loose-envify": {
1486 + "version": "1.4.0",
1487 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1488 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1489 + "license": "MIT",
1490 + "dependencies": {
1491 + "js-tokens": "^3.0.0 || ^4.0.0"
1492 + },
1493 + "bin": {
1494 + "loose-envify": "cli.js"
1495 + }
1496 + },
1497 + "node_modules/lru-cache": {
1498 + "version": "5.1.1",
1499 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1500 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1501 + "dev": true,
1502 + "license": "ISC",
1503 + "dependencies": {
1504 + "yallist": "^3.0.2"
1505 + }
1506 + },
1507 + "node_modules/ms": {
1508 + "version": "2.1.3",
1509 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1510 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1511 + "dev": true,
1512 + "license": "MIT"
1513 + },
1514 + "node_modules/nanoid": {
1515 + "version": "3.3.18",
1516 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1517 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1518 + "dev": true,
1519 + "funding": [
1520 + {
1521 + "type": "github",
1522 + "url": "https://github.com/sponsors/ai"
1523 + }
1524 + ],
1525 + "license": "MIT",
1526 + "bin": {
1527 + "nanoid": "bin/nanoid.cjs"
1528 + },
1529 + "engines": {
1530 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1531 + }
1532 + },
1533 + "node_modules/node-releases": {
1534 + "version": "2.0.53",
1535 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1536 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1537 + "dev": true,
1538 + "license": "MIT",
1539 + "engines": {
1540 + "node": ">=18"
1541 + }
1542 + },
1543 + "node_modules/picocolors": {
1544 + "version": "1.1.1",
1545 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1546 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1547 + "dev": true,
1548 + "license": "ISC"
1549 + },
1550 + "node_modules/postcss": {
1551 + "version": "8.5.26",
1552 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1553 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1554 + "dev": true,
1555 + "funding": [
1556 + {
1557 + "type": "opencollective",
1558 + "url": "https://opencollective.com/postcss/"
1559 + },
1560 + {
1561 + "type": "tidelift",
1562 + "url": "https://tidelift.com/funding/github/npm/postcss"
1563 + },
1564 + {
1565 + "type": "github",
1566 + "url": "https://github.com/sponsors/ai"
1567 + }
1568 + ],
1569 + "license": "MIT",
1570 + "dependencies": {
1571 + "nanoid": "^3.3.17",
1572 + "picocolors": "^1.1.1",
1573 + "source-map-js": "^1.2.1"
1574 + },
1575 + "engines": {
1576 + "node": "^10 || ^12 || >=14"
1577 + }
1578 + },
1579 + "node_modules/react": {
1580 + "version": "18.3.1",
1581 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1582 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1583 + "license": "MIT",
1584 + "dependencies": {
1585 + "loose-envify": "^1.1.0"
1586 + },
1587 + "engines": {
1588 + "node": ">=0.10.0"
1589 + }
1590 + },
1591 + "node_modules/react-dom": {
1592 + "version": "18.3.1",
1593 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1594 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1595 + "license": "MIT",
1596 + "dependencies": {
1597 + "loose-envify": "^1.1.0",
1598 + "scheduler": "^0.23.2"
1599 + },
1600 + "peerDependencies": {
1601 + "react": "^18.3.1"
1602 + }
1603 + },
1604 + "node_modules/react-refresh": {
1605 + "version": "0.17.0",
1606 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1607 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1608 + "dev": true,
1609 + "license": "MIT",
1610 + "engines": {
1611 + "node": ">=0.10.0"
1612 + }
1613 + },
1614 + "node_modules/react-router": {
1615 + "version": "6.30.4",
1616 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1617 + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1618 + "license": "MIT",
1619 + "dependencies": {
1620 + "@remix-run/router": "1.23.3"
1621 + },
1622 + "engines": {
1623 + "node": ">=14.0.0"
1624 + },
1625 + "peerDependencies": {
1626 + "react": ">=16.8"
1627 + }
1628 + },
1629 + "node_modules/react-router-dom": {
1630 + "version": "6.30.4",
1631 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1632 + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1633 + "license": "MIT",
1634 + "dependencies": {
1635 + "@remix-run/router": "1.23.3",
1636 + "react-router": "6.30.4"
1637 + },
1638 + "engines": {
1639 + "node": ">=14.0.0"
1640 + },
1641 + "peerDependencies": {
1642 + "react": ">=16.8",
1643 + "react-dom": ">=16.8"
1644 + }
1645 + },
1646 + "node_modules/rollup": {
1647 + "version": "4.62.4",
1648 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1649 + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1650 + "dev": true,
1651 + "license": "MIT",
1652 + "dependencies": {
1653 + "@types/estree": "1.0.9"
1654 + },
1655 + "bin": {
1656 + "rollup": "dist/bin/rollup"
1657 + },
1658 + "engines": {
1659 + "node": ">=18.0.0",
1660 + "npm": ">=8.0.0"
1661 + },
1662 + "optionalDependencies": {
1663 + "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1664 + "@rollup/rollup-android-arm-eabi": "4.62.4",
1665 + "@rollup/rollup-android-arm64": "4.62.4",
1666 + "@rollup/rollup-darwin-arm64": "4.62.4",
1667 + "@rollup/rollup-darwin-x64": "4.62.4",
1668 + "@rollup/rollup-freebsd-arm64": "4.62.4",
1669 + "@rollup/rollup-freebsd-x64": "4.62.4",
1670 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1671 + "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1672 + "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1673 + "@rollup/rollup-linux-arm64-musl": "4.62.4",
1674 + "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1675 + "@rollup/rollup-linux-loong64-musl": "4.62.4",
1676 + "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1677 + "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1678 + "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1679 + "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1680 + "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1681 + "@rollup/rollup-linux-x64-gnu": "4.62.4",
1682 + "@rollup/rollup-linux-x64-musl": "4.62.4",
1683 + "@rollup/rollup-openbsd-x64": "4.62.4",
1684 + "@rollup/rollup-openharmony-arm64": "4.62.4",
1685 + "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1686 + "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1687 + "@rollup/rollup-win32-x64-gnu": "4.62.4",
1688 + "@rollup/rollup-win32-x64-msvc": "4.62.4",
1689 + "fsevents": "~2.3.2"
1690 + }
1691 + },
1692 + "node_modules/scheduler": {
1693 + "version": "0.23.2",
1694 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1695 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1696 + "license": "MIT",
1697 + "dependencies": {
1698 + "loose-envify": "^1.1.0"
1699 + }
1700 + },
1701 + "node_modules/semver": {
1702 + "version": "6.3.1",
1703 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1704 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1705 + "dev": true,
1706 + "license": "ISC",
1707 + "bin": {
1708 + "semver": "bin/semver.js"
1709 + }
1710 + },
1711 + "node_modules/source-map-js": {
1712 + "version": "1.2.1",
1713 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1714 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1715 + "dev": true,
1716 + "license": "BSD-3-Clause",
1717 + "engines": {
1718 + "node": ">=0.10.0"
1719 + }
1720 + },
1721 + "node_modules/typescript": {
1722 + "version": "5.9.3",
1723 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1724 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1725 + "dev": true,
1726 + "license": "Apache-2.0",
1727 + "bin": {
1728 + "tsc": "bin/tsc",
1729 + "tsserver": "bin/tsserver"
1730 + },
1731 + "engines": {
1732 + "node": ">=14.17"
1733 + }
1734 + },
1735 + "node_modules/update-browserslist-db": {
1736 + "version": "1.3.1",
1737 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
1738 + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
1739 + "dev": true,
1740 + "funding": [
1741 + {
1742 + "type": "opencollective",
1743 + "url": "https://opencollective.com/browserslist"
1744 + },
1745 + {
1746 + "type": "tidelift",
1747 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1748 + },
1749 + {
1750 + "type": "github",
1751 + "url": "https://github.com/sponsors/ai"
1752 + }
1753 + ],
1754 + "license": "MIT",
1755 + "dependencies": {
1756 + "escalade": "^3.2.0",
1757 + "picocolors": "^1.1.1"
1758 + },
1759 + "bin": {
1760 + "update-browserslist-db": "cli.js"
1761 + },
1762 + "peerDependencies": {
1763 + "browserslist": ">= 4.21.0"
1764 + }
1765 + },
1766 + "node_modules/vite": {
1767 + "version": "5.4.21",
1768 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1769 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1770 + "dev": true,
1771 + "license": "MIT",
1772 + "dependencies": {
1773 + "esbuild": "^0.21.3",
1774 + "postcss": "^8.4.43",
1775 + "rollup": "^4.20.0"
1776 + },
1777 + "bin": {
1778 + "vite": "bin/vite.js"
1779 + },
1780 + "engines": {
1781 + "node": "^18.0.0 || >=20.0.0"
1782 + },
1783 + "funding": {
1784 + "url": "https://github.com/vitejs/vite?sponsor=1"
1785 + },
1786 + "optionalDependencies": {
1787 + "fsevents": "~2.3.3"
1788 + },
1789 + "peerDependencies": {
1790 + "@types/node": "^18.0.0 || >=20.0.0",
1791 + "less": "*",
1792 + "lightningcss": "^1.21.0",
1793 + "sass": "*",
1794 + "sass-embedded": "*",
1795 + "stylus": "*",
1796 + "sugarss": "*",
1797 + "terser": "^5.4.0"
1798 + },
1799 + "peerDependenciesMeta": {
1800 + "@types/node": {
1801 + "optional": true
1802 + },
1803 + "less": {
1804 + "optional": true
1805 + },
1806 + "lightningcss": {
1807 + "optional": true
1808 + },
1809 + "sass": {
1810 + "optional": true
1811 + },
1812 + "sass-embedded": {
1813 + "optional": true
1814 + },
1815 + "stylus": {
1816 + "optional": true
1817 + },
1818 + "sugarss": {
1819 + "optional": true
1820 + },
1821 + "terser": {
1822 + "optional": true
1823 + }
1824 + }
1825 + },
1826 + "node_modules/yallist": {
1827 + "version": "3.1.1",
1828 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1829 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1830 + "dev": true,
1831 + "license": "ISC"
1832 + }
1833 + }
1834 +}
added frontend/package.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "name": "forma-ka-frontend",
3 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
4 + "private": true,
5 + "version": "1.0.0",
6 + "type": "module",
7 + "scripts": {
8 + "dev": "vite",
9 + "build": "tsc -b && vite build",
10 + "preview": "vite preview"
11 + },
12 + "dependencies": {
13 + "react": "^18.3.1",
14 + "react-dom": "^18.3.1",
15 + "react-router-dom": "^6.26.0"
16 + },
17 + "devDependencies": {
18 + "@types/react": "^18.3.3",
19 + "@types/react-dom": "^18.3.0",
20 + "@vitejs/plugin-react": "^4.3.1",
21 + "typescript": "^5.5.4",
22 + "vite": "^5.4.0"
23 + }
24 +}
added frontend/public/manifest.webmanifest +18 −0
@@ -0,0 +1,18 @@
1 +{
2 + "name": "Forma-Ka — Toutes les formations du Québec",
3 + "short_name": "Forma-Ka",
4 + "description": "Cours en ligne, cours universitaires, séminaires, ateliers et certifications du Québec, un seul endroit.",
5 + "start_url": "/",
6 + "display": "standalone",
7 + "background_color": "#f5f3ee",
8 + "theme_color": "#f5f3ee",
9 + "lang": "fr",
10 + "icons": [
11 + {
12 + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Crect width='512' height='512' rx='96' fill='%23141814'/%3E%3Ctext x='256' y='356' font-family='Arial Black,sans-serif' font-size='256' font-weight='900' fill='%23ffd54d' text-anchor='middle'%3EFK%3C/text%3E%3C/svg%3E",
13 + "sizes": "512x512",
14 + "type": "image/svg+xml",
15 + "purpose": "any"
16 + }
17 + ]
18 +}
added frontend/src/App.tsx +184 −0
@@ -0,0 +1,184 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// App.tsx : layout global (header + ticker en direct + footer encre) et routage
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { NavLink, Route, Routes, useLocation } from "react-router-dom";
8 +import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
9 +import CookieConsent from "./components/CookieConsent";
10 +import Home from "./pages/Home";
11 +import FormationPage from "./pages/Formation";
12 +import PrivacyPage from "./pages/Privacy";
13 +import SourcesPage from "./pages/Sources";
14 +import StatsPage from "./pages/Stats";
15 +
16 +function Ticker() {
17 + const [items, setItems] = useState<string[]>([]);
18 +
19 + useEffect(() => {
20 + Promise.all([fetchStats(), fetchFacets(), fetchSources()])
21 + .then(([stats, facets, src]) => {
22 + registerSourceNames(src.sources);
23 + const parts: string[] = [`${stats.total} formations actives`];
24 + if ((stats.gratuites ?? 0) > 0) parts.push(`${stats.gratuites} gratuites`);
25 + if ((stats.en_ligne ?? 0) > 0) parts.push(`${stats.en_ligne} en ligne`);
26 + for (const t of (stats.par_type ?? []).slice(0, 5))
27 + parts.push(`${t.t} · ${t.n}`);
28 + for (const s of facets.sources.slice(0, 8))
29 + parts.push(`${sourceName(s.source)} · ${s.n}`);
30 + parts.push("Mise à jour automatique");
31 + setItems(parts);
32 + })
33 + .catch(() => setItems(["Forma-Ka — toutes les formations du Québec"]));
34 + }, []);
35 +
36 + if (items.length === 0) return null;
37 + // contenu doublé pour une boucle de défilement continue
38 + return (
39 + <div className="ticker" aria-hidden="true">
40 + <div className="ticker-track">
41 + {[...items, ...items].map((t, i) => (
42 + <span key={i}>{t}</span>
43 + ))}
44 + </div>
45 + </div>
46 + );
47 +}
48 +
49 +const NAV_LINKS = [
50 + { to: "/", label: "Formations", icon: "🎓", end: true },
51 + { to: "/stats", label: "Stats", icon: "📊", end: false },
52 + { to: "/sources", label: "Sources", icon: "🗂", end: false },
53 + { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false },
54 +];
55 +
56 +function Header() {
57 + const [open, setOpen] = useState(false);
58 + const location = useLocation();
59 +
60 + // fermer le menu à chaque navigation + verrouiller le défilement en dessous
61 + useEffect(() => { setOpen(false); }, [location]);
62 + useEffect(() => {
63 + document.body.style.overflow = open ? "hidden" : "";
64 + return () => { document.body.style.overflow = ""; };
65 + }, [open]);
66 +
67 + return (
68 + <>
69 + <header className="header">
70 + <div className="container header-inner">
71 + <NavLink to="/" className="brand" aria-label="Forma-Ka — accueil">
72 + Forma<span className="ka">Ka</span>
73 + <span className="brand-tag">Cours · séminaires · ateliers — tout le Québec</span>
74 + </NavLink>
75 + <nav className="nav" aria-label="Navigation principale">
76 + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
77 + Formations
78 + </NavLink>
79 + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
80 + Stats
81 + </NavLink>
82 + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>
83 + Sources
84 + </NavLink>
85 + </nav>
86 + <button
87 + className={`menu-btn ${open ? "open" : ""}`}
88 + aria-expanded={open}
89 + aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}
90 + onClick={() => setOpen(!open)}
91 + >
92 + <span /><span /><span />
93 + </button>
94 + </div>
95 +
96 + {/* menu déroulant mobile */}
97 + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">
98 + {NAV_LINKS.map((l, i) => (
99 + <NavLink
100 + key={l.to}
101 + to={l.to}
102 + end={l.end}
103 + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}
104 + className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}
105 + onClick={() => setOpen(false)}
106 + >
107 + <span className="mm-ico" aria-hidden="true">{l.icon}</span>
108 + {l.label}
109 + <span className="mm-arrow" aria-hidden="true">→</span>
110 + </NavLink>
111 + ))}
112 + <div className="mm-foot">
113 + Agrégateur indépendant — mis à jour automatiquement, chaque fiche
114 + renvoie à la formation originale.
115 + </div>
116 + </div>
117 + </header>
118 + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
119 + <Ticker />
120 + </>
121 + );
122 +}
123 +
124 +function Footer() {
125 + return (
126 + <footer className="footer">
127 + <div className="container">
128 + <div className="fbrand">
129 + Forma<span className="ka">Ka</span>
130 + </div>
131 + <div className="frow">
132 + <div>
133 + <b>Agrégateur indépendant</b> de formations dans la province de Québec —
134 + cours en ligne, cours universitaires et collégiaux, séminaires, ateliers,
135 + certifications. Les fiches proviennent des sites publics des établissements
136 + et sont rafraîchies automatiquement — chaque fiche renvoie vers la page
137 + originale de la formation.
138 + </div>
139 + </div>
140 + <div className="fmono">
141 + © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai
142 + {" · "}
143 + <NavLink to="/confidentialite">Confidentialité</NavLink>
144 + {" · "}
145 + <button
146 + className="flink"
147 + onClick={() => window.dispatchEvent(new Event("formaka:openConsent"))}
148 + >
149 + Gérer mes témoins
150 + </button>
151 + </div>
152 + </div>
153 + </footer>
154 + );
155 +}
156 +
157 +export default function App() {
158 + return (
159 + <>
160 + <Header />
161 + <main>
162 + <Routes>
163 + <Route path="/" element={<Home />} />
164 + <Route path="/formation/:uid" element={<FormationPage />} />
165 + <Route path="/stats" element={<StatsPage />} />
166 + <Route path="/sources" element={<SourcesPage />} />
167 + <Route path="/confidentialite" element={<PrivacyPage />} />
168 + <Route
169 + path="*"
170 + element={
171 + <div className="notice container">
172 + <div className="big">🧭</div>
173 + <h2>Page introuvable</h2>
174 + <p>Le lien demandé n'existe pas.</p>
175 + </div>
176 + }
177 + />
178 + </Routes>
179 + </main>
180 + <Footer />
181 + <CookieConsent />
182 + </>
183 + );
184 +}
added frontend/src/api.ts +178 −0
@@ -0,0 +1,178 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// api.ts : types + client API robuste (timeout, erreurs typées)
5 +// -----------------------------------------------------------------------------
6 +
7 +export interface FormationDetails {
8 + uec?: number;
9 + credits?: number;
10 + level?: string;
11 + duration_hours?: number;
12 + price_from?: boolean;
13 + modes_offerts?: string[];
14 + [k: string]: unknown;
15 +}
16 +
17 +export interface Formation {
18 + uid: string;
19 + source: string;
20 + external_id: string;
21 + url: string;
22 + title: string;
23 + training_type: string; // Cours universitaire, Séminaire, Atelier…
24 + category: string; // domaine : Informatique, Gestion, RH…
25 + mode: string; // en ligne | présentiel | hybride | asynchrone
26 + city: string;
27 + language: string; // fr | en | fr/en
28 + price: number | null; // null = non affiché (normal pour l'universitaire)
29 + price_label: string;
30 + is_free: boolean | null;
31 + duration: string;
32 + duration_hours: number | null;
33 + start_date: string | null; // ISO
34 + schedule_label: string;
35 + sessions: string[]; // toutes les dates offertes (ISO)
36 + level: string;
37 + credits: string; // « 3 crédits », « 1,4 UEC »
38 + credential: string;
39 + instructor: string;
40 + code: string; // sigle (ex. « GSF-1020 »)
41 + description: string;
42 + objectives: string[];
43 + prerequisites: string;
44 + audience: string;
45 + program: string[]; // plan / contenu
46 + tags: string[];
47 + details: FormationDetails;
48 + images: string[];
49 + price_history?: { ts: number; price: number | null }[];
50 + similar?: {
51 + uid: string; title: string; training_type: string; source: string;
52 + price: number | null; mode: string; duration: string;
53 + }[];
54 + first_seen?: number;
55 + last_seen: number;
56 + updated_at: number;
57 + active: number;
58 +}
59 +
60 +export interface Facets {
61 + types: { t: string; n: number }[];
62 + categories: { category: string; n: number }[];
63 + modes: string[];
64 + cities: string[];
65 + languages: string[];
66 + levels: string[];
67 + sources: { source: string; n: number }[];
68 +}
69 +
70 +export interface Source {
71 + id: string;
72 + name: string;
73 + url: string;
74 + listing_url: string;
75 + type_offre: string;
76 + connector: string | null;
77 + status: string;
78 + region: string;
79 + active_formations: number;
80 + last_sync: number | null;
81 +}
82 +
83 +export interface Stats {
84 + total: number;
85 + gratuites: number;
86 + en_ligne: number;
87 + universitaires: number;
88 + sources: number;
89 + avg_price: number | null;
90 + avg_hours: number | null;
91 + par_type: { t: string; n: number }[];
92 + recent_syncs: {
93 + source: string; ts: number; found: number; added: number;
94 + updated: number; removed: number; ok: number; message: string;
95 + }[];
96 +}
97 +
98 +const SOURCE_NAMES: Record<string, string> = {};
99 +
100 +export function registerSourceNames(sources: Source[]) {
101 + for (const s of sources) SOURCE_NAMES[s.id] = s.name;
102 +}
103 +export function sourceName(id: string): string {
104 + return SOURCE_NAMES[id] ?? id;
105 +}
106 +
107 +async function get<T>(path: string): Promise<T> {
108 + const ctrl = new AbortController();
109 + const timer = setTimeout(() => ctrl.abort(), 20000);
110 + try {
111 + const res = await fetch(path, { signal: ctrl.signal });
112 + if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
113 + return (await res.json()) as T;
114 + } finally {
115 + clearTimeout(timer);
116 + }
117 +}
118 +
119 +export interface FormationFilters {
120 + training_type?: string;
121 + category?: string;
122 + mode?: string;
123 + city?: string;
124 + language?: string;
125 + level?: string;
126 + source?: string;
127 + free?: string; // "1" = gratuites
128 + price_max?: string;
129 + credential?: string;
130 + starts_after?: string; // ISO
131 + q?: string;
132 + sort?: string; // recent | price | title | start
133 + limit?: string; // taille de page
134 + offset?: string; // décalage (pagination)
135 +}
136 +
137 +export function fetchFormations(f: FormationFilters) {
138 + const params = new URLSearchParams();
139 + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
140 + return get<{ total: number; formations: Formation[] }>(`/api/formations?${params}`);
141 +}
142 +
143 +export const fetchFormation = (uid: string) =>
144 + get<Formation>(`/api/formations/${encodeURIComponent(uid)}`);
145 +export const fetchFacets = (trainingType?: string) =>
146 + get<Facets>(`/api/facets${trainingType ? `?training_type=${encodeURIComponent(trainingType)}` : ""}`);
147 +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
148 +export const fetchStats = () => get<Stats>("/api/stats");
149 +
150 +/** null -> « Prix non affiché » (fréquent : cours universitaires) */
151 +export const fmtPrice = (p: number | null, label?: string) => {
152 + if (p === 0) return "Gratuit";
153 + if (p != null)
154 + return p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $";
155 + return label || "Prix non affiché";
156 +};
157 +
158 +/** "2026-12-01" -> « 1ᵉʳ décembre 2026 » */
159 +export function fmtDate(iso: string | null): string | null {
160 + if (!iso) return null;
161 + const [y, m, d] = iso.split("-").map(Number);
162 + if (!y || !m || !d) return null;
163 + const txt = new Date(y, m - 1, d).toLocaleDateString("fr-CA", {
164 + day: "numeric", month: "long", year: "numeric",
165 + });
166 + return txt.replace(/^1 /, "1ᵉʳ ");
167 +}
168 +
169 +/** 14 -> « 14 h », 3.5 -> « 3,5 h » */
170 +export const fmtHours = (h: number | null): string | null =>
171 + h == null ? null : `${h.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} h`;
172 +
173 +export const MODE_ICONS: Record<string, string> = {
174 + "en ligne": "💻",
175 + "présentiel": "🏛",
176 + "hybride": "🔀",
177 + "asynchrone": "🕓",
178 +};
added frontend/src/components/CookieConsent.tsx +103 −0
@@ -0,0 +1,103 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/CookieConsent.tsx : bandeau de consentement (témoins/stockage)
5 +// Honnête par design : Forma-Ka n'utilise AUCUN témoin tiers ni traceur.
6 +// Le sélecteur couvre le stockage local (préférences) et une éventuelle
7 +// mesure d'audience anonyme future. Choix mémorisé en localStorage,
8 +// ré-ouvrable via l'événement « formaka:openConsent » (lien du pied de page).
9 +// -----------------------------------------------------------------------------
10 +import { useEffect, useState } from "react";
11 +import { Link } from "react-router-dom";
12 +
13 +const KEY = "formaka-consent-v1";
14 +
15 +export interface Consent {
16 + essential: true; // toujours actif (fonctionnement du site)
17 + preferences: boolean; // mémoriser filtres et vue liste/carte
18 + statistics: boolean; // mesure d'audience anonyme (si activée un jour)
19 + ts: number;
20 +}
21 +
22 +export function getConsent(): Consent | null {
23 + try {
24 + const raw = localStorage.getItem(KEY);
25 + return raw ? (JSON.parse(raw) as Consent) : null;
26 + } catch {
27 + return null;
28 + }
29 +}
30 +
31 +function save(preferences: boolean, statistics: boolean): Consent {
32 + const c: Consent = { essential: true, preferences, statistics, ts: Date.now() };
33 + try { localStorage.setItem(KEY, JSON.stringify(c)); } catch { /* stockage bloqué */ }
34 + return c;
35 +}
36 +
37 +export default function CookieConsent() {
38 + const [visible, setVisible] = useState(false);
39 + const [custom, setCustom] = useState(false);
40 + const [prefs, setPrefs] = useState(true);
41 + const [stats, setStats] = useState(false);
42 +
43 + useEffect(() => {
44 + if (getConsent() === null) setVisible(true);
45 + const open = () => { setVisible(true); setCustom(true); };
46 + window.addEventListener("formaka:openConsent", open);
47 + return () => window.removeEventListener("formaka:openConsent", open);
48 + }, []);
49 +
50 + if (!visible) return null;
51 +
52 + const close = (p: boolean, s: boolean) => { save(p, s); setVisible(false); setCustom(false); };
53 +
54 + return (
55 + <div className="cookie-banner" role="dialog" aria-modal="false" aria-label="Gestion des témoins">
56 + <div className="cookie-inner">
57 + <div className="cookie-text">
58 + <b>🍪 Vos témoins, vos choix.</b>{" "}
59 + Forma-Ka n'utilise aucun traceur publicitaire ni témoin tiers. Le site
60 + mémorise localement vos préférences (filtres, vue carte) et votre choix
61 + de consentement. <Link to="/confidentialite">Politique de confidentialité</Link>
62 + </div>
63 +
64 + {custom && (
65 + <div className="cookie-options">
66 + <label className="cookie-opt">
67 + <input type="checkbox" checked disabled />
68 + <span><b>Essentiels</b> — fonctionnement du site (toujours actifs)</span>
69 + </label>
70 + <label className="cookie-opt">
71 + <input type="checkbox" checked={prefs} onChange={(e) => setPrefs(e.target.checked)} />
72 + <span><b>Préférences</b> — retenir vos filtres et votre vue liste/carte</span>
73 + </label>
74 + <label className="cookie-opt">
75 + <input type="checkbox" checked={stats} onChange={(e) => setStats(e.target.checked)} />
76 + <span><b>Statistiques</b> — mesure d'audience anonyme, sans profilage</span>
77 + </label>
78 + </div>
79 + )}
80 +
81 + <div className="cookie-actions">
82 + {!custom && (
83 + <button className="btn btn-ghost" onClick={() => setCustom(true)}>
84 + Personnaliser
85 + </button>
86 + )}
87 + <button className="btn btn-ghost" onClick={() => close(false, false)}>
88 + Essentiels seulement
89 + </button>
90 + {custom ? (
91 + <button className="btn btn-primary" onClick={() => close(prefs, stats)}>
92 + Enregistrer mes choix
93 + </button>
94 + ) : (
95 + <button className="btn btn-primary" onClick={() => close(true, true)}>
96 + Tout accepter
97 + </button>
98 + )}
99 + </div>
100 + </div>
101 + </div>
102 + );
103 +}
added frontend/src/components/FormationCard.tsx +74 −0
@@ -0,0 +1,74 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/FormationCard.tsx : carte de formation (grille de résultats)
5 +// Pas d'image imposée (rare dans le domaine) : monogramme de la source +
6 +// type de formation en badge, détails clés (mode, durée, ville, date).
7 +// -----------------------------------------------------------------------------
8 +import { Link } from "react-router-dom";
9 +import { Formation, MODE_ICONS, fmtDate, fmtHours, fmtPrice, sourceName } from "../api";
10 +
11 +/** Couleur stable dérivée du domaine (pour le bandeau du monogramme). */
12 +const HUES = [14, 36, 88, 152, 196, 226, 262, 318];
13 +function hue(s: string): number {
14 + let h = 0;
15 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
16 + return HUES[Math.abs(h) % HUES.length];
17 +}
18 +
19 +export default function FormationCard({ f }: { f: Formation }) {
20 + const img = f.images && f.images.length > 0 ? f.images[0] : null;
21 + const start = fmtDate(f.start_date);
22 + const dur = f.duration || fmtHours(f.duration_hours);
23 + const h = hue(f.category || f.source);
24 + return (
25 + <Link to={`/formation/${encodeURIComponent(f.uid)}`} className="card">
26 + <div className="card-img" style={{ aspectRatio: "5 / 2" }}>
27 + {img ? (
28 + <img src={img} alt={f.title} loading="lazy" />
29 + ) : (
30 + <div
31 + className="noimg"
32 + style={{
33 + background: `linear-gradient(135deg, hsl(${h} 42% 90%), hsl(${h} 38% 78%))`,
34 + color: `hsl(${h} 45% 26%)`,
35 + fontFamily: "var(--font-display)",
36 + fontSize: "1rem", fontWeight: 700, padding: "0 16px",
37 + textAlign: "center", lineHeight: 1.25,
38 + }}
39 + >
40 + {f.category || sourceName(f.source)}
41 + </div>
42 + )}
43 + {f.training_type && <span className="badge type">{f.training_type}</span>}
44 + {f.is_free && <span className="badge right">Gratuit</span>}
45 + </div>
46 + <div className="card-body">
47 + <div className="card-price">
48 + {f.price != null && f.price > 0 ? (
49 + <>{fmtPrice(f.price, f.price_label)}</>
50 + ) : f.is_free ? (
51 + <>Gratuit</>
52 + ) : (
53 + <span style={{ color: "var(--ink-3)", fontSize: ".85em" }}>
54 + {f.credits || f.price_label || "Prix non affiché"}
55 + </span>
56 + )}
57 + {f.code && <small style={{ marginLeft: 8 }}>{f.code}</small>}
58 + </div>
59 + <div className="card-title">{f.title}</div>
60 + <div className="card-meta">
61 + {f.mode && <span>{MODE_ICONS[f.mode] ?? ""} {f.mode}</span>}
62 + {f.mode && dur && <span className="sep" />}
63 + {dur && <span>{dur}</span>}
64 + {(f.mode || dur) && f.city && <span className="sep" />}
65 + {f.city && <span>{f.city}</span>}
66 + </div>
67 + <div className="card-foot">
68 + <span className="source-tag">{sourceName(f.source)}</span>
69 + {start && <span className="avail">Débute le {start}</span>}
70 + </div>
71 + </div>
72 + </Link>
73 + );
74 +}
added frontend/src/main.tsx +18 −0
@@ -0,0 +1,18 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// main.tsx : point d'entrée React
5 +// -----------------------------------------------------------------------------
6 +import React from "react";
7 +import ReactDOM from "react-dom/client";
8 +import { BrowserRouter } from "react-router-dom";
9 +import App from "./App";
10 +import "./styles.css";
11 +
12 +ReactDOM.createRoot(document.getElementById("root")!).render(
13 + <React.StrictMode>
14 + <BrowserRouter>
15 + <App />
16 + </BrowserRouter>
17 + </React.StrictMode>
18 +);
added frontend/src/pages/Formation.tsx +267 −0
@@ -0,0 +1,267 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Formation.tsx : fiche d'une formation — l'accent est mis sur les
5 +// DÉTAILS (le prix est souvent absent, p. ex. cours universitaires) :
6 +// description → objectifs → plan de cours → préalables → clientèle →
7 +// infos pratiques. Colonne droite : synthèse, dates offertes, CTA source,
8 +// formations similaires. CTA sticky en bas d'écran (mobile).
9 +// -----------------------------------------------------------------------------
10 +import { useEffect, useState } from "react";
11 +import { Link, useParams } from "react-router-dom";
12 +import {
13 + Formation, MODE_ICONS, fetchFormation, fetchSources, fmtDate, fmtHours,
14 + fmtPrice, registerSourceNames, sourceName,
15 +} from "../api";
16 +
17 +const NBSP = " ";
18 +
19 +export default function FormationPage() {
20 + const { uid } = useParams<{ uid: string }>();
21 + const [f, setF] = useState<Formation | null>(null);
22 + const [error, setError] = useState<string | null>(null);
23 + // re-rendu quand le registre des noms de sources est chargé
24 + const [, setSourcesReady] = useState(false);
25 +
26 + useEffect(() => {
27 + fetchSources()
28 + .then((r) => { registerSourceNames(r.sources); setSourcesReady(true); })
29 + .catch(() => {});
30 + if (!uid) return;
31 + fetchFormation(uid).then(setF).catch((e) => setError(String(e)));
32 + window.scrollTo(0, 0);
33 + }, [uid]);
34 +
35 + if (error)
36 + return (
37 + <div className="notice container">
38 + <div className="big">⚠️</div>
39 + <h2>Formation introuvable</h2>
40 + <p>{error}</p>
41 + <Link className="btn btn-primary" to="/">Retour aux formations</Link>
42 + </div>
43 + );
44 +
45 + if (!f)
46 + return (
47 + <div className="container detail">
48 + <div className="fiche" aria-busy="true">
49 + <div className="skel"><div className="sk-img" /></div>
50 + <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
51 + </div>
52 + </div>
53 + );
54 +
55 + const updated = f.updated_at
56 + ? new Date(f.updated_at * 1000).toLocaleDateString("fr-CA", {
57 + day: "numeric", month: "long", year: "numeric" }) : null;
58 +
59 + // chips clés — tout ce qui décrit la formation d'un coup d'œil
60 + const chips: string[] = [];
61 + if (f.training_type) chips.push(f.training_type);
62 + if (f.mode) chips.push(`${MODE_ICONS[f.mode] ?? ""} ${f.mode}`.trim());
63 + const dur = f.duration || fmtHours(f.duration_hours);
64 + if (dur) chips.push(dur);
65 + if (f.credits) chips.push(f.credits);
66 + if (f.level) chips.push(`Niveau ${f.level}`);
67 + if (f.language) chips.push(f.language === "fr" ? "Français" : f.language === "en" ? "Anglais" : f.language);
68 + if (f.city) chips.push(f.city);
69 + if (f.code) chips.push(f.code);
70 +
71 + const sessions = (f.sessions ?? []).filter(Boolean);
72 + const img = f.images && f.images.length > 0 ? f.images[0] : null;
73 +
74 + return (
75 + <div className="container detail">
76 + <nav className="crumbs" aria-label="Fil d'Ariane">
77 + <Link to="/">Formations</Link> ›
78 + {f.category && <span>{f.category}</span>} ›
79 + <span>{f.title}</span>
80 + </nav>
81 +
82 + <div className="fiche">
83 + {/* ------- colonne gauche (desktop) : les DÉTAILS de la formation ---- */}
84 + <div className="f-col">
85 + {img && (
86 + <section className="f-bloc f-galerie" aria-label="Visuel">
87 + <div className="carousel">
88 + <div className="carousel-track">
89 + <img src={img} alt={f.title} loading="eager" />
90 + </div>
91 + </div>
92 + </section>
93 + )}
94 +
95 + <section className="f-bloc f-desc" id="description">
96 + <h2>Description</h2>
97 + {f.description
98 + ? f.description.split(/\n{2,}/).map((p, i) => (
99 + <p key={i} style={{ color: "var(--ink-2)" }}>{p}</p>
100 + ))
101 + : <p className="fine">La source ne fournit pas de description pour cette formation.</p>}
102 + </section>
103 +
104 + {f.objectives.length > 0 && (
105 + <section className="f-bloc" id="objectifs">
106 + <h2>Objectifs d'apprentissage</h2>
107 + <div className="amenity-row">
108 + {f.objectives.map((o) => (
109 + <span className="amenity confirmed" key={o}>✓ {o}</span>
110 + ))}
111 + </div>
112 + </section>
113 + )}
114 +
115 + {f.program.length > 0 && (
116 + <section className="f-bloc" id="plan">
117 + <h2>Plan de la formation</h2>
118 + <ol style={{ color: "var(--ink-2)", lineHeight: 1.7, paddingLeft: 22, margin: 0 }}>
119 + {f.program.map((p, i) => (
120 + <li key={i}>{p}</li>
121 + ))}
122 + </ol>
123 + </section>
124 + )}
125 +
126 + {(f.prerequisites || f.audience) && (
127 + <section className="f-bloc" id="admission">
128 + <h2>Admission</h2>
129 + <div className="kv">
130 + {f.prerequisites && (
131 + <div className="cell"><div className="k">Préalables</div><div className="v">{f.prerequisites}</div></div>
132 + )}
133 + {f.audience && (
134 + <div className="cell"><div className="k">Clientèle visée</div><div className="v">{f.audience}</div></div>
135 + )}
136 + </div>
137 + </section>
138 + )}
139 +
140 + <section className="f-bloc f-pratique" id="pratique">
141 + <h2>Infos pratiques</h2>
142 + <div className="kv">
143 + <div className="cell"><div className="k">Établissement</div><div className="v">{sourceName(f.source)}</div></div>
144 + {f.code && (
145 + <div className="cell"><div className="k">Sigle</div><div className="v">{f.code}</div></div>
146 + )}
147 + {f.credential && (
148 + <div className="cell"><div className="k">Reconnaissance</div><div className="v">{f.credential}</div></div>
149 + )}
150 + {f.credits && (
151 + <div className="cell"><div className="k">Crédits / UEC</div><div className="v">{f.credits}</div></div>
152 + )}
153 + {dur && (
154 + <div className="cell"><div className="k">Durée</div><div className="v">{dur}</div></div>
155 + )}
156 + {f.instructor && (
157 + <div className="cell"><div className="k">Formateur·trice</div><div className="v">{f.instructor}</div></div>
158 + )}
159 + {f.price_label && (
160 + <div className="cell"><div className="k">Prix affiché</div><div className="v">{f.price_label}</div></div>
161 + )}
162 + {f.schedule_label && (
163 + <div className="cell"><div className="k">Horaire affiché</div><div className="v">{f.schedule_label}</div></div>
164 + )}
165 + {updated && (
166 + <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div>
167 + )}
168 + </div>
169 + </section>
170 + </div>
171 +
172 + {/* ------- colonne droite (desktop) : synthèse, dates, similaires ---- */}
173 + <div className="f-col">
174 + <section className="f-bloc f-hero">
175 + <div className="price">
176 + {f.price === 0 || f.is_free ? (
177 + "Gratuit"
178 + ) : f.price != null ? (
179 + <>{fmtPrice(f.price, f.price_label)}</>
180 + ) : (
181 + <span style={{ fontSize: ".72em" }}>Prix non affiché par la source</span>
182 + )}
183 + </div>
184 + <h1>{f.title}</h1>
185 + <div className="loc">
186 + {[f.category, f.city].filter(Boolean).join(" · ")}
187 + </div>
188 + <div className="chips-scroll" role="list" aria-label="Caractéristiques clés">
189 + {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)}
190 + </div>
191 + <nav className="ancres" aria-label="Sections de la fiche">
192 + <a href="#description">Description</a>
193 + {f.objectives.length > 0 && <a href="#objectifs">Objectifs</a>}
194 + {f.program.length > 0 && <a href="#plan">Plan</a>}
195 + <a href="#pratique">Infos pratiques</a>
196 + </nav>
197 + <a className="cta cta-desktop" href={f.url}
198 + target="_blank" rel="noopener noreferrer">
199 + Voir chez {sourceName(f.source)} ↗
200 + </a>
201 + </section>
202 +
203 + {sessions.length > 0 && (
204 + <section className="f-bloc" id="dates">
205 + <h2>Dates offertes</h2>
206 + <div className="amenity-row">
207 + {sessions.map((s) => (
208 + <span className="amenity confirmed" key={s}>📅 {fmtDate(s) ?? s}</span>
209 + ))}
210 + </div>
211 + </section>
212 + )}
213 +
214 + {f.tags.length > 0 && (
215 + <section className="f-bloc">
216 + <h2>Thèmes</h2>
217 + <div className="amenity-row">
218 + {f.tags.map((t) => (
219 + <span className="amenity unconfirmed" key={t}>{t}</span>
220 + ))}
221 + </div>
222 + </section>
223 + )}
224 +
225 + {(f.similar ?? []).length > 0 && (
226 + <section className="f-bloc">
227 + <h2>Formations similaires</h2>
228 + <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 10 }}>
229 + {(f.similar ?? []).map((s) => (
230 + <li key={s.uid}>
231 + <Link to={`/formation/${encodeURIComponent(s.uid)}`}
232 + style={{ color: "var(--ink)", fontWeight: 600 }}>
233 + {s.title}
234 + </Link>
235 + <div className="fine">
236 + {[s.training_type, s.mode, s.duration,
237 + s.price != null ? fmtPrice(s.price) : "",
238 + sourceName(s.source)].filter(Boolean).join(" · ")}
239 + </div>
240 + </li>
241 + ))}
242 + </ul>
243 + </section>
244 + )}
245 + </div>
246 + </div>
247 +
248 + <div className="fine f-foot">
249 + {updated && <>Dernière synchronisation : {updated}. </>}
250 + Les prix, dates et détails sont ceux affichés par la source — chaque fiche
251 + renvoie à la page originale de la formation.
252 + </div>
253 +
254 + {/* CTA sticky mobile — toujours visible */}
255 + <div className="cta-sticky">
256 + <span className="cta-sticky-prix">
257 + {f.price === 0 || f.is_free ? "Gratuit"
258 + : f.price != null ? fmtPrice(f.price, f.price_label)
259 + : (f.credits || "Détails")}
260 + </span>
261 + <a className="cta" href={f.url} target="_blank" rel="noopener noreferrer">
262 + Voir chez {sourceName(f.source)} ↗
263 + </a>
264 + </div>
265 + </div>
266 + );
267 +}
added frontend/src/pages/Home.tsx +455 −0
@@ -0,0 +1,455 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille
5 +// Filtres : recherche, type, domaine, mode, ville, prix, gratuit, langue,
6 +// niveau, source, tri — avec pastilles de filtres actifs.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useMemo, useState } from "react";
9 +import { useSearchParams } from "react-router-dom";
10 +import {
11 + Facets, Formation, FormationFilters, Stats,
12 + fetchFacets, fetchFormations, fetchSources, fetchStats,
13 + registerSourceNames, sourceName,
14 +} from "../api";
15 +import FormationCard from "../components/FormationCard";
16 +
17 +const PRICE_STEPS = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000];
18 +const PAGE_SIZE = 12;
19 +
20 +/** Numéros de pages à afficher : 1 … autour de la courante … dernière. */
21 +function pageNumbers(current: number, total: number): (number | "…")[] {
22 + if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
23 + const around = [current - 1, current, current + 1]
24 + .filter((p) => p > 1 && p < total);
25 + const out: (number | "…")[] = [1];
26 + if (around[0] > 2) out.push("…");
27 + out.push(...around);
28 + if (around[around.length - 1] < total - 1) out.push("…");
29 + out.push(total);
30 + return out;
31 +}
32 +
33 +const SORTS: { key: string; label: string }[] = [
34 + { key: "recent", label: "Nouveautés" },
35 + { key: "start", label: "Prochaines dates" },
36 + { key: "price", label: "Prix croissant" },
37 + { key: "title", label: "A → Z" },
38 +];
39 +
40 +export default function Home() {
41 + const [formations, setFormations] = useState<Formation[] | null>(null);
42 + const [total, setTotal] = useState(0);
43 + const [facets, setFacets] = useState<Facets | null>(null);
44 + const [stats, setStats] = useState<Stats | null>(null);
45 + const [error, setError] = useState<string | null>(null);
46 +
47 + // filtres (pré-remplis depuis l'URL, ex. /?training_type=Séminaire)
48 + const [params] = useSearchParams();
49 + const [q, setQ] = useState(params.get("q") ?? "");
50 + const [type, setType] = useState(params.get("training_type") ?? "");
51 + const [category, setCategory] = useState(params.get("category") ?? "");
52 + const [mode, setMode] = useState(params.get("mode") ?? "");
53 + const [city, setCity] = useState(params.get("city") ?? "");
54 + const [language, setLanguage] = useState(params.get("language") ?? "");
55 + const [level, setLevel] = useState(params.get("level") ?? "");
56 + const [source, setSource] = useState(params.get("source") ?? "");
57 + const [free, setFree] = useState(params.get("free") ?? "");
58 + const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
59 + const [sort, setSort] = useState(params.get("sort") ?? "recent");
60 + const [page, setPage] = useState(1);
61 + // feuille de filtres mobile (bottom sheet) + panneau avancé desktop
62 + const [sheetOpen, setSheetOpen] = useState(false);
63 + const [advOpen, setAdvOpen] = useState(false);
64 + const activeFilters = [q, type, category, mode, city, language, level,
65 + source, free, priceMax].filter(Boolean).length;
66 + const advCount = [language, level, source, priceMax].filter(Boolean).length;
67 +
68 + const filters: FormationFilters = useMemo(() => ({
69 + q, training_type: type, category, mode, city, language, level,
70 + source, free, price_max: priceMax, sort,
71 + }), [q, type, category, mode, city, language, level, source, free, priceMax, sort]);
72 +
73 + // tout changement de filtre/tri ramène à la première page
74 + useEffect(() => { setPage(1); }, [filters]);
75 +
76 + // re-rendu quand le registre des noms de sources est chargé
77 + const [, setSourcesReady] = useState(false);
78 + useEffect(() => {
79 + fetchSources()
80 + .then((r) => { registerSourceNames(r.sources); setSourcesReady(true); })
81 + .catch(() => {});
82 + fetchFacets().then(setFacets).catch(() => {});
83 + fetchStats().then(setStats).catch(() => {});
84 + }, []);
85 +
86 + useEffect(() => {
87 + let cancelled = false;
88 + setFormations(null);
89 + setError(null);
90 + fetchFormations({
91 + ...filters,
92 + limit: String(PAGE_SIZE),
93 + offset: String((page - 1) * PAGE_SIZE),
94 + })
95 + .then((r) => {
96 + if (!cancelled) {
97 + setFormations(r.formations);
98 + setTotal(r.total);
99 + }
100 + })
101 + .catch((e) => !cancelled && setError(String(e)));
102 + return () => {
103 + cancelled = true;
104 + };
105 + }, [filters, page]);
106 +
107 + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
108 + const goto = (p: number) => {
109 + setPage(Math.min(Math.max(1, p), totalPages));
110 + // remonter en haut des résultats, pas de la page
111 + document.querySelector(".results-head")?.scrollIntoView({ behavior: "smooth" });
112 + };
113 +
114 + const resetAll = () => {
115 + setQ(""); setType(""); setCategory(""); setMode(""); setCity("");
116 + setLanguage(""); setLevel(""); setSource(""); setFree(""); setPriceMax("");
117 + };
118 +
119 + // pastilles « filtres actifs » — libellé + action de retrait
120 + const pills: { label: string; clear: () => void }[] = [];
121 + if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });
122 + if (type) pills.push({ label: type, clear: () => setType("") });
123 + if (category) pills.push({ label: category, clear: () => setCategory("") });
124 + if (mode) pills.push({ label: mode, clear: () => setMode("") });
125 + if (city) pills.push({ label: city, clear: () => setCity("") });
126 + if (free) pills.push({ label: "Gratuites", clear: () => setFree("") });
127 + if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") });
128 + if (language) pills.push({ label: language === "fr" ? "Français" : language === "en" ? "Anglais" : language, clear: () => setLanguage("") });
129 + if (level) pills.push({ label: level, clear: () => setLevel("") });
130 + if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });
131 +
132 + const types = facets?.types ?? [];
133 +
134 + return (
135 + <div className="container">
136 + <section className="hero">
137 + <span className="kicker">Agrégateur — toutes les formations du Québec</span>
138 + <h1>
139 + Toutes les <span className="outline">formations</span>,<br />
140 + <span className="hl">un seul</span> endroit.
141 + </h1>
142 + <p className="lede">
143 + Forma-Ka rassemble les cours en ligne, cours universitaires et collégiaux,
144 + séminaires, ateliers et certifications offerts au Québec — mis à jour
145 + automatiquement, avec tous les détails et un lien direct vers la formation
146 + originale.
147 + </p>
148 + <div className="stat-row">
149 + <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>
150 + {stats && (
151 + <>
152 + <span className="stat-chip"><b>{stats.total}</b> formations actives</span>
153 + {(stats.gratuites ?? 0) > 0 && (
154 + <span className="stat-chip"><b>{stats.gratuites}</b> gratuites</span>
155 + )}
156 + {(stats.en_ligne ?? 0) > 0 && (
157 + <span className="stat-chip"><b>{stats.en_ligne}</b> en ligne</span>
158 + )}
159 + <span className="stat-chip"><b>{stats.sources}</b> établissements</span>
160 + </>
161 + )}
162 + </div>
163 + </section>
164 +
165 + {sheetOpen && (
166 + <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />
167 + )}
168 + <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">
169 + <div className="sheet-head">
170 + <span>Filtres</span>
171 + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">
172 +
173 + </button>
174 + </div>
175 +
176 + {/* — rangée principale : recherche, domaine, mode, ville, + filtres — */}
177 + <div className="f-primary">
178 + <div className="f-search">
179 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
180 + <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
181 + </svg>
182 + <input
183 + id="f-q" placeholder="Sujet, sigle, compétence…" value={q}
184 + onChange={(e) => setQ(e.target.value)}
185 + aria-label="Recherche"
186 + />
187 + {q && (
188 + <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>
189 + )}
190 + </div>
191 + <label className="f-ctl">
192 + <span>Domaine</span>
193 + <select value={category} onChange={(e) => setCategory(e.target.value)}>
194 + <option value="">Tous</option>
195 + {(facets?.categories ?? []).map((c) => (
196 + <option key={c.category} value={c.category}>{c.category} ({c.n})</option>
197 + ))}
198 + </select>
199 + </label>
200 + <label className="f-ctl">
201 + <span>Mode</span>
202 + <select value={mode} onChange={(e) => setMode(e.target.value)}>
203 + <option value="">Tous</option>
204 + {(facets?.modes ?? ["en ligne", "présentiel", "hybride", "asynchrone"]).map((m) => (
205 + <option key={m} value={m}>{m}</option>
206 + ))}
207 + </select>
208 + </label>
209 + <label className="f-ctl">
210 + <span>Ville</span>
211 + <select value={city} onChange={(e) => setCity(e.target.value)}>
212 + <option value="">Toutes</option>
213 + {(facets?.cities ?? []).map((c) => (
214 + <option key={c} value={c}>{c}</option>
215 + ))}
216 + </select>
217 + </label>
218 + <button
219 + className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`}
220 + onClick={() => setAdvOpen(!advOpen)}
221 + aria-expanded={advOpen}
222 + >
223 + Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}
224 + </button>
225 + </div>
226 +
227 + {/* — panneau avancé : segments & sélecteurs — */}
228 + {(advOpen || sheetOpen) && (
229 + <div className="f-adv">
230 + <div className="f-group">
231 + <label>Prix</label>
232 + <div className="seg" role="group">
233 + <button className={free === "" && !priceMax ? "on" : ""}
234 + onClick={() => { setFree(""); setPriceMax(""); }}>
235 + Peu importe
236 + </button>
237 + <button className={free === "1" ? "on" : ""}
238 + onClick={() => { setFree(free === "1" ? "" : "1"); setPriceMax(""); }}>
239 + Gratuites
240 + </button>
241 + </div>
242 + </div>
243 + <div className="f-group">
244 + <label>Prix maximum</label>
245 + <select className="f-native" value={priceMax}
246 + onChange={(e) => { setPriceMax(e.target.value); setFree(""); }}>
247 + <option value="">Aucun</option>
248 + {PRICE_STEPS.map((p) => (
249 + <option key={p} value={p}>≤ {p.toLocaleString("fr-CA")} $</option>
250 + ))}
251 + </select>
252 + </div>
253 + <div className="f-group">
254 + <label>Langue</label>
255 + <div className="seg" role="group">
256 + {[["", "Peu importe"], ["fr", "Français"], ["en", "Anglais"]].map(([v, l]) => (
257 + <button key={v} className={language === v ? "on" : ""}
258 + onClick={() => setLanguage(v)}>
259 + {l}
260 + </button>
261 + ))}
262 + </div>
263 + </div>
264 + <div className="f-group">
265 + <label>Niveau</label>
266 + <div className="seg" role="group">
267 + <button className={level === "" ? "on" : ""} onClick={() => setLevel("")}>
268 + Peu importe
269 + </button>
270 + {(facets?.levels ?? []).map((l) => (
271 + <button key={l} className={level === l ? "on" : ""}
272 + onClick={() => setLevel(l)}>
273 + {l}
274 + </button>
275 + ))}
276 + </div>
277 + </div>
278 + <div className="f-group">
279 + <label>Établissement</label>
280 + <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
281 + <option value="">Tous</option>
282 + {(facets?.sources ?? []).map((s) => (
283 + <option key={s.source} value={s.source}>
284 + {sourceName(s.source)} ({s.n})
285 + </option>
286 + ))}
287 + </select>
288 + </div>
289 + <div className="f-group f-group-end">
290 + <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>
291 + Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}
292 + </button>
293 + </div>
294 + </div>
295 + )}
296 +
297 + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
298 + Voir les résultats {formations ? `(${total})` : ""}
299 + </button>
300 + </section>
301 +
302 + <div className="chips" role="group" aria-label="Filtres rapides — type de formation">
303 + {types.slice(0, 8).map(({ t, n }) => (
304 + <button
305 + key={t}
306 + className={`chip ${type === t ? "on" : ""}`}
307 + onClick={() => setType(type === t ? "" : t)}
308 + title={`${n} formations`}
309 + >
310 + {t}
311 + </button>
312 + ))}
313 + <span className="chip-sep" aria-hidden="true" />
314 + <button
315 + className={`chip ${free === "1" ? "on" : ""}`}
316 + onClick={() => setFree(free === "1" ? "" : "1")}
317 + >
318 + 🆓 Gratuites
319 + </button>
320 + <button
321 + className={`chip ${mode === "en ligne" ? "on" : ""}`}
322 + onClick={() => setMode(mode === "en ligne" ? "" : "en ligne")}
323 + >
324 + 💻 En ligne
325 + </button>
326 + </div>
327 +
328 + {pills.length > 0 && (
329 + <div className="pills" aria-label="Filtres actifs">
330 + {pills.map((p) => (
331 + <button key={p.label} className="pill" onClick={p.clear}
332 + aria-label={`Retirer le filtre ${p.label}`}>
333 + {p.label} <span className="pill-x">✕</span>
334 + </button>
335 + ))}
336 + <button className="pill pill-clear" onClick={resetAll}>
337 + Tout effacer
338 + </button>
339 + </div>
340 + )}
341 +
342 + <div className="results-head">
343 + <h2>Formations offertes</h2>
344 + <div className="results-tools">
345 + {formations && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
346 + <div className="view-toggle" role="tablist" aria-label="Tri">
347 + {SORTS.map((s) => (
348 + <button
349 + key={s.key}
350 + role="tab" aria-selected={sort === s.key}
351 + className={sort === s.key ? "on" : ""}
352 + onClick={() => setSort(s.key)}
353 + >
354 + {s.label}
355 + </button>
356 + ))}
357 + </div>
358 + </div>
359 + </div>
360 +
361 + {error && (
362 + <div className="notice">
363 + <div className="big">⚠️</div>
364 + <h2>Impossible de charger les formations</h2>
365 + <p>{error}</p>
366 + <button className="btn btn-primary" onClick={() => window.location.reload()}>
367 + Réessayer
368 + </button>
369 + </div>
370 + )}
371 +
372 + {!error && formations === null && (
373 + <div className="grid" aria-busy="true">
374 + {Array.from({ length: 8 }).map((_, i) => (
375 + <div className="skel" key={i}>
376 + <div className="sk-img" />
377 + <div className="sk-line" />
378 + <div className="sk-line short" />
379 + </div>
380 + ))}
381 + </div>
382 + )}
383 +
384 + {!error && formations !== null && formations.length === 0 && (
385 + <div className="notice">
386 + <div className="big">🔍</div>
387 + <h2>Aucune formation ne correspond</h2>
388 + <p>
389 + Essayez d'élargir vos critères
390 + {activeFilters > 0 && (
391 + <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>
392 + )}.
393 + </p>
394 + </div>
395 + )}
396 +
397 + {!error && formations !== null && formations.length > 0 && (
398 + <>
399 + <div className="grid">
400 + {formations.map((f) => (
401 + <FormationCard key={f.uid} f={f} />
402 + ))}
403 + </div>
404 +
405 + {totalPages > 1 && (
406 + <nav className="pagination" aria-label="Pagination des résultats">
407 + <button
408 + className="page-btn page-prev"
409 + onClick={() => goto(page - 1)}
410 + disabled={page <= 1}
411 + aria-label="Page précédente"
412 + >
413 + ← Précédent
414 + </button>
415 + {pageNumbers(page, totalPages).map((p, i) =>
416 + p === "…" ? (
417 + <span key={`e${i}`} className="page-ellipsis" aria-hidden="true">…</span>
418 + ) : (
419 + <button
420 + key={p}
421 + className={`page-btn ${p === page ? "on" : ""}`}
422 + onClick={() => goto(p)}
423 + aria-current={p === page ? "page" : undefined}
424 + >
425 + {p}
426 + </button>
427 + )
428 + )}
429 + <button
430 + className="page-btn page-next"
431 + onClick={() => goto(page + 1)}
432 + disabled={page >= totalPages}
433 + aria-label="Page suivante"
434 + >
435 + Suivant →
436 + </button>
437 + </nav>
438 + )}
439 + <div className="fine" style={{ textAlign: "center", marginTop: 8 }}>
440 + Page {page} de {totalPages} — {total.toLocaleString("fr-CA")} formations
441 + </div>
442 + </>
443 + )}
444 +
445 + {/* Bouton flottant mobile — ouvre la feuille de filtres */}
446 + <button
447 + className="fab"
448 + onClick={() => setSheetOpen(true)}
449 + aria-label="Ouvrir les filtres"
450 + >
451 + ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
452 + </button>
453 + </div>
454 + );
455 +}
added frontend/src/pages/Privacy.tsx +66 −0
@@ -0,0 +1,66 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Privacy.tsx : politique de confidentialité et de témoins (cookies)
5 +// -----------------------------------------------------------------------------
6 +
7 +export default function PrivacyPage() {
8 + const reopen = () => window.dispatchEvent(new Event("formaka:openConsent"));
9 +
10 + return (
11 + <div className="container legal">
12 + <span className="kicker">Confidentialité &amp; témoins</span>
13 + <h1>Votre vie privée, simplement.</h1>
14 +
15 + <section>
16 + <h3>Ce que Forma-Ka collecte</h3>
17 + <p>
18 + Rien qui vous identifie. Forma-Ka n'a pas de compte utilisateur, pas de
19 + formulaire d'inscription, pas de pixel publicitaire et ne vend aucune
20 + donnée. Le site consulte des fiches publiques de gestionnaires
21 + immobiliers et vous les présente — c'est tout.
22 + </p>
23 + </section>
24 +
25 + <section>
26 + <h3>Témoins et stockage local</h3>
27 + <p>
28 + Forma-Ka n'utilise <b>aucun témoin (cookie) tiers</b>. Le seul stockage
29 + se fait dans votre navigateur (« localStorage ») :
30 + </p>
31 + <ul>
32 + <li><b>Essentiels</b> — votre choix de consentement lui-même.</li>
33 + <li><b>Préférences</b> (si acceptées) — vos filtres de recherche et votre vue liste/carte.</li>
34 + <li><b>Statistiques</b> (si acceptées) — une éventuelle mesure d'audience anonyme,
35 + sans identifiant ni profilage. Aucune n'est active à ce jour.</li>
36 + </ul>
37 + <p>
38 + <button className="btn btn-ghost" onClick={reopen}>Modifier mes choix de témoins</button>
39 + </p>
40 + </section>
41 +
42 + <section>
43 + <h3>Services externes</h3>
44 + <ul>
45 + <li><b>Photos des fiches</b> — chargées depuis les serveurs des gestionnaires
46 + immobiliers ; leur politique s'applique à ces requêtes.</li>
47 + <li><b>Carte</b> — tuiles vectorielles d'OpenFreeMap (données © OpenStreetMap),
48 + chargées uniquement si vous ouvrez la vue carte.</li>
49 + <li><b>Commodités de proximité</b> — calculées côté serveur à partir
50 + d'OpenStreetMap ; votre navigateur ne contacte pas ces services.</li>
51 + </ul>
52 + </section>
53 +
54 + <section>
55 + <h3>Vos fiches, vos sources</h3>
56 + <p>
57 + Chaque fiche renvoie vers l'fiche originale du gestionnaire. Pour
58 + faire retirer un contenu ou pour toute question :{" "}
59 + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>.
60 + </p>
61 + </section>
62 +
63 + <div className="fine">Dernière mise à jour : août 2026.</div>
64 + </div>
65 + );
66 +}
added frontend/src/pages/Sources.tsx +75 −0
@@ -0,0 +1,75 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Sources.tsx : registre des établissements de formation agrégés
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Source, fetchSources } from "../api";
8 +
9 +export default function SourcesPage() {
10 + const [sources, setSources] = useState<Source[] | null>(null);
11 + const [error, setError] = useState<string | null>(null);
12 +
13 + useEffect(() => {
14 + fetchSources()
15 + .then((r) => setSources(r.sources))
16 + .catch((e) => setError(String(e)));
17 + }, []);
18 +
19 + return (
20 + <div className="container sources">
21 + <span className="kicker">Registre — établissements de formation</span>
22 + <h1>Sources agrégées</h1>
23 + <p className="sub">
24 + Universités, cégeps, firmes de formation et organisateurs d'événements du
25 + Québec recensés par Forma-Ka. Chaque source « active » est synchronisée
26 + périodiquement par un connecteur dédié; les autres sont en attente de
27 + connecteur.
28 + </p>
29 +
30 + {error && <div className="notice">⚠️ {error}</div>}
31 + {!sources && !error && <div className="notice">Chargement…</div>}
32 +
33 + {sources && (
34 + <div className="src-wrap">
35 + <table className="src-table">
36 + <thead>
37 + <tr>
38 + <th>Établissement</th>
39 + <th>Type d'offre</th>
40 + <th>Statut</th>
41 + <th style={{ textAlign: "right" }}>Formations actives</th>
42 + <th>Dernière synchro</th>
43 + </tr>
44 + </thead>
45 + <tbody>
46 + {sources.map((s) => (
47 + <tr key={s.id}>
48 + <td>
49 + <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a>
50 + </td>
51 + <td style={{ color: "var(--ink-2)", maxWidth: 380 }}>{s.type_offre}</td>
52 + <td>
53 + {s.connector ? (
54 + <span className="pill ok">connecté</span>
55 + ) : (
56 + <span className="pill todo">{s.status}</span>
57 + )}
58 + </td>
59 + <td style={{ textAlign: "right" }}>
60 + <span className="count-pill">{s.active_formations || "—"}</span>
61 + </td>
62 + <td style={{ color: "var(--ink-3)" }}>
63 + {s.last_sync
64 + ? new Date(s.last_sync * 1000).toLocaleString("fr-CA")
65 + : "—"}
66 + </td>
67 + </tr>
68 + ))}
69 + </tbody>
70 + </table>
71 + </div>
72 + )}
73 + </div>
74 + );
75 +}
added frontend/src/pages/Stats.tsx +133 −0
@@ -0,0 +1,133 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Stats.tsx : portrait de l'offre de formation — totaux, répartition par
5 +// type (barres), santé des synchronisations récentes.
6 +// -----------------------------------------------------------------------------
7 +import { useEffect, useState } from "react";
8 +import { Link } from "react-router-dom";
9 +import { Stats, fetchSources, fetchStats, registerSourceNames, sourceName } from "../api";
10 +
11 +export default function StatsPage() {
12 + const [stats, setStats] = useState<Stats | null>(null);
13 + const [error, setError] = useState<string | null>(null);
14 +
15 + useEffect(() => {
16 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
17 + fetchStats().then(setStats).catch((e) => setError(String(e)));
18 + }, []);
19 +
20 + if (error)
21 + return (
22 + <div className="notice container">
23 + <div className="big">⚠️</div>
24 + <h2>Impossible de charger les statistiques</h2>
25 + <p>{error}</p>
26 + </div>
27 + );
28 +
29 + if (!stats)
30 + return (
31 + <div className="container">
32 + <div className="notice">Chargement…</div>
33 + </div>
34 + );
35 +
36 + const maxType = Math.max(1, ...stats.par_type.map((t) => t.n));
37 +
38 + return (
39 + <div className="container sources">
40 + <span className="kicker">Portrait — l'offre de formation au Québec</span>
41 + <h1>Statistiques</h1>
42 + <p className="sub">
43 + Portrait en direct des formations actives agrégées par Forma-Ka, tous
44 + établissements confondus.
45 + </p>
46 +
47 + <div className="stat-row" style={{ marginBottom: 28 }}>
48 + <span className="stat-chip"><b>{stats.total}</b> formations actives</span>
49 + <span className="stat-chip"><b>{stats.sources}</b> établissements</span>
50 + {(stats.gratuites ?? 0) > 0 && (
51 + <span className="stat-chip"><b>{stats.gratuites}</b> gratuites</span>
52 + )}
53 + {(stats.en_ligne ?? 0) > 0 && (
54 + <span className="stat-chip"><b>{stats.en_ligne}</b> en ligne</span>
55 + )}
56 + {stats.avg_price != null && (
57 + <span className="stat-chip">
58 + prix moyen (affiché) <b>{Math.round(stats.avg_price).toLocaleString("fr-CA")} $</b>
59 + </span>
60 + )}
61 + {stats.avg_hours != null && (
62 + <span className="stat-chip">
63 + durée moyenne <b>{Math.round(stats.avg_hours)} h</b>
64 + </span>
65 + )}
66 + </div>
67 +
68 + <h2 style={{ fontFamily: "var(--font-display)" }}>Par type de formation</h2>
69 + <div style={{ display: "grid", gap: 8, marginBottom: 36 }}>
70 + {stats.par_type.map((t) => (
71 + <Link
72 + key={t.t || "(sans type)"}
73 + to={`/?training_type=${encodeURIComponent(t.t)}`}
74 + style={{
75 + display: "grid", gridTemplateColumns: "180px 1fr 56px",
76 + alignItems: "center", gap: 12, color: "var(--ink)",
77 + }}
78 + >
79 + <span style={{ fontWeight: 600, fontSize: ".92rem" }}>{t.t || "Autre"}</span>
80 + <span style={{ background: "var(--surface-2)", border: "1px solid var(--line)", borderRadius: 6, overflow: "hidden", height: 18 }}>
81 + <span style={{
82 + display: "block", height: "100%",
83 + width: `${Math.max(2, Math.round((t.n / maxType) * 100))}%`,
84 + background: "var(--lime)", borderRight: "1px solid var(--ink)",
85 + }} />
86 + </span>
87 + <span style={{ fontFamily: "var(--font-mono)", fontSize: ".85rem", textAlign: "right" }}>
88 + {t.n}
89 + </span>
90 + </Link>
91 + ))}
92 + </div>
93 +
94 + <h2 style={{ fontFamily: "var(--font-display)" }}>Synchronisations récentes</h2>
95 + <div className="src-wrap">
96 + <table className="src-table">
97 + <thead>
98 + <tr>
99 + <th>Source</th>
100 + <th>Quand</th>
101 + <th style={{ textAlign: "right" }}>Trouvées</th>
102 + <th style={{ textAlign: "right" }}>+ / ~ / −</th>
103 + <th>État</th>
104 + </tr>
105 + </thead>
106 + <tbody>
107 + {stats.recent_syncs.map((s, i) => (
108 + <tr key={i}>
109 + <td>{sourceName(s.source)}</td>
110 + <td style={{ color: "var(--ink-3)" }}>
111 + {new Date(s.ts * 1000).toLocaleString("fr-CA")}
112 + </td>
113 + <td style={{ textAlign: "right" }}>{s.found}</td>
114 + <td style={{ textAlign: "right", fontFamily: "var(--font-mono)", fontSize: ".85rem" }}>
115 + {s.added} / {s.updated} / {s.removed}
116 + </td>
117 + <td>
118 + {s.ok ? (
119 + s.message === "ok"
120 + ? <span className="pill ok">ok</span>
121 + : <span className="pill todo" title={s.message}>alerte</span>
122 + ) : (
123 + <span className="pill todo" title={s.message}>échec</span>
124 + )}
125 + </td>
126 + </tr>
127 + ))}
128 + </tbody>
129 + </table>
130 + </div>
131 + </div>
132 + );
133 +}
added frontend/src/styles.css +1101 −0
@@ -0,0 +1,1101 @@
1 +/* -----------------------------------------------------------------------------
2 + Forma-Ka — Agrégateur de formations (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + styles.css : système de design « éditorial sharp » — thème clair
5 + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
6 + · Signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
7 + · Accent surligneur ambré #ffd54d sur encre profonde
8 + · 100 % adaptatif mobile (PWA installable, safe-areas iOS)
9 +----------------------------------------------------------------------------- */
10 +:root {
11 + --paper: #f5f3ee;
12 + --surface: #ffffff;
13 + --surface-2: #faf9f5;
14 + --ink: #141814;
15 + --ink-2: #4d5551;
16 + --ink-3: #8b928c;
17 + --line: rgba(20, 24, 20, 0.14);
18 + --line-strong: rgba(20, 24, 20, 0.85);
19 + --green: #1d3f66;
20 + --green-deep: #122a4a;
21 + --lime: #ffd54d;
22 + --lime-soft: #fdf2cf;
23 + --amber: #e8a33d;
24 + --amber-soft: #fdf3e2;
25 + --danger: #b3423a;
26 + --r-card: 10px;
27 + --r-ctl: 6px;
28 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
29 + --shadow-off: 6px 6px 0 var(--ink);
30 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
31 + --font-display: "Space Grotesk", system-ui, sans-serif;
32 + --font-body: "Inter", system-ui, sans-serif;
33 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
34 +}
35 +
36 +* { box-sizing: border-box; }
37 +html { scroll-behavior: smooth; }
38 +body {
39 + margin: 0;
40 + background: var(--paper);
41 + color: var(--ink);
42 + font-family: var(--font-body);
43 + font-size: 15px;
44 + line-height: 1.55;
45 + -webkit-font-smoothing: antialiased;
46 + padding-bottom: env(safe-area-inset-bottom);
47 +}
48 +/* grain subtil — texture signature */
49 +body::before {
50 + content: "";
51 + position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35;
52 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
53 +}
54 +#root { position: relative; z-index: 1; }
55 +
56 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0; }
57 +a { color: inherit; text-decoration: none; }
58 +button { font-family: inherit; }
59 +img { display: block; }
60 +::selection { background: var(--lime); color: var(--ink); }
61 +
62 +.mono { font-family: var(--font-mono); }
63 +.kicker {
64 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
65 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--green);
66 + display: inline-flex; align-items: center; gap: 8px;
67 +}
68 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
69 +
70 +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
71 +@media (max-width: 640px) { .container { padding: 0 16px; } }
72 +
73 +/* ================= Header ================= */
74 +.header {
75 + position: sticky; top: 0; z-index: 50;
76 + background: rgba(245, 243, 238, 0.88);
77 + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
78 + border-bottom: 2px solid var(--ink);
79 +}
80 +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
81 +.brand {
82 + font-family: var(--font-display); font-weight: 700; font-size: 26px;
83 + letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1;
84 +}
85 +.brand .ka {
86 + background: var(--ink); color: var(--lime); padding: 2px 7px 4px;
87 + border-radius: 6px; margin-left: 3px; transform: rotate(-2deg);
88 + transition: transform 0.2s ease;
89 +}
90 +.brand:hover .ka { transform: rotate(0deg); }
91 +.brand-tag {
92 + font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
93 + letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px;
94 +}
95 +@media (max-width: 760px) { .brand-tag { display: none; } }
96 +.nav { margin-left: auto; display: flex; gap: 4px; }
97 +.nav a {
98 + padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px;
99 + color: var(--ink-2); border: 1.5px solid transparent;
100 + transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center;
101 +}
102 +.nav a:hover { border-color: var(--ink); color: var(--ink); }
103 +.nav a.active { background: var(--ink); color: var(--lime); }
104 +
105 +/* ================= Ticker ================= */
106 +.ticker {
107 + background: var(--ink); color: var(--lime); overflow: hidden;
108 + font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em;
109 + text-transform: uppercase; padding: 7px 0; white-space: nowrap;
110 + border-bottom: 1px solid rgba(217, 242, 107, 0.25);
111 +}
112 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; }
113 +.ticker span { padding: 0 26px; position: relative; }
114 +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
115 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
116 +@media (prefers-reduced-motion: reduce) {
117 + .ticker-track { animation: none; }
118 + * { transition-duration: 0.01ms !important; }
119 +}
120 +
121 +/* ================= Hero ================= */
122 +.hero { padding: 58px 0 22px; }
123 +@media (max-width: 640px) { .hero { padding: 34px 0 14px; } }
124 +.hero h1 {
125 + font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98;
126 + text-transform: uppercase; letter-spacing: -0.035em; max-width: 900px; margin-top: 14px;
127 +}
128 +.hero h1 .outline {
129 + color: transparent; -webkit-text-stroke: 2px var(--ink);
130 +}
131 +.hero h1 .hl {
132 + background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block;
133 + transform: rotate(-1deg);
134 +}
135 +.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 640px; margin: 20px 0 0; }
136 +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
137 +.stat-chip {
138 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px;
139 + padding: 8px 16px; font-family: var(--font-mono); font-size: 12px;
140 + color: var(--ink-2); display: flex; gap: 8px; align-items: center;
141 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);
142 +}
143 +.stat-chip b { color: var(--ink); font-weight: 700; }
144 +.stat-chip .pulse {
145 + width: 8px; height: 8px; border-radius: 50%; background: var(--green);
146 + box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite;
147 +}
148 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(217, 242, 107, 0.4); } }
149 +
150 +/* ================= Filter bar ================= */
151 +.filterbar {
152 + background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card);
153 + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 30px 0 6px;
154 + display: flex; flex-direction: column; gap: 0; min-width: 0;
155 +}
156 +
157 +/* — rangée principale — */
158 +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
159 +.f-search {
160 + flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 9px;
161 + border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2);
162 + padding: 0 13px; min-height: 52px; color: var(--ink-2);
163 + transition: box-shadow 0.15s ease;
164 +}
165 +.f-search:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); }
166 +.f-search input {
167 + border: none; background: none; outline: none; flex: 1; min-width: 0;
168 + font-size: 15px; color: var(--ink); font-family: inherit;
169 +}
170 +.f-clear {
171 + border: none; background: var(--line); color: var(--ink-2); border-radius: 50%;
172 + width: 20px; height: 20px; font-size: 10px; cursor: pointer; flex: none;
173 + display: grid; place-items: center;
174 +}
175 +.f-ctl {
176 + flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center;
177 + gap: 2px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface);
178 + padding: 7px 12px 6px; min-height: 52px; cursor: pointer;
179 + transition: box-shadow 0.15s ease;
180 +}
181 +.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--lime); }
182 +.f-ctl > span {
183 + font-family: var(--font-mono); font-size: 9px; font-weight: 700;
184 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
185 +}
186 +.f-ctl select {
187 + border: none; background: transparent; outline: none; font-family: var(--font-display);
188 + font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer;
189 + appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px;
190 + text-overflow: ellipsis;
191 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E");
192 + background-repeat: no-repeat; background-position: right center;
193 +}
194 +.range-pair { display: flex; align-items: center; gap: 4px; }
195 +.range-pair select { max-width: 86px; }
196 +.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
197 +.f-more {
198 + flex: none; align-self: stretch; border: 1.5px solid var(--ink); border-radius: 9px;
199 + background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer;
200 + font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px;
201 + transition: all 0.13s ease;
202 +}
203 +.f-more:hover { background: var(--lime-soft); }
204 +.f-more.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.22); }
205 +
206 +/* — panneau avancé — */
207 +.f-adv {
208 + display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px;
209 + border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0;
210 + animation: adv-in 0.18s ease;
211 +}
212 +@keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
213 +.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
214 +.f-group > label {
215 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
216 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
217 +}
218 +.f-group-end { justify-content: flex-end; }
219 +.f-group .btn:disabled { opacity: 0.4; cursor: default; }
220 +.f-native {
221 + border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl);
222 + padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none;
223 + font-family: inherit; min-height: 42px; width: 100%; min-width: 0;
224 + appearance: none; -webkit-appearance: none;
225 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
226 + background-repeat: no-repeat; background-position: right 12px center;
227 +}
228 +.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
229 +
230 +/* segments (pilules soudées) */
231 +.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
232 +.seg button {
233 + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2);
234 + padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px;
235 + cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 38px;
236 + transition: all 0.12s ease;
237 +}
238 +.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; }
239 +.seg button:last-child { border-radius: 0 8px 8px 0; }
240 +.seg button:hover { background: var(--lime-soft); color: var(--ink); }
241 +.seg button.on { background: var(--ink); color: var(--lime); position: relative; z-index: 1; }
242 +
243 +/* pastilles de filtres actifs */
244 +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; }
245 +.pill {
246 + display: inline-flex; align-items: center; gap: 7px;
247 + border: 1.5px solid var(--ink); background: var(--lime-soft); color: var(--ink);
248 + border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600;
249 + font-family: var(--font-mono); cursor: pointer; transition: all 0.13s ease;
250 +}
251 +.pill:hover { background: var(--lime); }
252 +.pill-x { font-size: 10px; opacity: 0.65; }
253 +.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }
254 +.pill-clear:hover { background: var(--danger); color: #fff; }
255 +.chip-sep { width: 1.5px; align-self: stretch; background: var(--line); margin: 4px 4px; flex: none; }
256 +.link-btn {
257 + background: none; border: none; padding: 0; color: var(--green); font: inherit;
258 + text-decoration: underline; cursor: pointer;
259 +}
260 +.field { display: flex; flex-direction: column; gap: 5px; }
261 +.field label {
262 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
263 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); padding-left: 2px;
264 +}
265 +.field input, .field select {
266 + border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl);
267 + padding: 11px 12px; font-size: 15px; color: var(--ink); outline: none; font-family: inherit;
268 + transition: border-color 0.15s ease, box-shadow 0.15s ease; min-height: 44px;
269 + appearance: none; -webkit-appearance: none;
270 +}
271 +.field select {
272 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
273 + background-repeat: no-repeat; background-position: right 12px center; padding-right: 30px;
274 +}
275 +.field input:focus, .field select:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
276 +.btn {
277 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px;
278 + font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px;
279 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease;
280 + font-family: var(--font-display); letter-spacing: 0.01em;
281 +}
282 +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
283 +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); }
284 +.btn-primary:hover { background: var(--green-deep); }
285 +.btn-ghost { background: transparent; color: var(--ink); }
286 +.btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.2); }
287 +
288 +/* ================= Chips ================= */
289 +.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; }
290 +.chips::-webkit-scrollbar { display: none; }
291 +.chip {
292 + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink);
293 + border-radius: 999px; padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer;
294 + font-family: var(--font-display); white-space: nowrap; min-height: 40px;
295 + transition: all 0.13s ease;
296 +}
297 +.chip:hover { background: var(--lime-soft); transform: translateY(-1px); }
298 +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.2); }
299 +
300 +/* ================= Results ================= */
301 +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }
302 +.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; }
303 +.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; }
304 +.grid {
305 + display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
306 + gap: 22px; padding-bottom: 70px;
307 +}
308 +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 48px; } }
309 +
310 +.card {
311 + background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-card);
312 + overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat);
313 + transition: transform 0.16s ease, box-shadow 0.16s ease;
314 +}
315 +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
316 +.card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
317 +.card-img { position: relative; aspect-ratio: 16/10.5; background: repeating-linear-gradient(45deg, #eceae3 0 12px, #f3f1ea 12px 24px); overflow: hidden; }
318 +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
319 +.card:hover .card-img img { transform: scale(1.05); }
320 +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
321 +.badge {
322 + position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px;
323 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em;
324 + background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink);
325 +}
326 +.badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); }
327 +.badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; }
328 +.card-body { padding: 16px 18px 16px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
329 +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
330 +.card-price small { font-family: var(--font-mono); font-weight: 500; color: var(--ink-3); font-size: 11px; letter-spacing: 0.05em; }
331 +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); }
332 +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; font-family: var(--font-mono); }
333 +.card-meta .sep { width: 4px; height: 4px; background: var(--lime); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); }
334 +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1.5px dashed var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
335 +.source-tag {
336 + font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase;
337 + letter-spacing: 0.08em; color: var(--green-deep); background: var(--lime-soft);
338 + border: 1px solid var(--green); border-radius: 4px; padding: 3px 8px;
339 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;
340 +}
341 +.avail { font-size: 11.5px; color: var(--ink-2); font-weight: 500; text-align: right; }
342 +
343 +/* ================= Skeletons ================= */
344 +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
345 +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); }
346 +.skel .sk-img, .skel .sk-line {
347 + background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%);
348 + background-size: 800px 100%; animation: shimmer 1.4s infinite linear;
349 +}
350 +.skel .sk-img { aspect-ratio: 16/10.5; }
351 +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
352 +.skel .sk-line.short { width: 45%; }
353 +
354 +/* ================= Empty / error ================= */
355 +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
356 +.notice .big { font-size: 44px; margin-bottom: 10px; }
357 +.notice h2 { text-transform: uppercase; }
358 +
359 +/* ================= Detail page ================= */
360 +.detail { padding: 30px 0 90px; }
361 +.crumbs {
362 + font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase;
363 + color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
364 +}
365 +.crumbs a { border-bottom: 1.5px solid transparent; }
366 +.crumbs a:hover { color: var(--green); border-color: var(--green); }
367 +.detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
368 +@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } }
369 +/* les enfants de grille ont min-width:auto par défaut : un contenu large
370 + (grille du quartier) déborderait de l'écran mobile sans ceci */
371 +.detail-grid > * { min-width: 0; }
372 +
373 +.gallery { display: flex; flex-direction: column; gap: 10px; }
374 +.gallery-main {
375 + border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--ink);
376 + aspect-ratio: 16/10; background: #eceae3; cursor: zoom-in; box-shadow: var(--shadow-off-soft);
377 +}
378 +.gallery-main img { width: 100%; height: 100%; object-fit: cover; }
379 +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; }
380 +.thumbs button {
381 + border: 2px solid var(--line); border-radius: 8px; overflow: hidden; padding: 0; cursor: pointer;
382 + aspect-ratio: 4/3; background: #eceae3; transition: border-color 0.12s ease, transform 0.12s ease;
383 +}
384 +.thumbs button:hover { transform: translateY(-2px); }
385 +.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
386 +.thumbs img { width: 100%; height: 100%; object-fit: cover; }
387 +
388 +.panel {
389 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
390 + box-shadow: var(--shadow-off-soft); padding: 26px; position: sticky; top: 120px;
391 +}
392 +@media (max-width: 900px) { .panel { position: static; } }
393 +.panel .price { font-family: var(--font-display); font-size: 34px; font-weight: 700; letter-spacing: -0.03em; }
394 +.panel .price small { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); font-weight: 500; letter-spacing: 0.05em; }
395 +.panel h1 { font-size: 22px; margin: 8px 0 2px; }
396 +.panel .loc { color: var(--ink-2); font-size: 14px; }
397 +.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
398 +@media (max-width: 380px) { .kv { grid-template-columns: 1fr; } }
399 +.kv .cell { background: var(--surface-2); border: 1.5px solid var(--line); border-radius: var(--r-ctl); padding: 10px 13px; }
400 +.kv .cell .k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; }
401 +.kv .cell .v { font-weight: 700; font-size: 14.5px; margin-top: 2px; font-family: var(--font-display); }
402 +.klabel { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; margin-bottom: 8px; }
403 +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 20px; }
404 +.amenity {
405 + background: var(--lime-soft); color: var(--green-deep); font-size: 12px; font-weight: 600;
406 + border: 1px solid var(--green); border-radius: 999px; padding: 5px 12px;
407 +}
408 +.cta {
409 + display: block; text-align: center; background: var(--ink); color: var(--lime);
410 + font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl);
411 + padding: 15px; border: 1.5px solid var(--ink); min-height: 48px;
412 + box-shadow: 4px 4px 0 rgba(20,24,20,0.25); transition: all 0.14s ease;
413 +}
414 +.cta:hover { background: var(--lime); color: var(--ink); }
415 +.cta:active { transform: translate(2px, 2px); box-shadow: none; }
416 +.panel .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; }
417 +
418 +/* ================= Lightbox ================= */
419 +.lightbox {
420 + position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: 100;
421 + display: flex; align-items: center; justify-content: center; cursor: zoom-out;
422 + padding: max(16px, env(safe-area-inset-top)) 16px;
423 +}
424 +.lightbox img { max-width: 94vw; max-height: 90vh; border-radius: 6px; border: 2px solid var(--lime); }
425 +
426 +/* ================= Sources page ================= */
427 +.sources { padding: 44px 0 90px; }
428 +.sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; }
429 +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 700px; }
430 +.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); }
431 +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 720px; }
432 +.src-table th {
433 + text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
434 + letter-spacing: 0.12em; color: var(--ink-3); padding: 14px 18px;
435 + border-bottom: 1.5px solid var(--ink); background: var(--surface-2);
436 +}
437 +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
438 +.src-table tr:last-child td { border-bottom: none; }
439 +.src-table tr:hover td { background: var(--surface-2); }
440 +.src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
441 +.pill { display: inline-block; border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
442 +.pill.ok { background: var(--lime); color: var(--ink); border: 1px solid var(--ink); }
443 +.pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); }
444 +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; }
445 +
446 +/* ================= Page Statistiques ================= */
447 +.stats-page { padding: 44px 0 90px; }
448 +.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; }
449 +.stats-page .sub { color: var(--ink-2); max-width: 640px; margin-bottom: 30px; }
450 +
451 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
452 +.tile {
453 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
454 + padding: 18px 20px; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1);
455 +}
456 +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.03em; line-height: 1.05; }
457 +.tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }
458 +.hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); }
459 +.hero-tile .tile-k { color: rgba(217, 242, 107, 0.7); }
460 +
461 +.viz-card {
462 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
463 + box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px;
464 +}
465 +.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; }
466 +.viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }
467 +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
468 +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
469 +
470 +/* Histogramme (mono-série, encre verte) */
471 +.histo { display: flex; align-items: stretch; gap: 2px; height: 240px; }
472 +.histo-col { flex: 1; display: flex; flex-direction: column; min-width: 0; cursor: default; }
473 +.histo-bar-zone { flex: 1; display: flex; align-items: flex-end; border-bottom: 1.5px solid var(--ink); }
474 +.histo-bar {
475 + width: 100%; background: var(--green); border-radius: 4px 4px 0 0;
476 + min-height: 2px; transition: background 0.12s ease;
477 +}
478 +.histo-col:hover .histo-bar { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
479 +.histo-x { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3); text-align: left; height: 18px; padding-top: 5px; overflow: visible; white-space: nowrap; }
480 +
481 +/* Barres horizontales (mono-série) */
482 +.hbars { display: flex; flex-direction: column; gap: 7px; }
483 +.hbar-row {
484 + display: grid; grid-template-columns: minmax(96px, 170px) 1fr auto;
485 + gap: 12px; align-items: center; min-height: 26px; cursor: default;
486 +}
487 +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
488 +.hbar-label a { border-bottom: 1.5px solid var(--lime); }
489 +.hbar-label a:hover { color: var(--green-deep); }
490 +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
491 +.hbar-fill {
492 + display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0;
493 + min-width: 2px; transition: background 0.12s ease;
494 +}
495 +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
496 +.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
497 +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); }
498 +@media (max-width: 640px) {
499 + .hbar-row { grid-template-columns: minmax(80px, 110px) 1fr auto; gap: 8px; }
500 + .hbar-value em { display: none; }
501 + .viz-card { padding: 20px 16px 14px; }
502 + .histo { height: 170px; }
503 +}
504 +
505 +/* Infobulle */
506 +.viz-tip {
507 + position: fixed; z-index: 120; pointer-events: none; max-width: 180px;
508 + background: var(--ink); color: var(--paper); border-radius: 8px;
509 + border: 1px solid var(--lime); padding: 9px 12px; font-size: 12px; line-height: 1.5;
510 + box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35);
511 +}
512 +.viz-tip-title { font-family: var(--font-display); font-weight: 700; color: var(--lime); margin-bottom: 2px; }
513 +
514 +/* Vue tableau repliable (accessibilité / relief) */
515 +.viz-table { margin-top: 16px; border-top: 1.5px dashed var(--line); padding-top: 10px; }
516 +.viz-table summary {
517 + cursor: pointer; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
518 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--green-deep);
519 +}
520 +.viz-table table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13px; }
521 +.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); }
522 +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
523 +
524 +.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
525 +
526 +/* ================= Mobile : feuille de filtres + FAB ================= */
527 +.sheet-head { display: none; }
528 +.sheet-apply { display: none; }
529 +.sheet-backdrop { display: none; }
530 +.fab { display: none; }
531 +
532 +@media (max-width: 640px) {
533 + /* En-tête compact */
534 + .header-inner { height: 56px; }
535 + .brand { font-size: 21px; }
536 + .nav a { padding: 8px 13px; font-size: 13.5px; }
537 + .ticker { font-size: 10.5px; padding: 6px 0; }
538 +
539 + /* Héro resserré + stats en rangée défilante */
540 + .hero h1 { font-size: clamp(30px, 9.4vw, 44px); }
541 + .hero p.lede { font-size: 15px; }
542 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
543 + .stat-row::-webkit-scrollbar { display: none; }
544 + .stat-chip { flex: 0 0 auto; white-space: nowrap; }
545 +
546 + /* La barre de filtres devient une feuille coulissante (bottom sheet) */
547 + .filterbar { display: none; }
548 + .filterbar.open .f-primary { flex-direction: column; }
549 + .filterbar.open .f-ctl select { max-width: none; width: 100%; }
550 + .filterbar.open .f-more { display: none; }
551 + .filterbar.open .f-adv { margin-top: 4px; }
552 + .filterbar.open {
553 + display: flex; flex-direction: column; gap: 12px;
554 + position: fixed; left: 0; right: 0; bottom: 0; z-index: 95;
555 + margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0;
556 + max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
557 + padding: 16px 18px calc(18px + env(safe-area-inset-bottom));
558 + box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35);
559 + animation: sheet-up 0.22s ease;
560 + }
561 + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
562 + .filterbar.open .sheet-head {
563 + display: flex; justify-content: space-between; align-items: center;
564 + font-family: var(--font-display); font-weight: 700; font-size: 17px;
565 + text-transform: uppercase; letter-spacing: -0.01em;
566 + position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px;
567 + border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1;
568 + }
569 + .sheet-close {
570 + border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%;
571 + width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1;
572 + }
573 + .filterbar.open .sheet-apply { display: block; width: 100%; }
574 + .sheet-backdrop {
575 + display: block; position: fixed; inset: 0; z-index: 90;
576 + background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px);
577 + }
578 +
579 + /* Bouton flottant */
580 + .fab {
581 + display: flex; align-items: center; gap: 6px;
582 + position: fixed; left: 50%; transform: translateX(-50%);
583 + bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80;
584 + background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink);
585 + border-radius: 999px; padding: 13px 24px; font-family: var(--font-display);
586 + font-weight: 700; font-size: 15px; cursor: pointer;
587 + box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(20, 24, 20, 0.25);
588 + }
589 + .fab:active { transform: translateX(-50%) scale(0.97); }
590 +
591 + /* Fiche logement : vignettes en bande défilante + panneau non collant */
592 + .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; padding-bottom: 4px; }
593 + .thumbs::-webkit-scrollbar { display: none; }
594 + .thumbs button { flex: 0 0 96px; }
595 + .detail { padding-top: 20px; }
596 + .panel { padding: 20px; }
597 + .panel .price { font-size: 28px; }
598 + .results-head { margin-top: 20px; }
599 + .chips { margin-right: -16px; padding-right: 16px; }
600 + .notice { padding: 48px 16px; }
601 +}
602 +
603 +/* Anti-zoom iOS : les champs doivent faire >= 16px */
604 +@media (max-width: 900px) {
605 + .field input, .field select { font-size: 16px; }
606 +}
607 +
608 +/* ================= Footer ================= */
609 +.footer { background: var(--ink); color: rgba(245, 243, 238, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; }
610 +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 34px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 12px; }
611 +.footer .fbrand .ka { color: var(--lime); }
612 +.footer b { color: var(--paper); }
613 +.footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 720px; }
614 +.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(245,243,238,0.45); margin-top: 18px; }
615 +
616 +/* --- Carte interactive (mission 4) --------------------------------------- */
617 +.results-tools { display: flex; align-items: center; gap: 14px; }
618 +.view-toggle {
619 + display: inline-flex; border: 1.5px solid var(--line-strong);
620 + border-radius: var(--r-ctl); overflow: hidden; background: var(--surface);
621 + box-shadow: var(--shadow-flat);
622 +}
623 +.view-toggle button {
624 + border: 0; background: transparent; padding: 7px 14px; cursor: pointer;
625 + font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
626 +}
627 +.view-toggle button + button { border-left: 1.5px solid var(--line-strong); }
628 +.view-toggle button.on { background: var(--ink); color: var(--lime); }
629 +
630 +.map-split {
631 + display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px;
632 + height: calc(100dvh - 170px); min-height: 420px;
633 +}
634 +.map-list {
635 + overflow-y: auto; display: flex; flex-direction: column; gap: 14px;
636 + padding-right: 4px; scrollbar-width: thin;
637 +}
638 +.map-list .card { margin: 0; flex: 0 0 auto; }
639 +.mapview {
640 + width: 100%; height: 100%; border: 1.5px solid var(--line-strong);
641 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
642 + overflow: hidden; background: var(--surface-2);
643 +}
644 +.map-loading {
645 + display: flex; align-items: center; justify-content: center;
646 + color: var(--ink-3); font-family: var(--font-mono); font-size: 13px;
647 +}
648 +
649 +/* mobile d'abord : carte plein écran, liste masquée (bascule via l'onglet) */
650 +@media (max-width: 780px) {
651 + .map-split { grid-template-columns: 1fr; height: calc(100dvh - 210px); }
652 + .map-list { display: none; }
653 + .results-tools { width: 100%; justify-content: space-between; }
654 +}
655 +
656 +/* popup MapLibre au style Forma-Ka */
657 +.maplibregl-popup-content {
658 + padding: 0; border-radius: var(--r-card); overflow: hidden;
659 + border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off);
660 + font-family: var(--font-body);
661 +}
662 +.maplibregl-popup-close-button {
663 + font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2;
664 + text-shadow: 0 0 4px rgba(20, 24, 20, 0.8);
665 +}
666 +.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; }
667 +.mv-noimg { display: flex; align-items: center; justify-content: center;
668 + font-size: 34px; background: var(--surface-2); }
669 +.mv-pop-body { padding: 10px 12px 12px; }
670 +.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; }
671 +.mv-pop-price small { font-weight: 400; color: var(--ink-3); font-size: 12px; }
672 +.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px;
673 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
674 +.mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; }
675 +.mv-pop-src { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3);
676 + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
677 +.mv-pop-cta {
678 + display: block; margin-top: 10px; padding: 8px 10px; text-align: center;
679 + background: var(--ink); color: var(--lime); border-radius: var(--r-ctl);
680 + font-weight: 600; font-size: 13px;
681 +}
682 +.mv-pop-cta:hover { background: var(--green-deep); }
683 +
684 +/* --- Commodités de proximité (fiche) -------------------------------------- */
685 +.poi-list {
686 + list-style: none; margin: 0; padding: 0;
687 + display: flex; flex-direction: column; gap: 2px;
688 +}
689 +.poi-list li {
690 + display: flex; align-items: center; gap: 9px;
691 + padding: 5px 2px; border-bottom: 1px dashed var(--line);
692 + font-size: 13px;
693 +}
694 +.poi-list li:last-child { border-bottom: 0; }
695 +.poi-ico { width: 20px; text-align: center; flex: 0 0 auto; }
696 +.poi-name { flex: 1; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
697 +.poi-dist { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); font-weight: 600; flex: 0 0 auto; }
698 +
699 +/* --- Menu déroulant mobile ------------------------------------------------- */
700 +.menu-btn {
701 + display: none; position: relative; z-index: 60;
702 + width: 44px; height: 44px; margin-left: auto;
703 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
704 + background: var(--surface); cursor: pointer; padding: 0;
705 + flex-direction: column; align-items: center; justify-content: center; gap: 5px;
706 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);
707 + transition: box-shadow 0.15s ease, transform 0.15s ease;
708 +}
709 +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(20,24,20,0.15); }
710 +.menu-btn span {
711 + display: block; width: 18px; height: 2px; background: var(--ink);
712 + border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease;
713 +}
714 +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
715 +.menu-btn.open span:nth-child(2) { opacity: 0; }
716 +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
717 +
718 +.mobile-menu {
719 + display: none; position: absolute; left: 0; right: 0; top: 100%;
720 + background: var(--paper); border-bottom: 2px solid var(--ink);
721 + box-shadow: 0 18px 30px rgba(20, 24, 20, 0.18);
722 + padding: 8px 16px calc(16px + env(safe-area-inset-bottom));
723 + max-height: 0; overflow: hidden; opacity: 0;
724 + transition: max-height 0.3s ease, opacity 0.22s ease;
725 +}
726 +.mobile-menu.open { max-height: 480px; opacity: 1; }
727 +.mm-link {
728 + display: flex; align-items: center; gap: 12px;
729 + padding: 15px 10px; min-height: 52px;
730 + font-family: var(--font-display); font-weight: 700; font-size: 19px;
731 + letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line);
732 + opacity: 0; transform: translateY(-8px);
733 + transition: opacity 0.25s ease, transform 0.25s ease;
734 +}
735 +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
736 +.mm-link.active { color: var(--green); }
737 +.mm-link.active .mm-arrow { opacity: 1; }
738 +.mm-ico { width: 26px; text-align: center; font-size: 17px; }
739 +.mm-arrow { margin-left: auto; opacity: 0.25; transition: opacity 0.15s ease; }
740 +.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); }
741 +.mm-foot {
742 + padding: 12px 10px 4px; font-family: var(--font-mono);
743 + font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em;
744 +}
745 +.mm-backdrop {
746 + position: fixed; inset: 0; z-index: 40;
747 + background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px);
748 +}
749 +@media (max-width: 760px) {
750 + .menu-btn { display: flex; }
751 + .nav { display: none; }
752 + .mobile-menu { display: block; }
753 +}
754 +
755 +/* --- Bandeau de consentement (témoins) ------------------------------------- */
756 +.cookie-banner {
757 + position: fixed; left: 12px; right: 12px;
758 + bottom: calc(12px + env(safe-area-inset-bottom)); z-index: 80;
759 + animation: cookie-in 0.35s ease;
760 +}
761 +@keyframes cookie-in { from { transform: translateY(24px); opacity: 0; } }
762 +.cookie-inner {
763 + max-width: 720px; margin: 0 auto; background: var(--surface);
764 + border: 2px solid var(--ink); border-radius: var(--r-card);
765 + box-shadow: var(--shadow-off); padding: 16px 18px;
766 +}
767 +.cookie-text { font-size: 13.5px; color: var(--ink-2); line-height: 1.5; }
768 +.cookie-text b { color: var(--ink); }
769 +.cookie-text a { text-decoration: underline; text-underline-offset: 2px; }
770 +.cookie-options { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
771 +.cookie-opt {
772 + display: flex; align-items: flex-start; gap: 10px; font-size: 13px;
773 + color: var(--ink-2); cursor: pointer;
774 +}
775 +.cookie-opt input { margin-top: 2px; accent-color: var(--green); width: 16px; height: 16px; }
776 +.cookie-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; justify-content: flex-end; }
777 +@media (max-width: 640px) {
778 + .cookie-actions { justify-content: stretch; }
779 + .cookie-actions .btn { flex: 1; text-align: center; justify-content: center; }
780 +}
781 +
782 +/* --- Page confidentialité + lien pied de page ------------------------------- */
783 +.legal { padding: 48px 0 70px; max-width: 760px; }
784 +.legal h1 { font-size: clamp(30px, 5vw, 46px); margin: 12px 0 8px; letter-spacing: -0.03em; }
785 +.legal section { margin-top: 26px; }
786 +.legal h3 { font-size: 18px; margin-bottom: 8px; }
787 +.legal p, .legal li { color: var(--ink-2); font-size: 14.5px; }
788 +.legal ul { padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }
789 +.flink {
790 + border: 0; background: none; padding: 0; cursor: pointer;
791 + color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 2px;
792 +}
793 +
794 +/* --- Section « Le quartier » (fiche) --------------------------------------- */
795 +.quartier { margin-top: 34px; }
796 +.quartier h2 { font-size: 24px; letter-spacing: -0.02em; }
797 +.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; }
798 +.q-grid {
799 + display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
800 +}
801 +@media (max-width: 640px) { .q-grid { grid-template-columns: repeat(2, 1fr); } }
802 +.q-cell {
803 + background: var(--surface); border: 1.5px solid var(--line-strong);
804 + border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat);
805 +}
806 +.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; }
807 +.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
808 + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; }
809 +.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; }
810 +.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; }
811 +.q-bar-label { flex: 0 0 150px; color: var(--ink-2); }
812 +@media (max-width: 640px) { .q-bar-label { flex-basis: 120px; font-size: 12px; } }
813 +.q-bar-track {
814 + flex: 1; height: 10px; background: var(--surface-2);
815 + border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden;
816 +}
817 +.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; }
818 +.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono);
819 + font-size: 11.5px; font-weight: 700; }
820 +.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
821 +.q-badge {
822 + display: inline-flex; align-items: center; gap: 6px;
823 + border: 1.5px solid var(--line-strong); border-radius: 999px;
824 + padding: 7px 13px; font-size: 12.5px; background: var(--surface);
825 +}
826 +.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
827 +.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
828 +.quartier .fine { margin-top: 10px; }
829 +
830 +/* --- Fiche v2 (mobile-first) ------------------------------------------------ */
831 +/* mobile : flux unique ordonné ; desktop : 2 colonnes (logement | synthèse) */
832 +.fiche { display: flex; flex-direction: column; gap: 22px; }
833 +.f-col { display: contents; }
834 +.f-galerie { order: 1; } .f-hero { order: 2; } .f-desc { order: 3; }
835 +.f-incl { order: 4; } .f-pratique { order: 5; } .f-quartier { order: 6; }
836 +.f-poi { order: 7; }
837 +@media (min-width: 900px) {
838 + .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
839 + .f-col { display: flex; flex-direction: column; gap: 26px; min-width: 0; }
840 +}
841 +.f-bloc { min-width: 0; }
842 +.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 10px; }
843 +.f-bloc:empty { display: none; }
844 +
845 +/* galerie à balayage natif */
846 +.carousel { position: relative; border-radius: var(--r-card); overflow: hidden;
847 + border: 1.5px solid var(--line-strong); background: var(--surface-2); }
848 +.carousel-track {
849 + display: flex; overflow-x: auto; scroll-snap-type: x mandatory;
850 + -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 16/11;
851 +}
852 +.carousel-track::-webkit-scrollbar { display: none; }
853 +.carousel-track img {
854 + flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center;
855 + cursor: zoom-in;
856 +}
857 +.carousel-empty { display: flex; align-items: center; justify-content: center;
858 + aspect-ratio: 16/11; font-size: 48px; }
859 +.carousel-count {
860 + position: absolute; right: 12px; bottom: 12px; z-index: 2;
861 + background: rgba(20, 24, 20, 0.82); color: var(--lime);
862 + font-family: var(--font-mono); font-size: 12px; font-weight: 700;
863 + padding: 4px 10px; border-radius: 999px;
864 +}
865 +.carousel-nav {
866 + position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
867 + width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink);
868 + background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer;
869 + display: flex; align-items: center; justify-content: center; line-height: 1;
870 +}
871 +.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; }
872 +@media (max-width: 640px) { .carousel-nav { display: none; } } /* balayage natif */
873 +
874 +/* bandeau prix + badge marché */
875 +.f-hero .price { font-size: clamp(34px, 8vw, 44px); }
876 +.deal-badge {
877 + display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 4px;
878 + border-radius: 999px; padding: 7px 14px; font-size: 13px; font-weight: 600;
879 + border: 1.5px solid var(--line-strong);
880 +}
881 +.deal-good { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
882 +.deal-ok { background: var(--surface); color: var(--ink-2); }
883 +.deal-high { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
884 +.f-hero h1 { font-size: clamp(20px, 4.5vw, 26px); margin-top: 8px; }
885 +
886 +/* chips clés à défilement horizontal */
887 +.chips-scroll {
888 + display: flex; gap: 8px; overflow-x: auto; padding: 12px 0 6px;
889 + scrollbar-width: none; -webkit-overflow-scrolling: touch;
890 +}
891 +.chips-scroll::-webkit-scrollbar { display: none; }
892 +.chip-key {
893 + flex: 0 0 auto; background: var(--ink); color: var(--lime);
894 + border-radius: 999px; padding: 8px 14px; font-family: var(--font-mono);
895 + font-size: 12px; font-weight: 700; white-space: nowrap; min-height: 34px;
896 + display: inline-flex; align-items: center;
897 +}
898 +
899 +/* ancres de navigation rapide */
900 +.ancres {
901 + display: flex; gap: 4px; overflow-x: auto; padding: 6px 0 2px;
902 + scrollbar-width: none; border-bottom: 1.5px dashed var(--line); margin-bottom: 8px;
903 +}
904 +.ancres::-webkit-scrollbar { display: none; }
905 +.ancres a {
906 + flex: 0 0 auto; padding: 9px 12px; font-size: 13.5px; font-weight: 600;
907 + color: var(--ink-2); border-radius: var(--r-ctl); min-height: 44px;
908 + display: inline-flex; align-items: center;
909 +}
910 +.ancres a:hover { background: var(--lime-soft); color: var(--ink); }
911 +html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
912 +
913 +/* description restructurée */
914 +.enbref {
915 + background: var(--lime-soft); border: 1.5px solid var(--green);
916 + border-radius: var(--r-card); padding: 12px 15px; color: var(--green-deep);
917 + font-size: 15px; margin: 0 0 14px;
918 +}
919 +.desc-section { margin-bottom: 12px; }
920 +.desc-section h4 { font-size: 15px; margin-bottom: 3px; }
921 +.desc-section p { color: var(--ink-2); font-size: 14.5px; margin: 0; white-space: pre-line; }
922 +.texte-original { margin-top: 12px; }
923 +.texte-original summary {
924 + cursor: pointer; font-family: var(--font-mono); font-size: 12px;
925 + color: var(--ink-3); min-height: 44px; display: flex; align-items: center;
926 +}
927 +.texte-original p { color: var(--ink-3); font-size: 13px; white-space: pre-line; }
928 +
929 +/* inclusions : confirmées vs mentionnées */
930 +.amenity.confirmed { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); font-weight: 600; }
931 +.amenity.unconfirmed { background: transparent; border-style: dashed; color: var(--ink-3); }
932 +
933 +/* détails pratiques : historique de prix */
934 +.prix-histo {
935 + margin-top: 12px; padding: 10px 14px; border-radius: var(--r-card);
936 + border: 1.5px solid var(--line-strong); font-size: 13.5px; background: var(--surface);
937 +}
938 +.prix-histo.down { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
939 +
940 +/* quartier compacté sur mobile */
941 +@media (max-width: 640px) {
942 + .q-cell { padding: 9px 11px; }
943 + .q-val { font-size: 16px; }
944 + .q-bar { gap: 8px; font-size: 12px; }
945 + .q-bar-track { height: 8px; }
946 +}
947 +.q-badge-sub { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-left: 4px; }
948 +
949 +/* POI groupés repliables */
950 +.poi-groupe { border: 1.5px solid var(--line); border-radius: var(--r-card);
951 + margin-bottom: 8px; background: var(--surface); overflow: hidden; }
952 +.poi-groupe summary {
953 + display: flex; justify-content: space-between; align-items: center; gap: 10px;
954 + padding: 12px 14px; cursor: pointer; font-weight: 600; font-size: 14px;
955 + min-height: 48px; list-style: none;
956 +}
957 +.poi-groupe summary::-webkit-details-marker { display: none; }
958 +.poi-groupe summary::after { content: "▾"; color: var(--ink-3); transition: transform 0.15s ease; }
959 +.poi-groupe[open] summary::after { transform: rotate(180deg); }
960 +.poi-resume { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); font-weight: 400; }
961 +.poi-groupe .poi-list { padding: 0 14px 10px; }
962 +
963 +/* CTA sticky mobile */
964 +.cta-sticky {
965 + position: fixed; left: 0; right: 0; bottom: 0; z-index: 45;
966 + display: flex; align-items: center; gap: 12px;
967 + background: rgba(245, 243, 238, 0.94); backdrop-filter: blur(12px);
968 + border-top: 2px solid var(--ink);
969 + padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
970 +}
971 +.cta-sticky .cta { flex: 1; margin: 0; text-align: center; }
972 +.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; }
973 +.cta-sticky-prix small { font-family: var(--font-mono); font-weight: 500; font-size: 10px; color: var(--ink-3); }
974 +@media (min-width: 900px) { .cta-sticky { display: none; } }
975 +@media (max-width: 899px) {
976 + .cta-desktop { display: none; }
977 + .detail { padding-bottom: 84px; } /* place pour le CTA sticky */
978 +}
979 +.f-foot { margin-top: 26px; }
980 +
981 +/* --- Passerelle vers l'annonce originale ------------------------------------ */
982 +.passerelle {
983 + min-height: calc(100dvh - 120px); display: flex; align-items: center;
984 + justify-content: center; padding: 24px 16px;
985 +}
986 +.pass-card {
987 + width: 100%; max-width: 520px; background: var(--surface);
988 + border: 2px solid var(--ink); border-radius: var(--r-card);
989 + box-shadow: var(--shadow-off); padding: 30px 28px; text-align: center;
990 +}
991 +.pass-brands {
992 + display: flex; align-items: center; justify-content: center; gap: 14px;
993 + margin-bottom: 22px; flex-wrap: wrap;
994 +}
995 +.pass-brand {
996 + font-family: var(--font-display); font-weight: 700; font-size: 24px;
997 + letter-spacing: -0.04em; display: inline-flex; align-items: center;
998 +}
999 +.pass-brand .ka {
1000 + background: var(--ink); color: var(--lime); padding: 2px 7px 4px;
1001 + border-radius: 6px; margin-left: 3px; transform: rotate(-2deg);
1002 +}
1003 +.pass-trajet { display: inline-flex; align-items: center; gap: 5px; }
1004 +.pass-dot {
1005 + width: 6px; height: 6px; border-radius: 50%; background: var(--green);
1006 + opacity: 0.25; animation: pass-dot 1.2s ease infinite;
1007 +}
1008 +.pass-dot:nth-child(2) { animation-delay: 0.2s; }
1009 +.pass-dot:nth-child(3) { animation-delay: 0.4s; }
1010 +@keyframes pass-dot { 40% { opacity: 1; transform: translateX(2px); } }
1011 +.pass-fleche { color: var(--green); font-size: 18px; }
1012 +.pass-gestionnaire {
1013 + font-family: var(--font-display); font-weight: 700; font-size: 19px;
1014 + display: inline-flex; align-items: center; gap: 8px;
1015 + background: var(--lime-soft); border: 1.5px solid var(--green);
1016 + border-radius: 999px; padding: 6px 16px; color: var(--green-deep);
1017 +}
1018 +.pass-card h1 { font-size: 20px; letter-spacing: -0.02em; }
1019 +.pass-texte { color: var(--ink-2); font-size: 14.5px; margin: 10px 0 20px; }
1020 +.pass-progress {
1021 + height: 10px; border: 1.5px solid var(--ink); border-radius: 999px;
1022 + overflow: hidden; background: var(--surface-2); margin-bottom: 20px;
1023 +}
1024 +.pass-progress-fill {
1025 + display: block; height: 100%; width: 0; background: var(--lime);
1026 + border-right: 2px solid var(--ink);
1027 +}
1028 +.pass-progress-fill.go { animation: pass-fill 2.2s linear forwards; }
1029 +@keyframes pass-fill { to { width: 100%; } }
1030 +@media (prefers-reduced-motion: reduce) {
1031 + .pass-progress-fill.go { animation: none; width: 100%; }
1032 + .pass-dot { animation: none; opacity: 0.7; }
1033 +}
1034 +.pass-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 14px; }
1035 +
1036 +/* --- boutons PDF -------------------------------------------------------- */
1037 +.btn-pdf { display: block; text-align: center; margin-top: 10px; width: 100%; }
1038 +
1039 +/* --- Onglets de la page Stats -------------------------------------------- */
1040 +.onglets {
1041 + display: flex; gap: 6px; overflow-x: auto; margin: 26px 0 22px;
1042 + padding-bottom: 4px; scrollbar-width: none;
1043 + border-bottom: 2px solid var(--ink);
1044 +}
1045 +.onglets::-webkit-scrollbar { display: none; }
1046 +.onglet {
1047 + flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px;
1048 + border: 1.5px solid var(--ink); border-bottom: 0;
1049 + border-radius: var(--r-ctl) var(--r-ctl) 0 0;
1050 + background: var(--surface); color: var(--ink-2); cursor: pointer;
1051 + padding: 10px 16px; font-weight: 600; font-size: 13.5px; min-height: 44px;
1052 + transition: background 0.12s ease, color 0.12s ease;
1053 +}
1054 +.onglet:hover { background: var(--lime-soft); color: var(--ink); }
1055 +.onglet.on { background: var(--ink); color: var(--lime); }
1056 +
1057 +/* baisses de prix */
1058 +.baisses { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
1059 +.baisses li {
1060 + display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
1061 + padding: 9px 4px; border-bottom: 1px dashed var(--line); font-size: 13.5px;
1062 +}
1063 +.baisses li a { font-weight: 600; text-decoration: underline; text-underline-offset: 2px; }
1064 +.baisse-ville { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
1065 +.baisse-prix { margin-left: auto; }
1066 +.baisse-prix s { color: var(--ink-3); }
1067 +.baisse-pct {
1068 + font-style: normal; font-family: var(--font-mono); font-weight: 700;
1069 + background: var(--lime-soft); border: 1px solid var(--green);
1070 + color: var(--green-deep); border-radius: 999px; padding: 2px 8px;
1071 + font-size: 11px; margin-left: 8px;
1072 +}
1073 +.alertes { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--ink-2); }
1074 +
1075 +/* --- pagination (12 formations / page) ------------------------------------ */
1076 +.pagination {
1077 + display: flex; flex-wrap: wrap; justify-content: center; align-items: center;
1078 + gap: 8px; margin: 34px 0 6px;
1079 +}
1080 +.page-btn {
1081 + font-family: var(--font-mono); font-size: 0.85rem; font-weight: 500;
1082 + min-width: 38px; padding: 9px 12px; cursor: pointer;
1083 + background: var(--surface); color: var(--ink);
1084 + border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl);
1085 + transition: box-shadow 0.12s ease, transform 0.12s ease, background 0.12s ease;
1086 +}
1087 +.page-btn:hover:not(:disabled):not(.on) {
1088 + background: var(--lime-soft);
1089 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.22); transform: translate(-1px, -1px);
1090 +}
1091 +.page-btn.on {
1092 + background: var(--ink); color: var(--lime);
1093 + box-shadow: 3px 3px 0 var(--lime); cursor: default;
1094 +}
1095 +.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
1096 +.page-prev, .page-next { font-family: var(--font-body); font-weight: 600; }
1097 +.page-ellipsis { color: var(--ink-3); padding: 0 2px; user-select: none; }
1098 +@media (max-width: 560px) {
1099 + .page-btn { min-width: 34px; padding: 8px 10px; }
1100 + .page-prev, .page-next { flex-basis: 40%; }
1101 +}
added frontend/src/vite-env.d.ts +1 −0
@@ -0,0 +1 @@
1 +/// <reference types="vite/client" />
added frontend/tsconfig.json +20 −0
@@ -0,0 +1,20 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2020",
4 + "useDefineForClassFields": true,
5 + "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 + "module": "ESNext",
7 + "skipLibCheck": true,
8 + "moduleResolution": "bundler",
9 + "allowImportingTsExtensions": true,
10 + "resolveJsonModule": true,
11 + "isolatedModules": true,
12 + "noEmit": true,
13 + "jsx": "react-jsx",
14 + "strict": true,
15 + "noUnusedLocals": false,
16 + "noUnusedParameters": false,
17 + "noFallthroughCasesInSwitch": true
18 + },
19 + "include": ["src"]
20 +}
added frontend/tsconfig.tsbuildinfo +1 −0
@@ -0,0 +1 @@
1 +{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/cookieconsent.tsx","./src/components/formationcard.tsx","./src/pages/formation.tsx","./src/pages/home.tsx","./src/pages/privacy.tsx","./src/pages/sources.tsx","./src/pages/stats.tsx"],"version":"5.9.3"}
\ No newline at end of file
added frontend/vite.config.ts +15 −0
@@ -0,0 +1,15 @@
1 +// -----------------------------------------------------------------------------
2 +// Forma-Ka — Agrégateur de formations (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// vite.config.ts : configuration Vite (proxy API en dev, build vers dist/)
5 +// -----------------------------------------------------------------------------
6 +import { defineConfig } from "vite";
7 +import react from "@vitejs/plugin-react";
8 +
9 +export default defineConfig({
10 + plugins: [react()],
11 + server: {
12 + proxy: { "/api": "http://localhost:8080" },
13 + },
14 + build: { outDir: "dist" },
15 +});
added requirements.txt +6 −0
@@ -0,0 +1,6 @@
1 +# Forma-Ka — dépendances backend
2 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +fastapi>=0.110
4 +uvicorn>=0.29
5 +requests>=2.31
6 +beautifulsoup4>=4.12
added run.py +47 −0
@@ -0,0 +1,47 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# Forma-Ka — Agrégateur de formations (province de Québec)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# run.py : point d'entrée — `sync`, `watch`, `serve`
6 +# -----------------------------------------------------------------------------
7 +"""Utilisation :
8 + python run.py sync [source ...] # synchronise les formations
9 + python run.py watch [minutes] # synchronise en boucle (défaut 360 min)
10 + python run.py serve [port] # démarre l'API + le frontend (défaut 8080)
11 +"""
12 +from __future__ import annotations
13 +
14 +import os
15 +import sys
16 +from pathlib import Path
17 +
18 +# Charger .env (FIRECRAWL_API_KEY, SCRAPFLY_API_KEY…) sans dépendance externe
19 +_env = Path(__file__).parent / ".env"
20 +if _env.exists():
21 + for line in _env.read_text().splitlines():
22 + line = line.strip()
23 + if line and not line.startswith("#") and "=" in line:
24 + k, _, v = line.partition("=")
25 + os.environ.setdefault(k.strip(), v.strip())
26 +
27 +
28 +def main() -> None:
29 + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
30 + if cmd == "sync":
31 + from formaka import ingest
32 + ingest.run(sys.argv[2:] or None)
33 + elif cmd == "watch":
34 + from formaka import ingest
35 + minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 360
36 + ingest.watch(minutes * 60)
37 + elif cmd == "serve":
38 + import uvicorn
39 + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
40 + uvicorn.run("formaka.web:app", host="0.0.0.0", port=port)
41 + else:
42 + print(__doc__)
43 + sys.exit(1)
44 +
45 +
46 +if __name__ == "__main__":
47 + main()
added tests/test_normalize.py +95 −0
@@ -0,0 +1,95 @@
1 +# -----------------------------------------------------------------------------
2 +# Forma-Ka — Agrégateur de formations (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# tests/test_normalize.py : couche de normalisation commune
5 +# -----------------------------------------------------------------------------
6 +from formaka.normalize import (
7 + extract_details,
8 + normalize_language,
9 + normalize_mode,
10 + normalize_type,
11 + parse_date_fr,
12 + parse_duration_hours,
13 + parse_price,
14 +)
15 +from formaka.schema import Formation
16 +
17 +
18 +def test_parse_price():
19 + assert parse_price("1 295,00 $ + tx") == 1295.0
20 + assert parse_price("795 $") == 795.0
21 + assert parse_price("$1,250.00") == 1250.0
22 + assert parse_price("Gratuit") == 0.0
23 + assert parse_price("Sans frais") == 0.0
24 + assert parse_price("") is None
25 + assert parse_price("Sur demande") is None
26 +
27 +
28 +def test_parse_duration_hours():
29 + assert parse_duration_hours("2 jours") == 14.0
30 + assert parse_duration_hours("45 heures") == 45.0
31 + assert parse_duration_hours("3,5 h") == 3.5
32 + assert parse_duration_hours("90 minutes") == 1.5
33 + assert parse_duration_hours("demi-journée") == 3.5
34 + assert parse_duration_hours("") is None
35 +
36 +
37 +def test_normalize_mode():
38 + assert normalize_mode("Classe virtuelle") == "en ligne"
39 + assert normalize_mode("À distance") == "en ligne"
40 + assert normalize_mode("En salle") == "présentiel"
41 + assert normalize_mode("Comodal") == "hybride"
42 + assert normalize_mode("À votre rythme") == "asynchrone"
43 +
44 +
45 +def test_normalize_type():
46 + assert normalize_type("Séminaire de 2 jours") == "Séminaire"
47 + assert normalize_type("Cours de 1er cycle") == "Cours universitaire"
48 + assert normalize_type("AEC en bureautique") == "Cours collégial"
49 + assert normalize_type("Webinaire gratuit") == "Webinaire"
50 + assert normalize_type("Formation continue") == "Formation continue"
51 +
52 +
53 +def test_normalize_language():
54 + assert normalize_language("Français") == "fr"
55 + assert normalize_language("English") == "en"
56 + assert normalize_language("FR et EN") == "fr/en"
57 +
58 +
59 +def test_parse_date_fr():
60 + assert parse_date_fr("Débute le 14 octobre 2026") == "2026-10-14"
61 + assert parse_date_fr("1er décembre 2026") == "2026-12-01"
62 + assert parse_date_fr("2026-09-08") == "2026-09-08"
63 + assert parse_date_fr("14/10/2026") == "2026-10-14"
64 + assert parse_date_fr("aucune date") is None
65 +
66 +
67 +def test_extract_details():
68 + d = extract_details("Ce cours de 3 crédits (1,4 UEC), niveau débutant, 45 heures")
69 + assert d["credits"] == 3.0
70 + assert d["uec"] == 1.4
71 + assert d["level"] == "débutant"
72 + assert d["duration_hours"] == 45.0
73 +
74 +
75 +def test_finalize_prix_optionnel():
76 + """Un cours universitaire sans prix est parfaitement valide."""
77 + f = Formation(source="t", external_id="1", url="u", title=" Cours X ",
78 + description="Cours de 3 crédits offert à distance.").finalize()
79 + assert f.price is None
80 + assert f.is_free is None
81 + assert f.credits == "3 crédits"
82 + assert f.title == "Cours X"
83 +
84 +
85 +def test_finalize_complet():
86 + f = Formation(source="t", external_id="2", url="u", title="Atelier",
87 + training_type="atelier", duration="2 jours",
88 + price_label="À partir de 795 $",
89 + schedule_label="Prochaine séance : 14 octobre 2026").finalize()
90 + assert f.training_type == "Atelier"
91 + assert f.price == 795.0
92 + assert f.is_free is False
93 + assert f.duration_hours == 14.0
94 + assert f.start_date == "2026-10-14"
95 + assert f.details.get("price_from") is True
96