SPB Git forge

spb/forma-ka

Public
6commits 1branches 0releases
4.0 MBsize
maindefault branch
22 days agolast push
Python 66.7% TypeScript 17.1% CSS 15.7% HTML 0.5%

chore: repo forma-ka rattaché à spbgit + README ultra détaillé (FR) + visite guidée en 10 captures

- README.md réécrit en français : identité ambrée, visite guidée (10 captures
  2026-08-28), fonctionnalités, pipeline d ingestion, chaîne de fetch
  résiliente, schéma Formation, API & routes, 18 sources avec volumes frais,
  déploiement M3U96a:8110 (PM2 + ngrok), dépôt spbgit, écosystème Groupe Ka
- docs/screenshots/ : 10 captures JPG production + manifest.txt
- .gitignore renforcé (data/, *.db*, .env*, node_modules/, .venv/, logs) ;
  tsconfig.tsbuildinfo désindexé
- connecteurs : base.py + _resilient.py (chaîne anti-bot résiliente commune,
  déjà en production, jamais commités)
Simon-Pierre Boucher committed 29 days ago (Aug 29, 2026) parent 2d5b6a6

17 changed files +1,344 −133

modified .gitignore +17 −3
@@ -1,8 +1,22 @@
1 −.env
1 +# Forma-Ka — .gitignore
2 +# Données locales (SQLite + caches) — data/sources.json est force-add (registre requis)
3 +data/
4 +*.db*
5 +*.log
6 +
7 +# Secrets
8 +.env*
9 +
10 +# Environnements & dépendances
2 11 .venv/
12 +venv/
3 13 __pycache__/
4 14 *.pyc
5 −data/formaka.db
6 −frontend/node_modules/
15 +node_modules/
7 16 frontend/dist/
17 +tsconfig.tsbuildinfo
18 +
19 +# Outils locaux
20 +.pytest_cache/
21 +.claude/
8 22 .DS_Store
modified README.md +427 −129
@@ -2,143 +2,407 @@
2 2
3 3 # Forma·Ka
4 4
5 −### Every training program in Québec. One place.
5 +### Toutes les formations du Québec. Un seul endroit.
6 6
7 7 **[www.forma-ka.com](https://www.forma-ka.com)**
8 8
9 9 ![Python](https://img.shields.io/badge/Python-3.14-141814?style=for-the-badge&logo=python&logoColor=ffd54d)
10 10 ![FastAPI](https://img.shields.io/badge/FastAPI-API-141814?style=for-the-badge&logo=fastapi&logoColor=ffd54d)
11 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%2C900%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.*
12 +![SQLite](https://img.shields.io/badge/SQLite-stockage-141814?style=for-the-badge&logo=sqlite&logoColor=ffd54d)
13 +![PWA](https://img.shields.io/badge/PWA-installable-141814?style=for-the-badge&logoColor=ffd54d)
14 +
15 +![Sources](https://img.shields.io/badge/%C3%A9tablissements-18-1d3f66?style=flat-square)
16 +![Connecteurs](https://img.shields.io/badge/connecteurs_actifs-18-1d3f66?style=flat-square)
17 +![Formations](https://img.shields.io/badge/formations_actives-4%C2%A0930%2B-1d3f66?style=flat-square)
18 +![Gratuites](https://img.shields.io/badge/formations_gratuites-135-1d3f66?style=flat-square)
19 +![En ligne](https://img.shields.io/badge/en_ligne-3%C2%A0556-1d3f66?style=flat-square)
20 +![Couverture](https://img.shields.io/badge/couverture-tout_le_Qu%C3%A9bec-1d3f66?style=flat-square)
21 +
22 +*Agrégateur indépendant de formations dans la province de Québec — cours en
23 +ligne, cours universitaires et collégiaux, séminaires, ateliers, conférences,
24 +certifications et bootcamps — chaque formation avec sa fiche détaillée
25 +standardisée et un lien direct vers la page originale de l'établissement.
26 +Toujours à jour, automatiquement.*
25 27
26 28 <br/>
27 29
28 −**Author : Simon-Pierre Boucher — [contact@spboucher.ai](mailto:contact@spboucher.ai)**
30 +**Auteur : Simon-Pierre Boucher — [contact@spboucher.ai](mailto:contact@spboucher.ai)**
29 31
30 32 </div>
31 33
32 34 ---
33 35
34 −## Screenshots
36 +## Sommaire
37 +
38 +1. [Visite guidée en 10 captures](#visite-guidée-en-10-captures)
39 +2. [Pourquoi Forma-Ka ?](#pourquoi-forma-ka-)
40 +3. [Fonctionnalités](#fonctionnalités)
41 +4. [Architecture](#architecture)
42 +5. [Le pipeline d'ingestion en détail](#le-pipeline-dingestion-en-détail)
43 +6. [Les backends de fetch (chaîne résiliente)](#les-backends-de-fetch-chaîne-résiliente)
44 +7. [Le schéma `Formation`](#le-schéma-formation)
45 +8. [API & routes](#api--routes)
46 +9. [Données & sources agrégées](#données--sources-agrégées)
47 +10. [Ajouter un connecteur](#ajouter-un-connecteur)
48 +11. [Démarrage rapide](#démarrage-rapide)
49 +12. [Tests](#tests)
50 +13. [Déploiement (production)](#déploiement-production)
51 +14. [Dépôt & développement remote-first](#dépôt--développement-remote-first)
52 +15. [Confidentialité](#confidentialité)
53 +16. [Écosystème Groupe Ka](#écosystème-groupe-ka)
54 +17. [Contact](#contact)
35 55
36 −*Captured 2026-08-25 (mobile 390×844 · desktop 1440×900).*
56 +---
37 57
38 −### Mobile
58 +## Visite guidée en 10 captures
39 59
40 −<table>
41 − <tr>
42 − <td align="center"><img src="docs/screenshots/mobile/home.webp" width="240" alt="Home mobile"><br><sub><b>Home — search, filters, live ticker</b></sub></td>
43 − <td align="center"><img src="docs/screenshots/mobile/formation.webp" width="240" alt="Program mobile"><br><sub><b>Program page — details first</b></sub></td>
44 − <td align="center"><img src="docs/screenshots/mobile/stats.webp" width="240" alt="Stats mobile"><br><sub><b>Live statistics</b></sub></td>
45 − </tr>
46 −</table>
60 +*Captures du site en production ([www.forma-ka.com](https://www.forma-ka.com)),
61 +prises le 2026-08-28 en 1440 × 900. Les fichiers sont dans
62 +[`docs/screenshots/`](docs/screenshots/) (voir aussi
63 +[`manifest.txt`](docs/screenshots/manifest.txt) pour la correspondance
64 +capture → URL).*
65 +
66 +### 1 · Accueil — le moteur de recherche
67 +
68 +![Accueil](docs/screenshots/01-accueil.jpg)
69 +
70 +La page d'accueil (`/`) donne le ton « editorial sharp » : gros titres Space
71 +Grotesk, surlignage ambré **#ffd54d**, ombres décalées, ticker en continu des
72 +types de formations. On y trouve les compteurs en direct (**4 932 formations
73 +actives · 135 gratuites · 3 556 en ligne · 18 établissements**), la barre de
74 +recherche (sujet, sigle, compétence…), les filtres **Domaine / Mode / Ville /
75 +Plus de filtres** et les pastilles de filtre rapide par type (Cours
76 +universitaire, Formation continue, Cours collégial, Atelier, Conférence,
77 +Séminaire, Webinaire, Gratuites…). En bas, la bannière de témoins rappelle la
78 +philosophie : aucun traceur publicitaire, préférences en localStorage.
79 +
80 +### 2 · Statistiques — portrait en direct de l'offre québécoise
81 +
82 +![Statistiques](docs/screenshots/02-stats.jpg)
83 +
84 +La page `/stats` dresse le portrait en direct des formations actives, tous
85 +établissements confondus : compteurs globaux (prix moyen affiché **858 $**,
86 +durée moyenne **14 h**), répartition **par type de formation** (2 268 cours
87 +universitaires, 2 058 formations continues, 118 cours collégiaux, 112 ateliers,
88 +104 cours en ligne, 98 conférences, 80 séminaires, 41 webinaires, 27
89 +programmes, 22 certifications, 4 bootcamps) et journal des **synchronisations
90 +récentes** par source (trouvées / ajoutées / modifiées / retirées + état OK).
91 +
92 +### 3 · Sources agrégées — le registre des établissements
93 +
94 +![Sources](docs/screenshots/03-sources.jpg)
95 +
96 +La page `/sources` expose le registre `data/sources.json` enrichi en direct :
97 +chaque établissement avec son type d'offre, son statut de connecteur
98 +(**CONNECTÉ**), le nombre de formations actives et l'horodatage de la dernière
99 +synchronisation. C'est la vitrine de transparence de Forma-Ka : on voit d'où
100 +viennent les données et quand elles ont été rafraîchies.
101 +
102 +### 4 · Confidentialité — « Votre vie privée, simplement. »
47 103
48 −### Desktop
104 +![Confidentialité](docs/screenshots/04-confidentialite.jpg)
105 +
106 +La page `/confidentialite` détaille l'approche minimaliste : aucun compte
107 +utilisateur, aucun pixel publicitaire, aucun témoin tiers. Le seul stockage est
108 +le localStorage du navigateur (choix de consentement, filtres de recherche),
109 +avec un bouton « Modifier mes choix de témoins » accessible en tout temps.
110 +
111 +### 5 · Fiche formation — le détail d'abord (Isarta · Data Studio)
112 +
113 +![Fiche formation Isarta](docs/screenshots/05-formation-isarta-3Adata-studio.jpg)
114 +
115 +Une fiche `/formation/:uid` (ici `isarta:data-studio`) montre la philosophie
116 +« détail d'abord » : fil d'Ariane par catégorie, **plan de la formation**
117 +complet (11 points), bloc **Admission / clientèle visée**, colonne des
118 +**thèmes** et des **formations similaires** (avec type, mode, durée, prix et
119 +source), puis le contenu original signé par la source. Chaque fiche renvoie
120 +vers la page originale de l'établissement.
121 +
122 +### 6 · Fiche événement — prix optionnel, dates offertes (CRHA · 5 à 7 estival)
123 +
124 +![Fiche formation CRHA](docs/screenshots/10-formation-crha-3A202609035-407Lanaudiere.jpg)
125 +
126 +Deuxième exemple de fiche (`crha:202609035@7Lanaudiere`) qui illustre la
127 +souplesse du schéma : un événement de réseautage RH **sans prix affiché**
128 +(le prix est optionnel dans Forma-Ka), avec ses **dates offertes** (3 septembre
129 +2026), ses **infos pratiques** (établissement, sigle, horaire affiché, date de
130 +synchronisation) et ses thèmes régionaux (Comité régionale de Lanaudière).
131 +
132 +### 7 à 10 · La page 404 signature
133 +
134 +Les captures [`06-doc.jpg`](docs/screenshots/06-doc.jpg),
135 +[`07-contact.jpg`](docs/screenshots/07-contact.jpg),
136 +[`08-recherche.jpg`](docs/screenshots/08-recherche.jpg) et
137 +[`09-carte.jpg`](docs/screenshots/09-carte.jpg) visaient les URL `/doc`,
138 +`/contact`, `/recherche` et `/carte` : ces routes n'existent pas dans la SPA
139 +(la recherche vit sur l'accueil, le contact dans le pied de page). Elles
140 +documentent donc la **page 404 signature** de Forma-Ka — boussole, « PAGE
141 +INTROUVABLE », ticker toujours actif et pied de page complet (mission de
142 +l'agrégateur + lien confidentialité + gestion des témoins) :
143 +
144 +![Page 404](docs/screenshots/09-carte.jpg)
145 +
146 +### Galerie mobile & desktop (2026-08-25)
147 +
148 +*Captures WebP précédentes (mobile 390 × 844 · desktop 1440 × 900), conservées
149 +dans [`docs/screenshots/mobile/`](docs/screenshots/mobile/) et
150 +[`docs/screenshots/desktop/`](docs/screenshots/desktop/).*
49 151
50 152 <table>
51 153 <tr>
52 − <td align="center"><img src="docs/screenshots/desktop/home.webp" width="420" alt="Home desktop"><br><sub><b>Home — 4,900+ active programs, quick category filters</b></sub></td>
53 − <td align="center"><img src="docs/screenshots/desktop/formation.webp" width="420" alt="Program page"><br><sub><b>Program page — learning objectives, dates, themes</b></sub></td>
54 − </tr>
55 − <tr>
56 − <td align="center" colspan="2"><img src="docs/screenshots/desktop/stats.webp" width="640" alt="Stats"><br><sub><b>Statistics — live portrait of Québec's training offer</b></sub></td>
154 + <td align="center"><img src="docs/screenshots/mobile/home.webp" width="200" alt="Accueil mobile"><br><sub><b>Accueil mobile</b></sub></td>
155 + <td align="center"><img src="docs/screenshots/mobile/formation.webp" width="200" alt="Fiche mobile"><br><sub><b>Fiche — détail d'abord</b></sub></td>
156 + <td align="center"><img src="docs/screenshots/mobile/stats.webp" width="200" alt="Stats mobile"><br><sub><b>Stats mobile</b></sub></td>
157 + <td align="center"><img src="docs/screenshots/mobile/menu-mobile.webp" width="200" alt="Menu mobile"><br><sub><b>Menu mobile</b></sub></td>
57 158 </tr>
58 159 </table>
59 160
60 −> 🗄️ Previous screenshots are kept in [`docs/archive/`](docs/archive/).
61 −
62 −## Why Forma-Ka?
63 −
64 −Looking for training in Québec means opening dozens of websites — universities,
65 −CEGEPs, training firms, event organizers — each with its own navigation and
66 −format. **Forma-Ka flips the problem**: a dedicated connector per institution
67 −visits each site, normalizes every program into a single schema, and detects
68 −changes continuously.
69 −
70 −> Training sites don't offer webhooks. Forma-Ka reproduces the equivalent:
71 −> **periodic sync + content hashing** → additions, updates and removals detected
72 −> automatically. A program that disappears from the source site disappears from
73 −> Forma-Ka (after a 2-sync grace period).
161 +> 🗄️ Les toutes premières captures (PNG) sont archivées dans
162 +> [`docs/archive/`](docs/archive/).
74 163
75 −**Philosophy**: unlike a product aggregator, **price is optional** (university
76 −courses don't display one) — what matters are the **details** of each program:
77 −full description, learning objectives, course outline, prerequisites, target
78 −audience, duration, credits/CEUs, delivery mode, offered dates.
164 +---
79 165
80 −## Architecture in 30 seconds
166 +## Pourquoi Forma-Ka ?
167 +
168 +Chercher une formation au Québec, c'est ouvrir des dizaines de sites —
169 +universités, cégeps, firmes de formation, organisateurs d'événements — chacun
170 +avec sa navigation et son format. **Forma-Ka renverse le problème** : un
171 +connecteur dédié par établissement visite chaque site, normalise chaque
172 +formation dans un schéma unique et détecte les changements en continu.
173 +
174 +> Les sites de formation n'offrent pas de webhooks. Forma-Ka en reproduit
175 +> l'équivalent : **synchronisation périodique + hachage de contenu** → ajouts,
176 +> mises à jour et retraits détectés automatiquement. Une formation qui
177 +> disparaît du site source disparaît de Forma-Ka (après une période de grâce
178 +> de 2 synchronisations).
179 +
180 +**Philosophie** : contrairement à un agrégateur de produits, **le prix est
181 +optionnel** (un cours universitaire n'affiche pas de prix) — ce qui compte, ce
182 +sont les **détails** de chaque formation : description complète, objectifs
183 +d'apprentissage, plan de cours, préalables, clientèle visée, durée,
184 +crédits/UEC, mode de diffusion, dates offertes.
185 +
186 +## Fonctionnalités
187 +
188 +- **Recherche plein texte** (sujet, sigle, compétence) combinable avec les
189 + filtres **domaine** (Informatique, Gestion, RH, Marketing…), **type**
190 + (cours universitaire, formation continue, atelier, séminaire, webinaire,
191 + certification, bootcamp…), **mode** (en ligne, présentiel, hybride,
192 + asynchrone), **ville**, **langue**, **niveau**, **gratuit**, **prix maximal**
193 + et **date de début** ;
194 +- **Fiches détaillées standardisées** : description, objectifs, plan de cours,
195 + préalables, clientèle visée, durée (texte + heures), crédits/UEC, sigle,
196 + formateur, dates offertes, prix (optionnel, avec mention « à partir de »),
197 + thèmes, formations similaires, lien direct vers la source ;
198 +- **Statistiques en direct** (`/stats`) : portrait global + journal des
199 + synchronisations par source ;
200 +- **Registre des sources** (`/sources`) : statut du connecteur, volume,
201 + dernière synchro ;
202 +- **Ticker en continu** des types de formations en tête de chaque page ;
203 +- **PWA installable** (manifest + thème clair, accent ambré `#ffd54d`),
204 + pagination 12 par page, feuillet mobile (« bottom sheet ») pour les filtres ;
205 +- **Aucun traceur** : consentement localStorage, page confidentialité dédiée ;
206 +- **Mise à jour automatique** toutes les 6 h (watcher PM2) avec détection de
207 + dérive des connecteurs.
208 +
209 +## Architecture
81 210
82 211 ```mermaid
83 212 flowchart LR
84 − subgraph Sources["18 training institutions"]
85 − S1["ÉTS Formation · TÉLUQ · ULaval<br/>McGill · HEC · UQAM · Technologia<br/>AFI · Cégep à distance · Les Affaires<br/>… one connector per site"]
213 + subgraph Sources["18 établissements de formation"]
214 + S1["ÉTS Formation · TÉLUQ · ULaval<br/>McGill · HEC · UQAM · Technologia<br/>AFI · Cégep à distance · Les Affaires<br/>… un connecteur par site"]
86 215 end
87 216 subgraph FormaKa["Forma-Ka"]
88 − C["Connectors<br/><i>1 adapter / site</i>"] --> N["Normalization<br/><i>single Formation schema</i>"]
89 − N --> D[("SQLite<br/>hash + diff")]
217 + C["Connecteurs<br/><i>1 adaptateur / site</i>"] --> N["Normalisation<br/><i>schéma Formation unique</i>"]
218 + N --> D[("SQLite<br/>hash + diff + grâce")]
90 219 D --> A["FastAPI<br/>/api/formations · /api/facets"]
91 − A --> F["React 18 + Vite<br/>mobile PWA · light theme"]
220 + A --> F["React 18 + Vite<br/>PWA mobile · thème clair ambré"]
92 221 end
93 − W["⏱ Periodic watcher<br/>(PM2)"] -.-> C
222 + W["⏱ Watcher périodique<br/>(PM2, 6 h)"] -.-> C
94 223 S1 --> C
95 − F --> U["🔑 Learner"]
224 + F --> U["🎓 Apprenant"]
96 225 ```
97 226
98 −| Layer | Role | Files |
227 +| Couche | Rôle | Fichiers |
99 228 |---|---|---|
100 −| **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` |
101 −| **Schema** | Standardized `Formation`: type, category, mode, description, objectives, outline, prerequisites, duration, credits/CEUs, sessions, price *(optional)* | `formaka/schema.py` |
102 −| **Diff engine** | Content-hash upsert — new / changed / gone (grace period), connector drift detection | `formaka/db.py` |
103 −| **API** | Filters: type / category / mode / city / language / level / free / price / source / search, facets, stats, sync trigger | `formaka/web.py` |
104 −| **Frontend** | "Editorial sharp" design: Space Grotesk, offset shadows, amber accent, live ticker, mobile bottom sheet, 12-per-page pagination, installable PWA | `frontend/` |
229 +| **Connecteurs** | 1 module Python par établissement : HTML rendu serveur, API JSON internes (Shopify `products.json`, WordPress REST, Gatsby `page-data`, Destiny One…), JSON-LD schema.org (`Course`/`Event`), chaîne anti-bot résiliente pour les sites difficiles | `formaka/connectors/*.py` |
230 +| **Chaîne résiliente** | Escalade automatique direct → proxy résidentiel → anti-bot géré → déblocage premium quand un site se ferme (403/429/503, Cloudflare…) | `formaka/connectors/_resilient.py` |
231 +| **Schéma** | `Formation` standardisée : type, catégorie, mode, description, objectifs, plan, préalables, durée, crédits/UEC, sessions, prix *(optionnel)* | `formaka/schema.py` |
232 +| **Normalisation** | Couche commune : nettoyage de texte, types/modes/langues canoniques, prix, durées en heures, dates FR → ISO, extraction de détails (niveau, UEC…) | `formaka/normalize.py` |
233 +| **Moteur de diff** | Upsert par hash de contenu — nouveau / modifié / disparu (période de grâce de 2 synchros), détection de dérive des connecteurs, cache des fiches | `formaka/db.py`, `formaka/ingest.py` |
234 +| **API** | Filtres type / catégorie / mode / ville / langue / niveau / gratuit / prix / source / recherche, facettes, stats, déclenchement de synchro | `formaka/web.py` |
235 +| **Frontend** | Design « editorial sharp » : Space Grotesk, ombres décalées, accent ambré `#ffd54d`, ticker en continu, feuillet mobile, pagination 12/page, PWA installable | `frontend/` |
105 236
106 −## The three fetch backends
237 +### Structure du dépôt
107 238
108 −Each connector picks one (or chains them via the automatic `fetch_html()` fallback):
239 +```
240 +forma-ka/
241 +├── run.py # point d'entrée : sync | watch | serve
242 +├── requirements.txt # fastapi, uvicorn, requests, beautifulsoup4
243 +├── formaka/
244 +│ ├── schema.py # dataclass Formation + finalize()
245 +│ ├── normalize.py # normalisation commune (texte, prix, dates…)
246 +│ ├── ingest.py # pipeline sync/watch (diff + grâce + journal)
247 +│ ├── db.py # SQLite : formations, detail_cache, sync_log
248 +│ ├── web.py # FastAPI : /api/* + service du frontend bâti
249 +│ └── connectors/ # 18 connecteurs + base.py + _resilient.py
250 +├── frontend/ # React 18 + Vite + TypeScript (PWA)
251 +│ └── src/pages/ # Home, Formation, Stats, Sources, Privacy
252 +├── data/
253 +│ ├── sources.json # registre des établissements (versionné)
254 +│ └── formaka.db # base SQLite (NON versionnée)
255 +├── docs/screenshots/ # visite guidée (JPG) + galeries webp
256 +└── tests/ # pytest (normalisation)
257 +```
109 258
110 −1. **direct requests** — server-rendered sites (fast, free);
111 −2. **Scrapfly** (`SCRAPFLY_API_KEY`) — robust backend: anti-bot bypass (`asp`),
112 − JavaScript rendering (`render_js`), Canadian geolocation;
113 −3. **Firecrawl** (`FIRECRAWL_API_KEY`) — fallback JS rendering, html/markdown formats.
259 +## Le pipeline d'ingestion en détail
260 +
261 +1. **Découverte** — le registre des connecteurs est **auto-découvrant** :
262 + chaque module de `formaka/connectors/` qui définit une classe héritant de
263 + `BaseConnector` avec un `source_id` est enrôlé automatiquement.
264 +2. **Collecte** — `fetch()` liste les formations du site (listing HTML, API
265 + JSON interne, sitemap…), puis visite les pages de détail au besoin.
266 +3. **Cache des fiches** — les pages de détail sont mises en cache dans la base
267 + (`detail_cache`) avec une **clé hebdomadaire** : une page n'est revisitée
268 + que si elle est nouvelle, modifiée, ou quand la semaine ISO change. Les
269 + synchros intermédiaires sont donc rapides et économes.
270 +4. **Normalisation** — `Formation.finalize()` applique la couche commune
271 + (types/modes/langues canoniques, prix depuis le libellé, durée en heures,
272 + dates françaises → ISO, extraction de niveau/UEC/crédits) sans jamais
273 + écraser une valeur explicite du connecteur.
274 +5. **Diff & grâce** — chaque fiche a un `content_hash` SHA-256 ; l'upsert ne
275 + touche que ce qui a changé. Une formation absente du site source est
276 + retirée après **2 synchros consécutives** d'absence (période de grâce
277 + contre les listings instables).
278 +6. **Journal & dérive** — chaque synchro est journalisée (`sync_log` :
279 + trouvées / ajoutées / modifiées / retirées + taux de champs nuls) ; une
280 + chute anormale du volume ou une explosion des champs vides signale une
281 + dérive du connecteur (site remanié) visible sur `/stats` et `/sources`.
282 +
283 +## Les backends de fetch (chaîne résiliente)
284 +
285 +Historiquement, `BaseConnector.fetch_html()` enchaînait trois backends :
286 +
287 +1. **requests direct** — sites rendus serveur (rapide, gratuit) ;
288 +2. **Scrapfly** (`SCRAPFLY_API_KEY`) — contournement anti-bot (`asp`), rendu
289 + JavaScript (`render_js`), géolocalisation canadienne ;
290 +3. **Firecrawl** (`FIRECRAWL_API_KEY`) — rendu JS de repli, formats
291 + html/markdown.
292 +
293 +Cette chaîne est désormais renforcée par la **chaîne anti-bot résiliente
294 +commune du Groupe KA** (`formaka/connectors/_resilient.py`) : quand un site
295 +jusque-là ouvert déploie un anti-bot (Cloudflare, Akamai, Incapsula,
296 +PerimeterX) ou renvoie 403/429/503, la requête directe n'échoue plus
297 +silencieusement — elle **escalade automatiquement** :
298 +
299 +1. **Direct** — session du connecteur (`curl_cffi` avec empreinte de
300 + navigateur si disponible, sinon `requests`) ;
301 +2. **Proxy résidentiel** — IP résidentielle canadienne propre ;
302 +3. **Scrapfly (ASP)** — bypass anti-bot géré + rendu JS optionnel ;
303 +4. **Bright Data (Web Unlocker)** — déblocage premium, dernier recours.
304 +
305 +Le premier backend qui renvoie un 200 non vide gagne ; si tous échouent, la
306 +dernière réponse est renvoyée telle quelle pour que le connecteur journalise
307 +l'échec normalement (aucun blocage silencieux). Les clés d'API vivent dans
308 +`.env` (jamais versionné).
309 +
310 +## Le schéma `Formation`
311 +
312 +Chaque connecteur, peu importe le site source, produit des objets `Formation`
313 +(`formaka/schema.py`) :
314 +
315 +| Groupe | Champs |
316 +|---|---|
317 +| **Identité** | `source`, `external_id`, `url` → `uid = source:external_id` |
318 +| **Classement** | `title`, `training_type`, `category`, `tags`, `level`, `language` |
319 +| **Diffusion** | `mode` (en ligne / présentiel / hybride / asynchrone), `city`, `start_date` (ISO), `sessions[]`, `schedule_label`, `duration`, `duration_hours` |
320 +| **Contenu** | `description`, `objectives[]`, `program[]` (plan de cours), `prerequisites`, `audience`, `instructor`, `code` (sigle), `images[]` |
321 +| **Valeur** | `price` *(optionnel — `None` = non affiché, c'est normal !)*, `price_label` (texte original), `is_free`, `credits` (« 3 crédits », « 1,4 UEC »), `credential` |
322 +| **Technique** | `details` (JSON structuré dérivé), `content_hash()` (SHA-256 pour la détection de changements) |
114 323
115 −Detail pages are cached in the database (`detail_cache`) with a weekly key:
116 −each page is revisited only when new, changed, or when the ISO week rolls over.
324 +## API & routes
117 325
118 −## Aggregated sources
326 +### API REST (FastAPI)
119 327
120 −| Institution | Offer | Programs |
121 −|---|---|---|
122 −| Université Laval — Distance | University courses (distance/hybrid) | ~1,760 |
123 −| Technologia | Professional training — IT, AI, management | ~590 |
124 −| Université TÉLUQ | University courses, 100 % online | ~510 |
125 −| AFI by Edgenda | Professional training — IT, leadership | ~360 |
126 −| ÉTS Formation | Continuing education + CEUs | ~300 |
127 −| Versalys | Office tools, IT, languages | ~230 |
128 −| McGill School of Continuing Studies | Continuing education (English) | ~200 |
129 −| Isarta Formations | Marketing, communications, HR | ~190 |
130 −| CRHA — Espace Formation | HR training and events | ~175 |
131 −| Événements Les Affaires | Business conferences and webinars | ~135 |
132 −| Cégep à distance | College courses at a distance | ~120 |
133 −| HEC Montréal — École des dirigeant(e)s | Executive seminars and certifications | ~90 |
134 −| ITHQ | Wine/food workshops + hospitality training | ~80 |
135 −| École des entrepreneurs du Québec | Entrepreneur training (mostly free) | ~45 |
136 −| Institut de leadership | Leadership certifications and programs | ~35 |
137 −| AlphaNumérique | Free digital-literacy courses | ~30 |
138 −| Formation continue UQAM | Continuing education + CEUs | ~50 |
139 −| Le Wagon Montréal | Web dev / data / AI bootcamps | 4 |
140 −
141 −## Quick start
328 +| Point d'accès | Description |
329 +|---|---|
330 +| `GET /api/formations` | Liste filtrable (`training_type`, `category`, `mode`, `city`, `language`, `level`, `source`, `free`, `price_max`, `starts_after`, `q`, `sort`, `limit`, `offset`) |
331 +| `GET /api/formations/{uid}` | Fiche complète + historique de prix + formations similaires |
332 +| `GET /api/facets` | Valeurs distinctes pour construire les filtres |
333 +| `GET /api/sources` | Registre des établissements + état de synchro (`active_formations`, `last_sync`) |
334 +| `GET /api/stats` | Portrait global (totaux, moyennes, répartition par type) + journal des synchros |
335 +| `POST /api/sync` | Déclenche une synchronisation en arrière-plan |
336 +
337 +### Routes du frontend (SPA React Router)
338 +
339 +| Route | Page |
340 +|---|---|
341 +| `/` | Accueil — recherche, filtres, compteurs en direct |
342 +| `/formation/:uid` | Fiche détaillée (ex. `/formation/isarta:data-studio`) |
343 +| `/stats` | Statistiques en direct |
344 +| `/sources` | Registre des sources agrégées |
345 +| `/confidentialite` | Politique de confidentialité & témoins |
346 +| `*` | Page 404 signature (boussole) |
347 +
348 +Le backend sert aussi le frontend bâti (`frontend/dist`) : `/assets` en
349 +statique + rattrapage SPA sur toutes les autres routes.
350 +
351 +## Données & sources agrégées
352 +
353 +**18 établissements connectés — 4 932 formations actives** (relevé du
354 +2026-08-28) :
355 +
356 +| Établissement | Offre | Formations | Région |
357 +|---|---|---:|---|
358 +| Université Laval — Formation à distance | Cours universitaires à distance, hybrides et comodaux | 1 757 | Québec (à distance / hybride) |
359 +| Technologia | Formation professionnelle — TI, IA, gestion | 590 | Montréal / Québec |
360 +| Université TÉLUQ | Cours universitaires 100 % à distance, tous cycles | 511 | Québec (à distance) |
361 +| AFI par Edgenda | Formation professionnelle — TI, leadership | 358 | Québec / Montréal |
362 +| ÉTS Formation | Formation continue + UEC — technologie, construction, gestion, RH | 296 | Montréal |
363 +| Versalys | Bureautique, TI, langues | 257 | Montréal · Québec · Laval · Brossard |
364 +| McGill School of Continuing Studies | Formation continue (anglais) | 197 | Montréal |
365 +| Isarta Formations | Marketing, communications, RH | 189 | Montréal (virtuel) |
366 +| CRHA — Espace Formation | Formations et événements RH | 180 | Québec (province) — surtout en ligne |
367 +| Événements Les Affaires | Conférences et webinaires d'affaires | 139 | Montréal / Québec / en ligne |
368 +| Cégep à distance | Cours collégiaux à distance | 118 | Québec (en ligne) |
369 +| École des dirigeant(e)s HEC Montréal | Séminaires et certifications pour cadres | 93 | Montréal |
370 +| ITHQ — Ateliers et formations | Ateliers vins/cuisine + hôtellerie | 78 | Montréal (+ province) |
371 +| Formation continue UQAM | Formation continue universitaire — UEC, séminaires | 49 | Montréal |
372 +| École des entrepreneurs du Québec | Formations entrepreneur·e·s (souvent gratuites) | 48 | Montréal + en ligne |
373 +| Institut de leadership | Certifications et programmes en leadership | 37 | Montréal (+ cohortes en ligne) |
374 +| AlphaNumérique | Littératie numérique — gratuit | 29 | Québec (en ligne) |
375 +| Le Wagon Montréal | Bootcamps dev web / data / IA | 4 | Montréal |
376 +
377 +Répartition par type : **2 268** cours universitaires · **2 058** formations
378 +continues · **118** cours collégiaux · **112** ateliers · **104** cours en
379 +ligne · **98** conférences · **80** séminaires · **41** webinaires · **27**
380 +programmes · **22** certifications · **4** bootcamps.
381 +
382 +Le registre vit dans [`data/sources.json`](data/sources.json) (versionné) ;
383 +la base SQLite (`data/formaka.db`) est locale et reconstruite par `sync`.
384 +
385 +## Ajouter un connecteur
386 +
387 +1. Créer `formaka/connectors/<source_id>.py` : une classe héritant de
388 + `BaseConnector`, définir `source_id` et implémenter
389 + `fetch() -> list[Formation]`. Le registre est **auto-découvrant** — rien
390 + d'autre à modifier.
391 +2. Ajouter l'entrée correspondante dans `data/sources.json`.
392 +3. Tester : `.venv/bin/python run.py sync <source_id>`.
393 +
394 +```python
395 +class MonEcoleConnector(BaseConnector):
396 + source_id = "mon_ecole"
397 +
398 + def fetch(self) -> list[Formation]:
399 + html = self.fetch_html(LIST_URL) # direct -> chaîne résiliente
400 + ...
401 + return [Formation(source=self.source_id, external_id=..., url=...,
402 + title=..., description=..., objectives=[...], ...)]
403 +```
404 +
405 +## Démarrage rapide
142 406
143 407 ```bash
144 408 git clone https://git.spboucher.ai/forma-ka.git && cd forma-ka
@@ -149,58 +413,92 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
149 413 # Frontend
150 414 cd frontend && npm install && npm run build && cd ..
151 415
152 −# Scraping backend keys (JavaScript / anti-bot sites)
416 +# Clés des backends de scraping (sites JavaScript / anti-bot) — jamais versionnées
153 417 cat > .env <<EOF
154 −FIRECRAWL_API_KEY=fc-your-key
155 −SCRAPFLY_API_KEY=scp-live-your-key
418 +FIRECRAWL_API_KEY=fc-votre-cle
419 +SCRAPFLY_API_KEY=scp-live-votre-cle
156 420 EOF
157 421
158 −# Ingest, then serve
159 −.venv/bin/python run.py sync # all sources (or: run.py sync ets_formation teluq)
422 +# Ingestion, puis service
423 +.venv/bin/python run.py sync # toutes les sources (ou : run.py sync ets_formation teluq)
160 424 .venv/bin/python run.py serve 8080 # API + frontend -> http://localhost:8080
161 −.venv/bin/python run.py watch 360 # sync loop (default: every 6 h)
425 +.venv/bin/python run.py watch 360 # boucle de synchro (défaut : toutes les 6 h)
162 426 ```
163 427
164 −## Adding a connector
165 −
166 −1. Create `formaka/connectors/<source_id>.py`: a class inheriting from
167 − `BaseConnector`, define `source_id` and implement `fetch() -> list[Formation]`.
168 − The registry is **auto-discovering** — nothing else to edit.
169 −2. Add the matching entry to `data/sources.json`.
170 −3. Test: `.venv/bin/python run.py sync <source_id>`.
171 −
172 −```python
173 −class MySchoolConnector(BaseConnector):
174 − source_id = "my_school"
428 +## Tests
175 429
176 − def fetch(self) -> list[Formation]:
177 − html = self.fetch_html(LIST_URL) # direct -> Scrapfly -> Firecrawl
178 − ...
179 − return [Formation(source=self.source_id, external_id=..., url=...,
180 − title=..., description=..., objectives=[...], ...)]
430 +```bash
431 +.venv/bin/python -m pytest tests/ -q
181 432 ```
182 433
183 −## API
434 +## Déploiement (production)
184 435
185 −| Endpoint | Description |
436 +| Élément | Valeur |
186 437 |---|---|
187 −| `GET /api/formations` | Filterable list (`training_type`, `category`, `mode`, `city`, `language`, `level`, `source`, `free`, `price_max`, `starts_after`, `q`, `sort`, `limit`, `offset`) |
188 −| `GET /api/formations/{uid}` | Full program page + price history + similar programs |
189 −| `GET /api/facets` | Distinct values for building filters |
190 −| `GET /api/sources` | Institution registry + sync state |
191 −| `GET /api/stats` | Global portrait + sync log |
192 −| `POST /api/sync` | Trigger a background sync |
193 −
194 −## Tests
438 +| **Nœud** | `M3U96a` (Mac Studio, cluster MacLustr) — `~/apps/forma-ka` |
439 +| **Port** | `8110` |
440 +| **Processus PM2** | `forma-ka` (API + frontend, `run.py serve 8110`) · `forma-ka-sync` (`run.py watch 360` → synchro toutes les 6 h) · `forma-ka-ngrok` (tunnel) |
441 +| **Domaine** | [www.forma-ka.com](https://www.forma-ka.com) via ngrok |
442 +| **Base** | `data/formaka.db` (SQLite, locale au nœud) |
443 +| **Résilience** | PM2 avec redémarrage automatique + `pm2 startup` (launchd) |
195 444
196 445 ```bash
197 −.venv/bin/python -m pytest tests/ -q
446 +# Sur le nœud (lecture seule — l'état de référence)
447 +pm2 ls | grep forma-ka
448 +curl -s localhost:8110/api/stats | head -c 300
198 449 ```
199 450
200 −---
451 +## Dépôt & développement remote-first
452 +
453 +La **source de vérité est le dépôt git sur le nœud de déploiement**
454 +(`M3U96a:~/apps/forma-ka`), pas une copie locale. Le remote `origin` est le
455 +git personnel **spbgit** (git.spboucher.ai) — dépôt nu
456 +`~/srv/git/forma-ka.git` hébergé sur M3U96a.
457 +
458 +Cycle de travail : éditer sur le nœud via SSH → `npm run build` (frontend) →
459 +`pm2 restart forma-ka` → `git add/commit/push origin main` **sur le nœud**
460 +(agent forwarding actif).
461 +
462 +Ce qui n'est **jamais versionné** (voir [`.gitignore`](.gitignore)) : la base
463 +SQLite et les caches (`data/`, sauf `sources.json` déjà suivi), les secrets
464 +(`.env*`), les environnements (`.venv/`, `node_modules/`), les artefacts de
465 +build (`frontend/dist/`) et les journaux.
466 +
467 +## Confidentialité
468 +
469 +Forma-Ka ne collecte **rien qui identifie l'utilisateur** : pas de compte, pas
470 +de formulaire d'inscription, pas de pixel publicitaire, pas de témoin tiers,
471 +aucune donnée vendue. Le seul stockage est le **localStorage** du navigateur
472 +(choix de consentement, filtres, préférences d'affichage), modifiable en tout
473 +temps via « Gérer mes témoins ». Détails : page
474 +[`/confidentialite`](https://www.forma-ka.com/confidentialite).
475 +
476 +## Écosystème Groupe Ka
477 +
478 +Forma-Ka fait partie du **Groupe Ka** ([groupe-ka.com](https://www.groupe-ka.com)),
479 +la famille d'agrégateurs indépendants du Québec — notamment
480 +[Lou-Ka](https://www.lou-ka.com) (logements),
481 +[Immo-Ka](https://www.immo-ka.com) (propriétés),
482 +[Vrai-Prix](https://www.vrai-prix.com) (épicerie),
483 +[Auto-Ka](https://www.auto-ka.com) (véhicules),
484 +[Food-Ka](https://www.food-ka.com), [Resto-Ka](https://www.resto-ka.com),
485 +[Sorti-Ka](https://www.sorti-ka.com) (sorties),
486 +[Job-Ka](https://www.job-ka.com) (emplois),
487 +[Trouve-Ka](https://www.trouve-ka.com) (recherche),
488 +[Créa-Ka](https://www.crea-ka.com) (créateurs),
489 +[Fabri-Ka](https://www.fabri-ka.com) (produits d'ici) et
490 +[Ka·Stats](https://www.ka-stats.com) (statistiques du Québec).
491 +Même ADN partout : connecteurs dédiés, schéma standardisé, diff par hachage,
492 +fiches détaillées, respect de la vie privée — et le mécanisme de Forma-Ka est
493 +directement adapté de celui de Lou-Ka.
494 +
495 +## Contact
201 496
202 497 <div align="center">
203 498
204 −© 2026 **Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai)
499 +**Simon-Pierre Boucher**
500 +[contact@spboucher.ai](mailto:contact@spboucher.ai) · [www.forma-ka.com](https://www.forma-ka.com)
501 +
502 +© 2026 Simon-Pierre Boucher — tous droits réservés.
205 503
206 504 </div>
added docs/README.md +504 −0
@@ -0,0 +1,504 @@
1 +<div align="center">
2 +
3 +# Forma·Ka
4 +
5 +### Toutes les formations du Québec. Un seul endroit.
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-stockage-141814?style=for-the-badge&logo=sqlite&logoColor=ffd54d)
13 +![PWA](https://img.shields.io/badge/PWA-installable-141814?style=for-the-badge&logoColor=ffd54d)
14 +
15 +![Sources](https://img.shields.io/badge/%C3%A9tablissements-18-1d3f66?style=flat-square)
16 +![Connecteurs](https://img.shields.io/badge/connecteurs_actifs-18-1d3f66?style=flat-square)
17 +![Formations](https://img.shields.io/badge/formations_actives-4%C2%A0930%2B-1d3f66?style=flat-square)
18 +![Gratuites](https://img.shields.io/badge/formations_gratuites-135-1d3f66?style=flat-square)
19 +![En ligne](https://img.shields.io/badge/en_ligne-3%C2%A0556-1d3f66?style=flat-square)
20 +![Couverture](https://img.shields.io/badge/couverture-tout_le_Qu%C3%A9bec-1d3f66?style=flat-square)
21 +
22 +*Agrégateur indépendant de formations dans la province de Québec — cours en
23 +ligne, cours universitaires et collégiaux, séminaires, ateliers, conférences,
24 +certifications et bootcamps — chaque formation avec sa fiche détaillée
25 +standardisée et un lien direct vers la page originale de l'établissement.
26 +Toujours à jour, automatiquement.*
27 +
28 +<br/>
29 +
30 +**Auteur : Simon-Pierre Boucher — [contact@spboucher.ai](mailto:contact@spboucher.ai)**
31 +
32 +</div>
33 +
34 +---
35 +
36 +## Sommaire
37 +
38 +1. [Visite guidée en 10 captures](#visite-guidée-en-10-captures)
39 +2. [Pourquoi Forma-Ka ?](#pourquoi-forma-ka-)
40 +3. [Fonctionnalités](#fonctionnalités)
41 +4. [Architecture](#architecture)
42 +5. [Le pipeline d'ingestion en détail](#le-pipeline-dingestion-en-détail)
43 +6. [Les backends de fetch (chaîne résiliente)](#les-backends-de-fetch-chaîne-résiliente)
44 +7. [Le schéma `Formation`](#le-schéma-formation)
45 +8. [API & routes](#api--routes)
46 +9. [Données & sources agrégées](#données--sources-agrégées)
47 +10. [Ajouter un connecteur](#ajouter-un-connecteur)
48 +11. [Démarrage rapide](#démarrage-rapide)
49 +12. [Tests](#tests)
50 +13. [Déploiement (production)](#déploiement-production)
51 +14. [Dépôt & développement remote-first](#dépôt--développement-remote-first)
52 +15. [Confidentialité](#confidentialité)
53 +16. [Écosystème Groupe Ka](#écosystème-groupe-ka)
54 +17. [Contact](#contact)
55 +
56 +---
57 +
58 +## Visite guidée en 10 captures
59 +
60 +*Captures du site en production ([www.forma-ka.com](https://www.forma-ka.com)),
61 +prises le 2026-08-28 en 1440 × 900. Les fichiers sont dans
62 +[`docs/screenshots/`](docs/screenshots/) (voir aussi
63 +[`manifest.txt`](docs/screenshots/manifest.txt) pour la correspondance
64 +capture → URL).*
65 +
66 +### 1 · Accueil — le moteur de recherche
67 +
68 +![Accueil](docs/screenshots/01-accueil.jpg)
69 +
70 +La page d'accueil (`/`) donne le ton « editorial sharp » : gros titres Space
71 +Grotesk, surlignage ambré **#ffd54d**, ombres décalées, ticker en continu des
72 +types de formations. On y trouve les compteurs en direct (**4 932 formations
73 +actives · 135 gratuites · 3 556 en ligne · 18 établissements**), la barre de
74 +recherche (sujet, sigle, compétence…), les filtres **Domaine / Mode / Ville /
75 +Plus de filtres** et les pastilles de filtre rapide par type (Cours
76 +universitaire, Formation continue, Cours collégial, Atelier, Conférence,
77 +Séminaire, Webinaire, Gratuites…). En bas, la bannière de témoins rappelle la
78 +philosophie : aucun traceur publicitaire, préférences en localStorage.
79 +
80 +### 2 · Statistiques — portrait en direct de l'offre québécoise
81 +
82 +![Statistiques](docs/screenshots/02-stats.jpg)
83 +
84 +La page `/stats` dresse le portrait en direct des formations actives, tous
85 +établissements confondus : compteurs globaux (prix moyen affiché **858 $**,
86 +durée moyenne **14 h**), répartition **par type de formation** (2 268 cours
87 +universitaires, 2 058 formations continues, 118 cours collégiaux, 112 ateliers,
88 +104 cours en ligne, 98 conférences, 80 séminaires, 41 webinaires, 27
89 +programmes, 22 certifications, 4 bootcamps) et journal des **synchronisations
90 +récentes** par source (trouvées / ajoutées / modifiées / retirées + état OK).
91 +
92 +### 3 · Sources agrégées — le registre des établissements
93 +
94 +![Sources](docs/screenshots/03-sources.jpg)
95 +
96 +La page `/sources` expose le registre `data/sources.json` enrichi en direct :
97 +chaque établissement avec son type d'offre, son statut de connecteur
98 +(**CONNECTÉ**), le nombre de formations actives et l'horodatage de la dernière
99 +synchronisation. C'est la vitrine de transparence de Forma-Ka : on voit d'où
100 +viennent les données et quand elles ont été rafraîchies.
101 +
102 +### 4 · Confidentialité — « Votre vie privée, simplement. »
103 +
104 +![Confidentialité](docs/screenshots/04-confidentialite.jpg)
105 +
106 +La page `/confidentialite` détaille l'approche minimaliste : aucun compte
107 +utilisateur, aucun pixel publicitaire, aucun témoin tiers. Le seul stockage est
108 +le localStorage du navigateur (choix de consentement, filtres de recherche),
109 +avec un bouton « Modifier mes choix de témoins » accessible en tout temps.
110 +
111 +### 5 · Fiche formation — le détail d'abord (Isarta · Data Studio)
112 +
113 +![Fiche formation Isarta](docs/screenshots/05-formation-isarta-3Adata-studio.jpg)
114 +
115 +Une fiche `/formation/:uid` (ici `isarta:data-studio`) montre la philosophie
116 +« détail d'abord » : fil d'Ariane par catégorie, **plan de la formation**
117 +complet (11 points), bloc **Admission / clientèle visée**, colonne des
118 +**thèmes** et des **formations similaires** (avec type, mode, durée, prix et
119 +source), puis le contenu original signé par la source. Chaque fiche renvoie
120 +vers la page originale de l'établissement.
121 +
122 +### 6 · Fiche événement — prix optionnel, dates offertes (CRHA · 5 à 7 estival)
123 +
124 +![Fiche formation CRHA](docs/screenshots/10-formation-crha-3A202609035-407Lanaudiere.jpg)
125 +
126 +Deuxième exemple de fiche (`crha:202609035@7Lanaudiere`) qui illustre la
127 +souplesse du schéma : un événement de réseautage RH **sans prix affiché**
128 +(le prix est optionnel dans Forma-Ka), avec ses **dates offertes** (3 septembre
129 +2026), ses **infos pratiques** (établissement, sigle, horaire affiché, date de
130 +synchronisation) et ses thèmes régionaux (Comité régionale de Lanaudière).
131 +
132 +### 7 à 10 · La page 404 signature
133 +
134 +Les captures [`06-doc.jpg`](docs/screenshots/06-doc.jpg),
135 +[`07-contact.jpg`](docs/screenshots/07-contact.jpg),
136 +[`08-recherche.jpg`](docs/screenshots/08-recherche.jpg) et
137 +[`09-carte.jpg`](docs/screenshots/09-carte.jpg) visaient les URL `/doc`,
138 +`/contact`, `/recherche` et `/carte` : ces routes n'existent pas dans la SPA
139 +(la recherche vit sur l'accueil, le contact dans le pied de page). Elles
140 +documentent donc la **page 404 signature** de Forma-Ka — boussole, « PAGE
141 +INTROUVABLE », ticker toujours actif et pied de page complet (mission de
142 +l'agrégateur + lien confidentialité + gestion des témoins) :
143 +
144 +![Page 404](docs/screenshots/09-carte.jpg)
145 +
146 +### Galerie mobile & desktop (2026-08-25)
147 +
148 +*Captures WebP précédentes (mobile 390 × 844 · desktop 1440 × 900), conservées
149 +dans [`docs/screenshots/mobile/`](docs/screenshots/mobile/) et
150 +[`docs/screenshots/desktop/`](docs/screenshots/desktop/).*
151 +
152 +<table>
153 + <tr>
154 + <td align="center"><img src="docs/screenshots/mobile/home.webp" width="200" alt="Accueil mobile"><br><sub><b>Accueil mobile</b></sub></td>
155 + <td align="center"><img src="docs/screenshots/mobile/formation.webp" width="200" alt="Fiche mobile"><br><sub><b>Fiche — détail d'abord</b></sub></td>
156 + <td align="center"><img src="docs/screenshots/mobile/stats.webp" width="200" alt="Stats mobile"><br><sub><b>Stats mobile</b></sub></td>
157 + <td align="center"><img src="docs/screenshots/mobile/menu-mobile.webp" width="200" alt="Menu mobile"><br><sub><b>Menu mobile</b></sub></td>
158 + </tr>
159 +</table>
160 +
161 +> 🗄️ Les toutes premières captures (PNG) sont archivées dans
162 +> [`docs/archive/`](docs/archive/).
163 +
164 +---
165 +
166 +## Pourquoi Forma-Ka ?
167 +
168 +Chercher une formation au Québec, c'est ouvrir des dizaines de sites —
169 +universités, cégeps, firmes de formation, organisateurs d'événements — chacun
170 +avec sa navigation et son format. **Forma-Ka renverse le problème** : un
171 +connecteur dédié par établissement visite chaque site, normalise chaque
172 +formation dans un schéma unique et détecte les changements en continu.
173 +
174 +> Les sites de formation n'offrent pas de webhooks. Forma-Ka en reproduit
175 +> l'équivalent : **synchronisation périodique + hachage de contenu** → ajouts,
176 +> mises à jour et retraits détectés automatiquement. Une formation qui
177 +> disparaît du site source disparaît de Forma-Ka (après une période de grâce
178 +> de 2 synchronisations).
179 +
180 +**Philosophie** : contrairement à un agrégateur de produits, **le prix est
181 +optionnel** (un cours universitaire n'affiche pas de prix) — ce qui compte, ce
182 +sont les **détails** de chaque formation : description complète, objectifs
183 +d'apprentissage, plan de cours, préalables, clientèle visée, durée,
184 +crédits/UEC, mode de diffusion, dates offertes.
185 +
186 +## Fonctionnalités
187 +
188 +- **Recherche plein texte** (sujet, sigle, compétence) combinable avec les
189 + filtres **domaine** (Informatique, Gestion, RH, Marketing…), **type**
190 + (cours universitaire, formation continue, atelier, séminaire, webinaire,
191 + certification, bootcamp…), **mode** (en ligne, présentiel, hybride,
192 + asynchrone), **ville**, **langue**, **niveau**, **gratuit**, **prix maximal**
193 + et **date de début** ;
194 +- **Fiches détaillées standardisées** : description, objectifs, plan de cours,
195 + préalables, clientèle visée, durée (texte + heures), crédits/UEC, sigle,
196 + formateur, dates offertes, prix (optionnel, avec mention « à partir de »),
197 + thèmes, formations similaires, lien direct vers la source ;
198 +- **Statistiques en direct** (`/stats`) : portrait global + journal des
199 + synchronisations par source ;
200 +- **Registre des sources** (`/sources`) : statut du connecteur, volume,
201 + dernière synchro ;
202 +- **Ticker en continu** des types de formations en tête de chaque page ;
203 +- **PWA installable** (manifest + thème clair, accent ambré `#ffd54d`),
204 + pagination 12 par page, feuillet mobile (« bottom sheet ») pour les filtres ;
205 +- **Aucun traceur** : consentement localStorage, page confidentialité dédiée ;
206 +- **Mise à jour automatique** toutes les 6 h (watcher PM2) avec détection de
207 + dérive des connecteurs.
208 +
209 +## Architecture
210 +
211 +```mermaid
212 +flowchart LR
213 + subgraph Sources["18 établissements de formation"]
214 + S1["ÉTS Formation · TÉLUQ · ULaval<br/>McGill · HEC · UQAM · Technologia<br/>AFI · Cégep à distance · Les Affaires<br/>… un connecteur par site"]
215 + end
216 + subgraph FormaKa["Forma-Ka"]
217 + C["Connecteurs<br/><i>1 adaptateur / site</i>"] --> N["Normalisation<br/><i>schéma Formation unique</i>"]
218 + N --> D[("SQLite<br/>hash + diff + grâce")]
219 + D --> A["FastAPI<br/>/api/formations · /api/facets"]
220 + A --> F["React 18 + Vite<br/>PWA mobile · thème clair ambré"]
221 + end
222 + W["⏱ Watcher périodique<br/>(PM2, 6 h)"] -.-> C
223 + S1 --> C
224 + F --> U["🎓 Apprenant"]
225 +```
226 +
227 +| Couche | Rôle | Fichiers |
228 +|---|---|---|
229 +| **Connecteurs** | 1 module Python par établissement : HTML rendu serveur, API JSON internes (Shopify `products.json`, WordPress REST, Gatsby `page-data`, Destiny One…), JSON-LD schema.org (`Course`/`Event`), chaîne anti-bot résiliente pour les sites difficiles | `formaka/connectors/*.py` |
230 +| **Chaîne résiliente** | Escalade automatique direct → proxy résidentiel → anti-bot géré → déblocage premium quand un site se ferme (403/429/503, Cloudflare…) | `formaka/connectors/_resilient.py` |
231 +| **Schéma** | `Formation` standardisée : type, catégorie, mode, description, objectifs, plan, préalables, durée, crédits/UEC, sessions, prix *(optionnel)* | `formaka/schema.py` |
232 +| **Normalisation** | Couche commune : nettoyage de texte, types/modes/langues canoniques, prix, durées en heures, dates FR → ISO, extraction de détails (niveau, UEC…) | `formaka/normalize.py` |
233 +| **Moteur de diff** | Upsert par hash de contenu — nouveau / modifié / disparu (période de grâce de 2 synchros), détection de dérive des connecteurs, cache des fiches | `formaka/db.py`, `formaka/ingest.py` |
234 +| **API** | Filtres type / catégorie / mode / ville / langue / niveau / gratuit / prix / source / recherche, facettes, stats, déclenchement de synchro | `formaka/web.py` |
235 +| **Frontend** | Design « editorial sharp » : Space Grotesk, ombres décalées, accent ambré `#ffd54d`, ticker en continu, feuillet mobile, pagination 12/page, PWA installable | `frontend/` |
236 +
237 +### Structure du dépôt
238 +
239 +```
240 +forma-ka/
241 +├── run.py # point d'entrée : sync | watch | serve
242 +├── requirements.txt # fastapi, uvicorn, requests, beautifulsoup4
243 +├── formaka/
244 +│ ├── schema.py # dataclass Formation + finalize()
245 +│ ├── normalize.py # normalisation commune (texte, prix, dates…)
246 +│ ├── ingest.py # pipeline sync/watch (diff + grâce + journal)
247 +│ ├── db.py # SQLite : formations, detail_cache, sync_log
248 +│ ├── web.py # FastAPI : /api/* + service du frontend bâti
249 +│ └── connectors/ # 18 connecteurs + base.py + _resilient.py
250 +├── frontend/ # React 18 + Vite + TypeScript (PWA)
251 +│ └── src/pages/ # Home, Formation, Stats, Sources, Privacy
252 +├── data/
253 +│ ├── sources.json # registre des établissements (versionné)
254 +│ └── formaka.db # base SQLite (NON versionnée)
255 +├── docs/screenshots/ # visite guidée (JPG) + galeries webp
256 +└── tests/ # pytest (normalisation)
257 +```
258 +
259 +## Le pipeline d'ingestion en détail
260 +
261 +1. **Découverte** — le registre des connecteurs est **auto-découvrant** :
262 + chaque module de `formaka/connectors/` qui définit une classe héritant de
263 + `BaseConnector` avec un `source_id` est enrôlé automatiquement.
264 +2. **Collecte** — `fetch()` liste les formations du site (listing HTML, API
265 + JSON interne, sitemap…), puis visite les pages de détail au besoin.
266 +3. **Cache des fiches** — les pages de détail sont mises en cache dans la base
267 + (`detail_cache`) avec une **clé hebdomadaire** : une page n'est revisitée
268 + que si elle est nouvelle, modifiée, ou quand la semaine ISO change. Les
269 + synchros intermédiaires sont donc rapides et économes.
270 +4. **Normalisation** — `Formation.finalize()` applique la couche commune
271 + (types/modes/langues canoniques, prix depuis le libellé, durée en heures,
272 + dates françaises → ISO, extraction de niveau/UEC/crédits) sans jamais
273 + écraser une valeur explicite du connecteur.
274 +5. **Diff & grâce** — chaque fiche a un `content_hash` SHA-256 ; l'upsert ne
275 + touche que ce qui a changé. Une formation absente du site source est
276 + retirée après **2 synchros consécutives** d'absence (période de grâce
277 + contre les listings instables).
278 +6. **Journal & dérive** — chaque synchro est journalisée (`sync_log` :
279 + trouvées / ajoutées / modifiées / retirées + taux de champs nuls) ; une
280 + chute anormale du volume ou une explosion des champs vides signale une
281 + dérive du connecteur (site remanié) visible sur `/stats` et `/sources`.
282 +
283 +## Les backends de fetch (chaîne résiliente)
284 +
285 +Historiquement, `BaseConnector.fetch_html()` enchaînait trois backends :
286 +
287 +1. **requests direct** — sites rendus serveur (rapide, gratuit) ;
288 +2. **Scrapfly** (`SCRAPFLY_API_KEY`) — contournement anti-bot (`asp`), rendu
289 + JavaScript (`render_js`), géolocalisation canadienne ;
290 +3. **Firecrawl** (`FIRECRAWL_API_KEY`) — rendu JS de repli, formats
291 + html/markdown.
292 +
293 +Cette chaîne est désormais renforcée par la **chaîne anti-bot résiliente
294 +commune du Groupe KA** (`formaka/connectors/_resilient.py`) : quand un site
295 +jusque-là ouvert déploie un anti-bot (Cloudflare, Akamai, Incapsula,
296 +PerimeterX) ou renvoie 403/429/503, la requête directe n'échoue plus
297 +silencieusement — elle **escalade automatiquement** :
298 +
299 +1. **Direct** — session du connecteur (`curl_cffi` avec empreinte de
300 + navigateur si disponible, sinon `requests`) ;
301 +2. **Proxy résidentiel** — IP résidentielle canadienne propre ;
302 +3. **Scrapfly (ASP)** — bypass anti-bot géré + rendu JS optionnel ;
303 +4. **Bright Data (Web Unlocker)** — déblocage premium, dernier recours.
304 +
305 +Le premier backend qui renvoie un 200 non vide gagne ; si tous échouent, la
306 +dernière réponse est renvoyée telle quelle pour que le connecteur journalise
307 +l'échec normalement (aucun blocage silencieux). Les clés d'API vivent dans
308 +`.env` (jamais versionné).
309 +
310 +## Le schéma `Formation`
311 +
312 +Chaque connecteur, peu importe le site source, produit des objets `Formation`
313 +(`formaka/schema.py`) :
314 +
315 +| Groupe | Champs |
316 +|---|---|
317 +| **Identité** | `source`, `external_id`, `url` → `uid = source:external_id` |
318 +| **Classement** | `title`, `training_type`, `category`, `tags`, `level`, `language` |
319 +| **Diffusion** | `mode` (en ligne / présentiel / hybride / asynchrone), `city`, `start_date` (ISO), `sessions[]`, `schedule_label`, `duration`, `duration_hours` |
320 +| **Contenu** | `description`, `objectives[]`, `program[]` (plan de cours), `prerequisites`, `audience`, `instructor`, `code` (sigle), `images[]` |
321 +| **Valeur** | `price` *(optionnel — `None` = non affiché, c'est normal !)*, `price_label` (texte original), `is_free`, `credits` (« 3 crédits », « 1,4 UEC »), `credential` |
322 +| **Technique** | `details` (JSON structuré dérivé), `content_hash()` (SHA-256 pour la détection de changements) |
323 +
324 +## API & routes
325 +
326 +### API REST (FastAPI)
327 +
328 +| Point d'accès | Description |
329 +|---|---|
330 +| `GET /api/formations` | Liste filtrable (`training_type`, `category`, `mode`, `city`, `language`, `level`, `source`, `free`, `price_max`, `starts_after`, `q`, `sort`, `limit`, `offset`) |
331 +| `GET /api/formations/{uid}` | Fiche complète + historique de prix + formations similaires |
332 +| `GET /api/facets` | Valeurs distinctes pour construire les filtres |
333 +| `GET /api/sources` | Registre des établissements + état de synchro (`active_formations`, `last_sync`) |
334 +| `GET /api/stats` | Portrait global (totaux, moyennes, répartition par type) + journal des synchros |
335 +| `POST /api/sync` | Déclenche une synchronisation en arrière-plan |
336 +
337 +### Routes du frontend (SPA React Router)
338 +
339 +| Route | Page |
340 +|---|---|
341 +| `/` | Accueil — recherche, filtres, compteurs en direct |
342 +| `/formation/:uid` | Fiche détaillée (ex. `/formation/isarta:data-studio`) |
343 +| `/stats` | Statistiques en direct |
344 +| `/sources` | Registre des sources agrégées |
345 +| `/confidentialite` | Politique de confidentialité & témoins |
346 +| `*` | Page 404 signature (boussole) |
347 +
348 +Le backend sert aussi le frontend bâti (`frontend/dist`) : `/assets` en
349 +statique + rattrapage SPA sur toutes les autres routes.
350 +
351 +## Données & sources agrégées
352 +
353 +**18 établissements connectés — 4 932 formations actives** (relevé du
354 +2026-08-28) :
355 +
356 +| Établissement | Offre | Formations | Région |
357 +|---|---|---:|---|
358 +| Université Laval — Formation à distance | Cours universitaires à distance, hybrides et comodaux | 1 757 | Québec (à distance / hybride) |
359 +| Technologia | Formation professionnelle — TI, IA, gestion | 590 | Montréal / Québec |
360 +| Université TÉLUQ | Cours universitaires 100 % à distance, tous cycles | 511 | Québec (à distance) |
361 +| AFI par Edgenda | Formation professionnelle — TI, leadership | 358 | Québec / Montréal |
362 +| ÉTS Formation | Formation continue + UEC — technologie, construction, gestion, RH | 296 | Montréal |
363 +| Versalys | Bureautique, TI, langues | 257 | Montréal · Québec · Laval · Brossard |
364 +| McGill School of Continuing Studies | Formation continue (anglais) | 197 | Montréal |
365 +| Isarta Formations | Marketing, communications, RH | 189 | Montréal (virtuel) |
366 +| CRHA — Espace Formation | Formations et événements RH | 180 | Québec (province) — surtout en ligne |
367 +| Événements Les Affaires | Conférences et webinaires d'affaires | 139 | Montréal / Québec / en ligne |
368 +| Cégep à distance | Cours collégiaux à distance | 118 | Québec (en ligne) |
369 +| École des dirigeant(e)s HEC Montréal | Séminaires et certifications pour cadres | 93 | Montréal |
370 +| ITHQ — Ateliers et formations | Ateliers vins/cuisine + hôtellerie | 78 | Montréal (+ province) |
371 +| Formation continue UQAM | Formation continue universitaire — UEC, séminaires | 49 | Montréal |
372 +| École des entrepreneurs du Québec | Formations entrepreneur·e·s (souvent gratuites) | 48 | Montréal + en ligne |
373 +| Institut de leadership | Certifications et programmes en leadership | 37 | Montréal (+ cohortes en ligne) |
374 +| AlphaNumérique | Littératie numérique — gratuit | 29 | Québec (en ligne) |
375 +| Le Wagon Montréal | Bootcamps dev web / data / IA | 4 | Montréal |
376 +
377 +Répartition par type : **2 268** cours universitaires · **2 058** formations
378 +continues · **118** cours collégiaux · **112** ateliers · **104** cours en
379 +ligne · **98** conférences · **80** séminaires · **41** webinaires · **27**
380 +programmes · **22** certifications · **4** bootcamps.
381 +
382 +Le registre vit dans [`data/sources.json`](data/sources.json) (versionné) ;
383 +la base SQLite (`data/formaka.db`) est locale et reconstruite par `sync`.
384 +
385 +## Ajouter un connecteur
386 +
387 +1. Créer `formaka/connectors/<source_id>.py` : une classe héritant de
388 + `BaseConnector`, définir `source_id` et implémenter
389 + `fetch() -> list[Formation]`. Le registre est **auto-découvrant** — rien
390 + d'autre à modifier.
391 +2. Ajouter l'entrée correspondante dans `data/sources.json`.
392 +3. Tester : `.venv/bin/python run.py sync <source_id>`.
393 +
394 +```python
395 +class MonEcoleConnector(BaseConnector):
396 + source_id = "mon_ecole"
397 +
398 + def fetch(self) -> list[Formation]:
399 + html = self.fetch_html(LIST_URL) # direct -> chaîne résiliente
400 + ...
401 + return [Formation(source=self.source_id, external_id=..., url=...,
402 + title=..., description=..., objectives=[...], ...)]
403 +```
404 +
405 +## Démarrage rapide
406 +
407 +```bash
408 +git clone https://git.spboucher.ai/forma-ka.git && cd forma-ka
409 +
410 +# Backend
411 +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
412 +
413 +# Frontend
414 +cd frontend && npm install && npm run build && cd ..
415 +
416 +# Clés des backends de scraping (sites JavaScript / anti-bot) — jamais versionnées
417 +cat > .env <<EOF
418 +FIRECRAWL_API_KEY=fc-votre-cle
419 +SCRAPFLY_API_KEY=scp-live-votre-cle
420 +EOF
421 +
422 +# Ingestion, puis service
423 +.venv/bin/python run.py sync # toutes les sources (ou : run.py sync ets_formation teluq)
424 +.venv/bin/python run.py serve 8080 # API + frontend -> http://localhost:8080
425 +.venv/bin/python run.py watch 360 # boucle de synchro (défaut : toutes les 6 h)
426 +```
427 +
428 +## Tests
429 +
430 +```bash
431 +.venv/bin/python -m pytest tests/ -q
432 +```
433 +
434 +## Déploiement (production)
435 +
436 +| Élément | Valeur |
437 +|---|---|
438 +| **Nœud** | `M3U96a` (Mac Studio, cluster MacLustr) — `~/apps/forma-ka` |
439 +| **Port** | `8110` |
440 +| **Processus PM2** | `forma-ka` (API + frontend, `run.py serve 8110`) · `forma-ka-sync` (`run.py watch 360` → synchro toutes les 6 h) · `forma-ka-ngrok` (tunnel) |
441 +| **Domaine** | [www.forma-ka.com](https://www.forma-ka.com) via ngrok |
442 +| **Base** | `data/formaka.db` (SQLite, locale au nœud) |
443 +| **Résilience** | PM2 avec redémarrage automatique + `pm2 startup` (launchd) |
444 +
445 +```bash
446 +# Sur le nœud (lecture seule — l'état de référence)
447 +pm2 ls | grep forma-ka
448 +curl -s localhost:8110/api/stats | head -c 300
449 +```
450 +
451 +## Dépôt & développement remote-first
452 +
453 +La **source de vérité est le dépôt git sur le nœud de déploiement**
454 +(`M3U96a:~/apps/forma-ka`), pas une copie locale. Le remote `origin` est le
455 +git personnel **spbgit** (git.spboucher.ai) — dépôt nu
456 +`~/srv/git/forma-ka.git` hébergé sur M3U96a.
457 +
458 +Cycle de travail : éditer sur le nœud via SSH → `npm run build` (frontend) →
459 +`pm2 restart forma-ka` → `git add/commit/push origin main` **sur le nœud**
460 +(agent forwarding actif).
461 +
462 +Ce qui n'est **jamais versionné** (voir [`.gitignore`](.gitignore)) : la base
463 +SQLite et les caches (`data/`, sauf `sources.json` déjà suivi), les secrets
464 +(`.env*`), les environnements (`.venv/`, `node_modules/`), les artefacts de
465 +build (`frontend/dist/`) et les journaux.
466 +
467 +## Confidentialité
468 +
469 +Forma-Ka ne collecte **rien qui identifie l'utilisateur** : pas de compte, pas
470 +de formulaire d'inscription, pas de pixel publicitaire, pas de témoin tiers,
471 +aucune donnée vendue. Le seul stockage est le **localStorage** du navigateur
472 +(choix de consentement, filtres, préférences d'affichage), modifiable en tout
473 +temps via « Gérer mes témoins ». Détails : page
474 +[`/confidentialite`](https://www.forma-ka.com/confidentialite).
475 +
476 +## Écosystème Groupe Ka
477 +
478 +Forma-Ka fait partie du **Groupe Ka** ([groupe-ka.com](https://www.groupe-ka.com)),
479 +la famille d'agrégateurs indépendants du Québec — notamment
480 +[Lou-Ka](https://www.lou-ka.com) (logements),
481 +[Immo-Ka](https://www.immo-ka.com) (propriétés),
482 +[Vrai-Prix](https://www.vrai-prix.com) (épicerie),
483 +[Auto-Ka](https://www.auto-ka.com) (véhicules),
484 +[Food-Ka](https://www.food-ka.com), [Resto-Ka](https://www.resto-ka.com),
485 +[Sorti-Ka](https://www.sorti-ka.com) (sorties),
486 +[Job-Ka](https://www.job-ka.com) (emplois),
487 +[Trouve-Ka](https://www.trouve-ka.com) (recherche),
488 +[Créa-Ka](https://www.crea-ka.com) (créateurs),
489 +[Fabri-Ka](https://www.fabri-ka.com) (produits d'ici) et
490 +[Ka·Stats](https://www.ka-stats.com) (statistiques du Québec).
491 +Même ADN partout : connecteurs dédiés, schéma standardisé, diff par hachage,
492 +fiches détaillées, respect de la vie privée — et le mécanisme de Forma-Ka est
493 +directement adapté de celui de Lou-Ka.
494 +
495 +## Contact
496 +
497 +<div align="center">
498 +
499 +**Simon-Pierre Boucher**
500 +[contact@spboucher.ai](mailto:contact@spboucher.ai) · [www.forma-ka.com](https://www.forma-ka.com)
501 +
502 +© 2026 Simon-Pierre Boucher — tous droits réservés.
503 +
504 +</div>
added docs/screenshots/01-accueil.jpg +0 −0

Binary file not shown.

added docs/screenshots/02-stats.jpg +0 −0

Binary file not shown.

added docs/screenshots/03-sources.jpg +0 −0

Binary file not shown.

added docs/screenshots/04-confidentialite.jpg +0 −0

Binary file not shown.

added docs/screenshots/05-formation-isarta-3Adata-studio.jpg +0 −0

Binary file not shown.

added docs/screenshots/06-doc.jpg +0 −0

Binary file not shown.

added docs/screenshots/07-contact.jpg +0 −0

Binary file not shown.

added docs/screenshots/08-recherche.jpg +0 −0

Binary file not shown.

added docs/screenshots/09-carte.jpg +0 −0

Binary file not shown.

added docs/screenshots/10-formation-crha-3A202609035-407Lanaudiere.jpg +0 −0

Binary file not shown.

added docs/screenshots/manifest.txt +10 −0
@@ -0,0 +1,10 @@
1 +01-accueil.jpg :: https://www.forma-ka.com/
2 +02-stats.jpg :: https://www.forma-ka.com/stats
3 +03-sources.jpg :: https://www.forma-ka.com/sources
4 +04-confidentialite.jpg :: https://www.forma-ka.com/confidentialite
5 +05-formation-isarta-3Adata-studio.jpg :: https://www.forma-ka.com/formation/isarta%3Adata-studio
6 +06-doc.jpg :: https://www.forma-ka.com/doc
7 +07-contact.jpg :: https://www.forma-ka.com/contact
8 +08-recherche.jpg :: https://www.forma-ka.com/recherche
9 +09-carte.jpg :: https://www.forma-ka.com/carte
10 +10-formation-crha-3A202609035-407Lanaudiere.jpg :: https://www.forma-ka.com/formation/crha%3A202609035%407Lanaudiere
added formaka/connectors/_resilient.py +318 −0
@@ -0,0 +1,318 @@
1 +# =============================================================================
2 +# Groupe KA — connecteurs : chaîne de fetch anti-bot RÉSILIENTE (commune)
3 +# Auteur : Simon-Pierre Boucher <contact@spboucher.ai>
4 +# Fichier : connectors/_resilient.py
5 +# -----------------------------------------------------------------------------
6 +# But : rendre les connecteurs durables dans le temps. Quand un site jusque-là
7 +# ouvert déploie un anti-bot (Cloudflare / Akamai / Incapsula / PerimeterX) ou
8 +# renvoie 403/429/503, la requête directe N'ÉCHOUE PLUS silencieusement : elle
9 +# ESCALADE automatiquement à travers une chaîne de secours :
10 +#
11 +# 1. Direct — la session du connecteur (curl_cffi impersonate si
12 +# dispo, sinon requests) : rapide et gratuit.
13 +# 2. Oxylabs (résid.) — proxy résidentiel Canada (-cc-CA) : IP propre.
14 +# 3. Scrapfly (ASP) — bypass anti-bot géré + rendu JS optionnel.
15 +# 4. Bright Data — Web Unlocker : déblocage premium, dernier recours.
16 +#
17 +# Le premier backend qui renvoie un 200 non vide gagne. Si TOUS échouent, on
18 +# renvoie la dernière réponse (avec son code d'erreur) pour que le connecteur
19 +# journalise l'échec comme avant — aucun changement de comportement en cas
20 +# d'échec total, aucun blocage silencieux.
21 +#
22 +# Conception :
23 +# - Aucun effet de bord à l'import ; toute brique non configurée est sautée.
24 +# - Les clés sont lues de os.environ, avec repli sur le .env de l'app puis
25 +# ~/.claude/.env, et acceptent les deux noms Scrapfly (SCRAPFLY_KEY /
26 +# SCRAPFLY_API_KEY). => aucune modif de .env nécessaire.
27 +# - `_ResilientResponse` imite requests.Response (.text/.content/.status_code/
28 +# .url/.headers/.json()/.ok/.raise_for_status()) : les connecteurs existants
29 +# continuent de fonctionner sans modification.
30 +# - Coupe-circuit par hôte : après plusieurs escalades totalement infructueuses
31 +# sur un même hôte, on saute l'escalade payante pendant un temps de repos
32 +# (évite de brûler du quota Scrapfly/Bright Data sur une source morte).
33 +# =============================================================================
34 +from __future__ import annotations
35 +
36 +import json as _json
37 +import os
38 +import time
39 +from pathlib import Path
40 +from urllib.parse import quote, urlsplit
41 +
42 +import requests
43 +
44 +# -- curl_cffi est OPTIONNEL (meilleur fingerprint TLS s'il est présent) ------
45 +try: # pragma: no cover
46 + from curl_cffi import requests as _cffi # type: ignore
47 + _HAS_CFFI = True
48 +except Exception: # noqa: BLE001
49 + _cffi = None
50 + _HAS_CFFI = False
51 +
52 +# Codes HTTP typiques d'un blocage anti-bot (≠ 401/404/410/500 « métier » :
53 +# 401 = auth manquante, 403/429 = bot bloqué, 5xx CF = challenge/edge).
54 +BLOCK_STATUS = {403, 429, 503, 520, 521, 522, 523, 524, 526, 1020}
55 +
56 +# Marqueurs de page-challenge (Cloudflare/Akamai/Incapsula/PerimeterX/DataDome).
57 +_CHALLENGE_MARKERS = (
58 + "just a moment", "cf-browser-verification", "cf-challenge",
59 + "attention required", "access denied", "request unsuccessful",
60 + "px-captcha", "perimeterx", "incapsula", "_incapsula_", "datadome",
61 + "captcha-delivery", "please enable javascript and cookies",
62 + "checking your browser", "ddos protection by",
63 +)
64 +
65 +_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
66 + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
67 +
68 +# Coupe-circuit en mémoire : hôte -> (timestamp_jusquà, échecs_consécutifs)
69 +_COOLDOWN: dict[str, tuple[float, int]] = {}
70 +_COOLDOWN_HITS = 3 # nb d'échecs totaux avant repos
71 +_COOLDOWN_SECONDS = 900.0 # 15 min de repos pour un hôte « mort »
72 +
73 +# -- chargement paresseux des secrets ----------------------------------------
74 +_ENV_CACHE: dict[str, str] | None = None
75 +
76 +
77 +def _load_env_files() -> dict[str, str]:
78 + """Parse les .env candidats une seule fois (repli si os.environ vide)."""
79 + global _ENV_CACHE
80 + if _ENV_CACHE is not None:
81 + return _ENV_CACHE
82 + out: dict[str, str] = {}
83 + candidates = []
84 + # .env de l'app (remonte quelques niveaux depuis ce module)
85 + here = Path(__file__).resolve()
86 + for up in range(2, 6):
87 + try:
88 + candidates.append(here.parents[up] / ".env")
89 + except IndexError:
90 + break
91 + candidates.append(Path.home() / ".claude" / ".env")
92 + for path in candidates:
93 + try:
94 + if not path.is_file():
95 + continue
96 + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
97 + line = line.strip()
98 + if not line or line.startswith("#") or "=" not in line:
99 + continue
100 + k, _, v = line.partition("=")
101 + k, v = k.strip(), v.strip().strip('"').strip("'")
102 + # ne pas écraser une valeur déjà trouvée (priorité app > global)
103 + if k and k not in out:
104 + out[k] = v
105 + except Exception: # noqa: BLE001
106 + continue
107 + _ENV_CACHE = out
108 + return out
109 +
110 +
111 +def _secret(*names: str) -> str | None:
112 + """Cherche une clé dans os.environ puis dans les .env (par ordre de noms)."""
113 + for n in names:
114 + v = os.environ.get(n)
115 + if v:
116 + return v
117 + env = _load_env_files()
118 + for n in names:
119 + v = env.get(n)
120 + if v:
121 + return v
122 + return None
123 +
124 +
125 +# -- réponse compatible requests.Response ------------------------------------
126 +class _ResilientResponse:
127 + """Imite le minimum utile d'une requests.Response pour les connecteurs."""
128 +
129 + def __init__(self, url: str, status_code: int, text: str,
130 + headers: dict | None = None, via: str = "direct") -> None:
131 + self.url = url
132 + self.status_code = int(status_code or 0)
133 + self._text = text or ""
134 + self.headers = headers or {}
135 + self.encoding = "utf-8"
136 + self.via = via # backend gagnant (diagnostic)
137 +
138 + @property
139 + def text(self) -> str:
140 + return self._text
141 +
142 + @property
143 + def content(self) -> bytes:
144 + return self._text.encode("utf-8", errors="ignore")
145 +
146 + @property
147 + def ok(self) -> bool:
148 + return 200 <= self.status_code < 400
149 +
150 + def json(self, **kw):
151 + return _json.loads(self._text)
152 +
153 + def raise_for_status(self):
154 + if 400 <= self.status_code < 600:
155 + raise requests.HTTPError(
156 + f"{self.status_code} via {self.via} pour {self.url}",
157 + response=self) # type: ignore[arg-type]
158 + return None
159 +
160 + def __repr__(self) -> str: # pragma: no cover
161 + return f"<_ResilientResponse [{self.status_code}] via {self.via}>"
162 +
163 +
164 +# -- détection de blocage -----------------------------------------------------
165 +def is_blocked(resp) -> bool:
166 + """True si la réponse ressemble à un blocage anti-bot (≠ erreur métier)."""
167 + if resp is None:
168 + return True
169 + code = getattr(resp, "status_code", 0) or 0
170 + if code in BLOCK_STATUS:
171 + return True
172 + # 200 mais page-challenge servie
173 + if code == 200:
174 + try:
175 + body = (resp.text or "")[:4000].lower()
176 + except Exception: # noqa: BLE001
177 + return False
178 + server = str(resp.headers.get("Server", "")).lower() if getattr(resp, "headers", None) else ""
179 + if any(m in body for m in _CHALLENGE_MARKERS):
180 + return True
181 + if "cloudflare" in server and ("captcha" in body or "challenge" in body):
182 + return True
183 + return False
184 +
185 +
186 +def _host(url: str) -> str:
187 + try:
188 + return urlsplit(url).netloc.lower()
189 + except Exception: # noqa: BLE001
190 + return url
191 +
192 +
193 +def _cooling(host: str) -> bool:
194 + until, _ = _COOLDOWN.get(host, (0.0, 0))
195 + return time.time() < until
196 +
197 +
198 +def _note_failure(host: str) -> None:
199 + until, hits = _COOLDOWN.get(host, (0.0, 0))
200 + hits += 1
201 + if hits >= _COOLDOWN_HITS:
202 + _COOLDOWN[host] = (time.time() + _COOLDOWN_SECONDS, 0)
203 + else:
204 + _COOLDOWN[host] = (until, hits)
205 +
206 +
207 +def _note_success(host: str) -> None:
208 + _COOLDOWN.pop(host, None)
209 +
210 +
211 +# -- backends d'escalade ------------------------------------------------------
212 +def _try_oxylabs(url: str, timeout: int, country: str,
213 + headers: dict | None) -> _ResilientResponse | None:
214 + endpoint = _secret("OXYLABS_PROXY") # pr.oxylabs.io:7777
215 + user = _secret("OXYLABS_PROXY_USER") # customer-... (sans -cc-XX)
216 + pwd = _secret("OXYLABS_PROXY_PASS")
217 + if not (endpoint and user and pwd):
218 + return None
219 + cc = (country or "ca").upper()
220 + puser = f"{user}-cc-{cc}"
221 + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}"
222 + proxies = {"http": proxy, "https": proxy}
223 + hdrs = {"User-Agent": _UA}
224 + if headers:
225 + hdrs.update(headers)
226 + try:
227 + r = requests.get(url, proxies=proxies, headers=hdrs, timeout=timeout,
228 + verify=False) # noqa: S501 (proxy MITM du CA Oxylabs)
229 + return _ResilientResponse(url, r.status_code, r.text,
230 + dict(r.headers), via="oxylabs")
231 + except Exception: # noqa: BLE001
232 + return None
233 +
234 +
235 +def _try_scrapfly(url: str, timeout: int, country: str, render_js: bool,
236 + headers: dict | None) -> _ResilientResponse | None:
237 + key = _secret("SCRAPFLY_KEY", "SCRAPFLY_API_KEY")
238 + if not key:
239 + return None
240 + params = {"key": key, "url": url, "country": country or "ca",
241 + "asp": "true", "proxy_pool": "public_residential_pool"}
242 + if render_js:
243 + params["render_js"] = "true"
244 + if headers:
245 + for k, v in headers.items():
246 + params[f"headers[{k}]"] = v
247 + try:
248 + r = requests.get("https://api.scrapfly.io/scrape", params=params,
249 + timeout=max(timeout, 180))
250 + result = (r.json() or {}).get("result") or {}
251 + return _ResilientResponse(
252 + url, result.get("status_code") or 0, result.get("content") or "",
253 + (result.get("response_headers") or {}), via="scrapfly")
254 + except Exception: # noqa: BLE001
255 + return None
256 +
257 +
258 +def _try_brightdata(url: str, timeout: int,
259 + headers: dict | None) -> _ResilientResponse | None:
260 + key = _secret("BRIGHTDATA_API_KEY")
261 + zone = _secret("BRIGHTDATA_ZONE") or "web_unlocker1"
262 + if not key:
263 + return None
264 + try:
265 + r = requests.post(
266 + "https://api.brightdata.com/request",
267 + headers={"Authorization": f"Bearer {key}",
268 + "Content-Type": "application/json"},
269 + json={"zone": zone, "url": url, "format": "raw"},
270 + timeout=max(timeout, 120))
271 + return _ResilientResponse(url, r.status_code, r.text,
272 + dict(r.headers), via="brightdata")
273 + except Exception: # noqa: BLE001
274 + return None
275 +
276 +
277 +# -- API publique -------------------------------------------------------------
278 +def escalate(url: str, *, timeout: int = 30, country: str = "ca",
279 + render_js: bool = False, headers: dict | None = None,
280 + original=None):
281 + """Tente la chaîne de secours et renvoie la meilleure réponse.
282 +
283 + Renvoie un `_ResilientResponse` 200 dès qu'un backend réussit ; sinon la
284 + dernière réponse tentée (ou `original`) pour préserver le comportement
285 + d'échec du connecteur. Respecte le coupe-circuit par hôte.
286 + """
287 + host = _host(url)
288 + if _cooling(host):
289 + return original # source au repos : on ne brûle pas de quota payant
290 +
291 + last = original
292 + for backend in (
293 + lambda: _try_oxylabs(url, timeout, country, headers),
294 + lambda: _try_scrapfly(url, timeout, country, render_js, headers),
295 + lambda: _try_brightdata(url, timeout, headers),
296 + ):
297 + resp = backend()
298 + if resp is None:
299 + continue
300 + last = resp
301 + if resp.status_code == 200 and resp.text and not is_blocked(resp):
302 + _note_success(host)
303 + return resp
304 + time.sleep(0.4)
305 +
306 + _note_failure(host)
307 + return last if last is not None else original
308 +
309 +
310 +def escalate_if_blocked(resp, url: str, *, timeout: int = 30,
311 + country: str = "ca", render_js: bool = False,
312 + headers: dict | None = None):
313 + """Renvoie `resp` s'il est bon ; sinon lance l'escalade anti-bot."""
314 + if not is_blocked(resp):
315 + return resp
316 + better = escalate(url, timeout=timeout, country=country,
317 + render_js=render_js, headers=headers, original=resp)
318 + return better if better is not None else resp
modified formaka/connectors/base.py +68 −0
@@ -201,3 +201,71 @@ class BaseConnector:
201 201 # -- contrat --------------------------------------------------------------
202 202 def fetch(self) -> list[Formation]:
203 203 raise NotImplementedError
204 +
205 +
206 +# =============================================================================
207 +# Résilience anti-bot (Groupe KA) — auto-escalade de get() sans toucher au corps.
208 +# Ajouté par l'orchestrateur KA : enrobe BaseConnector.get pour qu'un blocage
209 +# anti-bot (403/429/503/challenge) ou une coupure réseau déclenche la chaîne
210 +# de secours (Oxylabs résidentiel -> Scrapfly ASP -> Bright Data). Voir
211 +# connectors/_resilient.py. Idempotent (marqueur _KA_RESILIENT_WRAPPED).
212 +# =============================================================================
213 +if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False):
214 + import requests as _ka_requests # noqa: E402
215 + from . import _resilient as _kar # noqa: E402
216 +
217 + _ka_orig_get = BaseConnector.get
218 +
219 + def _ka_full_url(url, kw):
220 + try:
221 + return _ka_requests.Request("GET", url,
222 + params=kw.get("params")).prepare().url
223 + except Exception: # noqa: BLE001
224 + return url
225 +
226 + def _ka_resilient_get(self, url, **kw):
227 + timeout = getattr(self, "timeout", 30)
228 + headers = kw.get("headers")
229 + try:
230 + return _ka_orig_get(self, url, **kw)
231 + except _ka_requests.HTTPError as exc:
232 + r = getattr(exc, "response", None)
233 + if r is not None and _kar.is_blocked(r):
234 + target = getattr(r, "url", None) or _ka_full_url(url, kw)
235 + better = _kar.escalate_if_blocked(
236 + r, target, timeout=timeout, headers=headers)
237 + if better is not None and getattr(better, "status_code", 0) == 200:
238 + return better
239 + raise
240 + except (_ka_requests.ConnectionError, _ka_requests.Timeout):
241 + better = _kar.escalate(_ka_full_url(url, kw),
242 + timeout=timeout, headers=headers)
243 + if better is not None and getattr(better, "status_code", 0) == 200:
244 + return better
245 + raise
246 +
247 + def _ka_get_resilient(self, url, *, render_js=False, country="ca", **kw):
248 + """Fetch anti-bot explicite : force la chaîne de secours au besoin.
249 +
250 + Comme get() mais tente d'abord le direct puis escalade même sur 200-
251 + challenge, avec rendu JS optionnel. Renvoie une réponse compatible
252 + requests (.text/.content/.status_code/.json()...).
253 + """
254 + timeout = getattr(self, "timeout", 30)
255 + headers = kw.get("headers")
256 + try:
257 + resp = _ka_orig_get(self, url, **kw)
258 + except _ka_requests.HTTPError as exc:
259 + resp = getattr(exc, "response", None)
260 + except (_ka_requests.ConnectionError, _ka_requests.Timeout):
261 + resp = None
262 + target = _ka_full_url(url, kw)
263 + if resp is not None and getattr(resp, "url", None):
264 + target = resp.url
265 + return _kar.escalate_if_blocked(resp, target, timeout=timeout,
266 + country=country, render_js=render_js,
267 + headers=headers)
268 +
269 + BaseConnector.get = _ka_resilient_get
270 + BaseConnector.get_resilient = _ka_get_resilient
271 + BaseConnector._KA_RESILIENT_WRAPPED = True
deleted frontend/tsconfig.tsbuildinfo +0 −1
@@ -1 +0,0 @@
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
2