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)
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 |  |
| 10 | 10 |  |
| 11 | 11 |  |
| 12 | − | |
| 13 | − | |
| 14 | − | |
| 15 | − | |
| 16 | − | |
| 17 | − | |
| 18 | − | |
| 19 | − | |
| 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 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | ||