Forma-Ka — Québec training aggregator: 18 connectors, detail-first Formation schema, FastAPI + React PWA
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 54 changed files with +10,143 and −0
added
.gitignore
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +.env | |
| 2 | +.venv/ | |
| 3 | +__pycache__/ | |
| 4 | +*.pyc | |
| 5 | +data/formaka.db | |
| 6 | +frontend/node_modules/ | |
| 7 | +frontend/dist/ | |
| 8 | +.DS_Store | |
added
README.md
+191 −0
@@ -0,0 +1,191 @@ | ||
| 1 | +<div align="center"> | |
| 2 | + | |
| 3 | +# Forma·Ka | |
| 4 | + | |
| 5 | +### Every training program in Québec. One place. | |
| 6 | + | |
| 7 | +**[www.forma-ka.com](https://www.forma-ka.com)** | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 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.* | |
| 25 | + | |
| 26 | +<br/> | |
| 27 | + | |
| 28 | +**Author : Simon-Pierre Boucher — [contact@spboucher.ai](mailto:contact@spboucher.ai)** | |
| 29 | + | |
| 30 | +</div> | |
| 31 | + | |
| 32 | +--- | |
| 33 | + | |
| 34 | +## Screenshots | |
| 35 | + | |
| 36 | +| Home — search, filters, live ticker | Program page — details first | | |
| 37 | +|---|---| | |
| 38 | +|  |  | | |
| 39 | + | |
| 40 | +<details> | |
| 41 | +<summary><b>Stats page — live portrait of Québec's training offer</b></summary> | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | +</details> | |
| 46 | + | |
| 47 | +## Why Forma-Ka? | |
| 48 | + | |
| 49 | +Looking for training in Québec means opening dozens of websites — universities, | |
| 50 | +CEGEPs, training firms, event organizers — each with its own navigation and | |
| 51 | +format. **Forma-Ka flips the problem**: a dedicated connector per institution | |
| 52 | +visits each site, normalizes every program into a single schema, and detects | |
| 53 | +changes continuously. | |
| 54 | + | |
| 55 | +> Training sites don't offer webhooks. Forma-Ka reproduces the equivalent: | |
| 56 | +> **periodic sync + content hashing** → additions, updates and removals detected | |
| 57 | +> automatically. A program that disappears from the source site disappears from | |
| 58 | +> Forma-Ka (after a 2-sync grace period). | |
| 59 | + | |
| 60 | +**Philosophy**: unlike a product aggregator, **price is optional** (university | |
| 61 | +courses don't display one) — what matters are the **details** of each program: | |
| 62 | +full description, learning objectives, course outline, prerequisites, target | |
| 63 | +audience, duration, credits/CEUs, delivery mode, offered dates. | |
| 64 | + | |
| 65 | +## Architecture in 30 seconds | |
| 66 | + | |
| 67 | +```mermaid | |
| 68 | +flowchart LR | |
| 69 | + subgraph Sources["18 training institutions"] | |
| 70 | + S1["ÉTS Formation · TÉLUQ · ULaval<br/>McGill · HEC · UQAM · Technologia<br/>AFI · Cégep à distance · Les Affaires<br/>… one connector per site"] | |
| 71 | + end | |
| 72 | + subgraph FormaKa["Forma-Ka"] | |
| 73 | + C["Connectors<br/><i>1 adapter / site</i>"] --> N["Normalization<br/><i>single Formation schema</i>"] | |
| 74 | + N --> D[("SQLite<br/>hash + diff")] | |
| 75 | + D --> A["FastAPI<br/>/api/formations · /api/facets"] | |
| 76 | + A --> F["React 18 + Vite<br/>mobile PWA · light theme"] | |
| 77 | + end | |
| 78 | + W["⏱ Periodic watcher<br/>(PM2)"] -.-> C | |
| 79 | + S1 --> C | |
| 80 | + F --> U["🔑 Learner"] | |
| 81 | +``` | |
| 82 | + | |
| 83 | +| Layer | Role | Files | | |
| 84 | +|---|---|---| | |
| 85 | +| **Connectors** | 1 Python module per institution: server-rendered HTML, internal JSON APIs (Shopify products.json, WordPress REST, Gatsby page-data, Destiny One…), schema.org JSON-LD (Course/Event), **Scrapfly** (robust anti-bot) or **Firecrawl** (JS rendering) for hard sites | `formaka/connectors/*.py` | | |
| 86 | +| **Schema** | Standardized `Formation`: type, category, mode, description, objectives, outline, prerequisites, duration, credits/CEUs, sessions, price *(optional)* | `formaka/schema.py` | | |
| 87 | +| **Diff engine** | Content-hash upsert — new / changed / gone (grace period), connector drift detection | `formaka/db.py` | | |
| 88 | +| **API** | Filters: type / category / mode / city / language / level / free / price / source / search, facets, stats, sync trigger | `formaka/web.py` | | |
| 89 | +| **Frontend** | "Editorial sharp" design: Space Grotesk, offset shadows, amber accent, live ticker, mobile bottom sheet, 12-per-page pagination, installable PWA | `frontend/` | | |
| 90 | + | |
| 91 | +## The three fetch backends | |
| 92 | + | |
| 93 | +Each connector picks one (or chains them via the automatic `fetch_html()` fallback): | |
| 94 | + | |
| 95 | +1. **direct requests** — server-rendered sites (fast, free); | |
| 96 | +2. **Scrapfly** (`SCRAPFLY_API_KEY`) — robust backend: anti-bot bypass (`asp`), | |
| 97 | + JavaScript rendering (`render_js`), Canadian geolocation; | |
| 98 | +3. **Firecrawl** (`FIRECRAWL_API_KEY`) — fallback JS rendering, html/markdown formats. | |
| 99 | + | |
| 100 | +Detail pages are cached in the database (`detail_cache`) with a weekly key: | |
| 101 | +each page is revisited only when new, changed, or when the ISO week rolls over. | |
| 102 | + | |
| 103 | +## Aggregated sources | |
| 104 | + | |
| 105 | +| Institution | Offer | Programs | | |
| 106 | +|---|---|---| | |
| 107 | +| Université Laval — Distance | University courses (distance/hybrid) | ~1,760 | | |
| 108 | +| Technologia | Professional training — IT, AI, management | ~590 | | |
| 109 | +| Université TÉLUQ | University courses, 100 % online | ~510 | | |
| 110 | +| AFI by Edgenda | Professional training — IT, leadership | ~360 | | |
| 111 | +| ÉTS Formation | Continuing education + CEUs | ~300 | | |
| 112 | +| Versalys | Office tools, IT, languages | ~230 | | |
| 113 | +| McGill School of Continuing Studies | Continuing education (English) | ~200 | | |
| 114 | +| Isarta Formations | Marketing, communications, HR | ~190 | | |
| 115 | +| CRHA — Espace Formation | HR training and events | ~175 | | |
| 116 | +| Événements Les Affaires | Business conferences and webinars | ~135 | | |
| 117 | +| Cégep à distance | College courses at a distance | ~120 | | |
| 118 | +| HEC Montréal — École des dirigeant(e)s | Executive seminars and certifications | ~90 | | |
| 119 | +| ITHQ | Wine/food workshops + hospitality training | ~80 | | |
| 120 | +| École des entrepreneurs du Québec | Entrepreneur training (mostly free) | ~45 | | |
| 121 | +| Institut de leadership | Leadership certifications and programs | ~35 | | |
| 122 | +| AlphaNumérique | Free digital-literacy courses | ~30 | | |
| 123 | +| Formation continue UQAM | Continuing education + CEUs | ~50 | | |
| 124 | +| Le Wagon Montréal | Web dev / data / AI bootcamps | 4 | | |
| 125 | + | |
| 126 | +## Quick start | |
| 127 | + | |
| 128 | +```bash | |
| 129 | +git clone https://git.spboucher.ai/forma-ka.git && cd forma-ka | |
| 130 | + | |
| 131 | +# Backend | |
| 132 | +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt | |
| 133 | + | |
| 134 | +# Frontend | |
| 135 | +cd frontend && npm install && npm run build && cd .. | |
| 136 | + | |
| 137 | +# Scraping backend keys (JavaScript / anti-bot sites) | |
| 138 | +cat > .env <<EOF | |
| 139 | +FIRECRAWL_API_KEY=fc-your-key | |
| 140 | +SCRAPFLY_API_KEY=scp-live-your-key | |
| 141 | +EOF | |
| 142 | + | |
| 143 | +# Ingest, then serve | |
| 144 | +.venv/bin/python run.py sync # all sources (or: run.py sync ets_formation teluq) | |
| 145 | +.venv/bin/python run.py serve 8080 # API + frontend -> http://localhost:8080 | |
| 146 | +.venv/bin/python run.py watch 360 # sync loop (default: every 6 h) | |
| 147 | +``` | |
| 148 | + | |
| 149 | +## Adding a connector | |
| 150 | + | |
| 151 | +1. Create `formaka/connectors/<source_id>.py`: a class inheriting from | |
| 152 | + `BaseConnector`, define `source_id` and implement `fetch() -> list[Formation]`. | |
| 153 | + The registry is **auto-discovering** — nothing else to edit. | |
| 154 | +2. Add the matching entry to `data/sources.json`. | |
| 155 | +3. Test: `.venv/bin/python run.py sync <source_id>`. | |
| 156 | + | |
| 157 | +```python | |
| 158 | +class MySchoolConnector(BaseConnector): | |
| 159 | + source_id = "my_school" | |
| 160 | + | |
| 161 | + def fetch(self) -> list[Formation]: | |
| 162 | + html = self.fetch_html(LIST_URL) # direct -> Scrapfly -> Firecrawl | |
| 163 | + ... | |
| 164 | + return [Formation(source=self.source_id, external_id=..., url=..., | |
| 165 | + title=..., description=..., objectives=[...], ...)] | |
| 166 | +``` | |
| 167 | + | |
| 168 | +## API | |
| 169 | + | |
| 170 | +| Endpoint | Description | | |
| 171 | +|---|---| | |
| 172 | +| `GET /api/formations` | Filterable list (`training_type`, `category`, `mode`, `city`, `language`, `level`, `source`, `free`, `price_max`, `starts_after`, `q`, `sort`, `limit`, `offset`) | | |
| 173 | +| `GET /api/formations/{uid}` | Full program page + price history + similar programs | | |
| 174 | +| `GET /api/facets` | Distinct values for building filters | | |
| 175 | +| `GET /api/sources` | Institution registry + sync state | | |
| 176 | +| `GET /api/stats` | Global portrait + sync log | | |
| 177 | +| `POST /api/sync` | Trigger a background sync | | |
| 178 | + | |
| 179 | +## Tests | |
| 180 | + | |
| 181 | +```bash | |
| 182 | +.venv/bin/python -m pytest tests/ -q | |
| 183 | +``` | |
| 184 | + | |
| 185 | +--- | |
| 186 | + | |
| 187 | +<div align="center"> | |
| 188 | + | |
| 189 | +© 2026 **Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai) | |
| 190 | + | |
| 191 | +</div> | |
added
data/sources.json
+185 −0
@@ -0,0 +1,185 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Forma-Ka — Registre des sources (établissements de formation, province de Québec). Auteur : Simon-Pierre Boucher — contact@spboucher.ai", | |
| 3 | + "sources": [ | |
| 4 | + { | |
| 5 | + "id": "ets_formation", | |
| 6 | + "name": "ÉTS Formation", | |
| 7 | + "url": "https://www.perf.etsmtl.ca", | |
| 8 | + "listing_url": "https://www.perf.etsmtl.ca/Formations", | |
| 9 | + "type_offre": "Formation continue professionnelle — technologie, construction, gestion, RH (attestations + UEC)", | |
| 10 | + "connector": "ets_formation", | |
| 11 | + "status": "actif", | |
| 12 | + "region": "Montréal" | |
| 13 | + }, | |
| 14 | + { | |
| 15 | + "id": "teluq", | |
| 16 | + "name": "Université TÉLUQ", | |
| 17 | + "url": "https://www.teluq.ca", | |
| 18 | + "listing_url": "https://www.teluq.ca/etudes/cours", | |
| 19 | + "type_offre": "Cours universitaires 100 % à distance — tous cycles, ~500 cours crédités", | |
| 20 | + "connector": "teluq", | |
| 21 | + "status": "actif", | |
| 22 | + "region": "Québec (à distance)" | |
| 23 | + }, | |
| 24 | + { | |
| 25 | + "id": "ulaval_distance", | |
| 26 | + "name": "Université Laval — Formation à distance", | |
| 27 | + "url": "https://www.distance.ulaval.ca", | |
| 28 | + "listing_url": "https://www.distance.ulaval.ca/etudes/cours", | |
| 29 | + "type_offre": "Cours universitaires à distance, hybrides et comodaux (~1 750 cours crédités)", | |
| 30 | + "connector": "ulaval_distance", | |
| 31 | + "status": "actif", | |
| 32 | + "region": "Québec (à distance / hybride)" | |
| 33 | + }, | |
| 34 | + { | |
| 35 | + "id": "uqam_perfectionnement", | |
| 36 | + "name": "Formation continue UQAM", | |
| 37 | + "url": "https://formation.uqam.ca", | |
| 38 | + "listing_url": "https://formation.uqam.ca/formations/", | |
| 39 | + "type_offre": "Formation continue universitaire — UEC, séminaires et perfectionnement professionnel", | |
| 40 | + "connector": "uqam_perfectionnement", | |
| 41 | + "status": "actif", | |
| 42 | + "region": "Montréal" | |
| 43 | + }, | |
| 44 | + { | |
| 45 | + "id": "hec_dirigeants", | |
| 46 | + "name": "École des dirigeant(e)s HEC Montréal", | |
| 47 | + "url": "https://ecole-dirigeants.hec.ca", | |
| 48 | + "listing_url": "https://ecole-dirigeants.hec.ca/collections/all", | |
| 49 | + "type_offre": "Séminaires et certifications pour cadres et dirigeant·e·s", | |
| 50 | + "connector": "hec_dirigeants", | |
| 51 | + "status": "actif", | |
| 52 | + "region": "Montréal" | |
| 53 | + }, | |
| 54 | + { | |
| 55 | + "id": "mcgill_scs", | |
| 56 | + "name": "McGill School of Continuing Studies", | |
| 57 | + "url": "https://continuingstudies.mcgill.ca", | |
| 58 | + "listing_url": "https://continuingstudies.mcgill.ca/search/publicCourseAdvancedSearch.do", | |
| 59 | + "type_offre": "Formation continue universitaire (anglais) — professionnel, langues, CEU", | |
| 60 | + "connector": "mcgill_scs", | |
| 61 | + "status": "actif", | |
| 62 | + "region": "Montréal" | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + "id": "cegep_a_distance", | |
| 66 | + "name": "Cégep à distance", | |
| 67 | + "url": "https://cegepadistance.ca", | |
| 68 | + "listing_url": "https://guidedechoixdecours.cegepadistance.ca/cours/", | |
| 69 | + "type_offre": "Cours collégiaux à distance — unités, préalables, tous régimes", | |
| 70 | + "connector": "cegep_a_distance", | |
| 71 | + "status": "actif", | |
| 72 | + "region": "Québec (en ligne)" | |
| 73 | + }, | |
| 74 | + { | |
| 75 | + "id": "technologia", | |
| 76 | + "name": "Technologia", | |
| 77 | + "url": "https://www.technologia.com", | |
| 78 | + "listing_url": "https://www.technologia.com/formations", | |
| 79 | + "type_offre": "Formation continue professionnelle — TI, IA, gestion de projets, leadership", | |
| 80 | + "connector": "technologia", | |
| 81 | + "status": "actif", | |
| 82 | + "region": "Montréal / Québec" | |
| 83 | + }, | |
| 84 | + { | |
| 85 | + "id": "afi", | |
| 86 | + "name": "AFI par Edgenda", | |
| 87 | + "url": "https://www.afiexpertise.com", | |
| 88 | + "listing_url": "https://www.afiexpertise.com/fr/formations-professionnelles", | |
| 89 | + "type_offre": "Formation continue professionnelle — TI, bureautique, leadership, IA", | |
| 90 | + "connector": "afi", | |
| 91 | + "status": "actif", | |
| 92 | + "region": "Québec / Montréal" | |
| 93 | + }, | |
| 94 | + { | |
| 95 | + "id": "versalys", | |
| 96 | + "name": "Versalys", | |
| 97 | + "url": "https://www.versalys.com", | |
| 98 | + "listing_url": "https://www.versalys.com/formations/", | |
| 99 | + "type_offre": "Formation continue — bureautique, TI, langues, développement professionnel", | |
| 100 | + "connector": "versalys", | |
| 101 | + "status": "actif", | |
| 102 | + "region": "Montréal · Québec · Laval · Brossard" | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + "id": "isarta", | |
| 106 | + "name": "Isarta Formations", | |
| 107 | + "url": "https://formations.isarta.com", | |
| 108 | + "listing_url": "https://formations.isarta.com/", | |
| 109 | + "type_offre": "Formation continue — marketing, communication, médias sociaux, IA, RH", | |
| 110 | + "connector": "isarta", | |
| 111 | + "status": "actif", | |
| 112 | + "region": "Montréal (virtuel)" | |
| 113 | + }, | |
| 114 | + { | |
| 115 | + "id": "eeq", | |
| 116 | + "name": "École des entrepreneurs du Québec", | |
| 117 | + "url": "https://eequebec.com", | |
| 118 | + "listing_url": "https://eequebec.com/formations/", | |
| 119 | + "type_offre": "Formations pour entrepreneurs — majoritairement gratuites/subventionnées", | |
| 120 | + "connector": "eeq", | |
| 121 | + "status": "actif", | |
| 122 | + "region": "Montréal + en ligne" | |
| 123 | + }, | |
| 124 | + { | |
| 125 | + "id": "ithq", | |
| 126 | + "name": "ITHQ — Ateliers et formations", | |
| 127 | + "url": "https://www.ithq.qc.ca", | |
| 128 | + "listing_url": "https://www.ithq.qc.ca/ateliers-et-formation-continue/", | |
| 129 | + "type_offre": "Ateliers grand public (vins, cuisine) + formation continue tourisme/hôtellerie/restauration", | |
| 130 | + "connector": "ithq", | |
| 131 | + "status": "actif", | |
| 132 | + "region": "Montréal (+ province)" | |
| 133 | + }, | |
| 134 | + { | |
| 135 | + "id": "alphanumerique", | |
| 136 | + "name": "AlphaNumérique", | |
| 137 | + "url": "https://alphanumerique.ca", | |
| 138 | + "listing_url": "https://alphanumerique.ca/espace-public/cours-autonomes/", | |
| 139 | + "type_offre": "Cours autonomes de littératie numérique — 100 % gratuits", | |
| 140 | + "connector": "alphanumerique", | |
| 141 | + "status": "actif", | |
| 142 | + "region": "Québec (en ligne)" | |
| 143 | + }, | |
| 144 | + { | |
| 145 | + "id": "lesaffaires", | |
| 146 | + "name": "Événements Les Affaires", | |
| 147 | + "url": "https://evenements.lesaffaires.com", | |
| 148 | + "listing_url": "https://evenements.lesaffaires.com/collections/all", | |
| 149 | + "type_offre": "Conférences, formations et webinaires d'affaires — dates précises", | |
| 150 | + "connector": "lesaffaires", | |
| 151 | + "status": "actif", | |
| 152 | + "region": "Montréal / Québec / en ligne" | |
| 153 | + }, | |
| 154 | + { | |
| 155 | + "id": "crha", | |
| 156 | + "name": "CRHA — Espace Formation", | |
| 157 | + "url": "https://ordrecrha.org", | |
| 158 | + "listing_url": "https://carrefourrh.org/formation", | |
| 159 | + "type_offre": "Formations et événements en ressources humaines (heures de formation continue CRHA)", | |
| 160 | + "connector": "crha", | |
| 161 | + "status": "actif", | |
| 162 | + "region": "Québec (province) — surtout en ligne" | |
| 163 | + }, | |
| 164 | + { | |
| 165 | + "id": "institut_leadership", | |
| 166 | + "name": "Institut de leadership", | |
| 167 | + "url": "https://www.institutleadership.ca", | |
| 168 | + "listing_url": "https://www.institutleadership.ca/montreal/", | |
| 169 | + "type_offre": "Certifications, programmes et formations signatures en leadership", | |
| 170 | + "connector": "institut_leadership", | |
| 171 | + "status": "actif", | |
| 172 | + "region": "Montréal (+ cohortes en ligne)" | |
| 173 | + }, | |
| 174 | + { | |
| 175 | + "id": "lewagon_mtl", | |
| 176 | + "name": "Le Wagon Montréal", | |
| 177 | + "url": "https://www.lewagon.com/fr/montreal", | |
| 178 | + "listing_url": "https://www.lewagon.com/fr/montreal", | |
| 179 | + "type_offre": "Bootcamps développement web, IA, science des données (temps plein/partiel)", | |
| 180 | + "connector": "lewagon_mtl", | |
| 181 | + "status": "actif", | |
| 182 | + "region": "Montréal" | |
| 183 | + } | |
| 184 | + ] | |
| 185 | +} | |
added
docs/formation.png
+0 −0
Binary file not shown.
added
docs/home.png
+0 −0
Binary file not shown.
added
docs/stats.png
+0 −0
Binary file not shown.
added
formaka/__init__.py
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# ----------------------------------------------------------------------------- | |
| 5 | +"""Forma-Ka : toutes les formations du Québec — cours en ligne, séminaires, | |
| 6 | +cours universitaires et collégiaux, ateliers, certifications — un seul endroit.""" | |
| 7 | + | |
| 8 | +__version__ = "1.0.0" | |
added
formaka/connectors/__init__.py
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/__init__.py : registre AUTO-DÉCOUVRANT des connecteurs | |
| 5 | +# Tout module de ce paquet contenant une sous-classe de BaseConnector avec un | |
| 6 | +# source_id non vide est enregistré automatiquement — aucun fichier partagé | |
| 7 | +# à modifier pour ajouter un connecteur. | |
| 8 | +# ----------------------------------------------------------------------------- | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import importlib | |
| 12 | +import pkgutil | |
| 13 | +import sys | |
| 14 | + | |
| 15 | +from .base import BaseConnector | |
| 16 | + | |
| 17 | +CONNECTORS: dict[str, type[BaseConnector]] = {} | |
| 18 | + | |
| 19 | +for _mod in pkgutil.iter_modules(__path__): | |
| 20 | + if _mod.name in ("base", "__init__"): | |
| 21 | + continue | |
| 22 | + try: | |
| 23 | + module = importlib.import_module(f"{__name__}.{_mod.name}") | |
| 24 | + except Exception as exc: # un connecteur cassé ne bloque pas les autres | |
| 25 | + print(f"[forma-ka] connecteur '{_mod.name}' ignoré : {exc}", file=sys.stderr) | |
| 26 | + continue | |
| 27 | + for obj in vars(module).values(): | |
| 28 | + if (isinstance(obj, type) and issubclass(obj, BaseConnector) | |
| 29 | + and obj is not BaseConnector and getattr(obj, "source_id", "")): | |
| 30 | + CONNECTORS[obj.source_id] = obj | |
added
formaka/connectors/afi.py
+203 −0
@@ -0,0 +1,203 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/afi.py : connecteur AFI par Edgenda (afiexpertise.com) | |
| 5 | +# Firme de formation TI, bureautique et leadership (Québec, Montréal, | |
| 6 | +# classe virtuelle) — ~470 formations au calendrier public. | |
| 7 | +# Site Gatsby : chaque fiche possède un JSON statique complet à | |
| 8 | +# /page-data/fr/formation/<slug>/page-data.json — nom, description HTML, | |
| 9 | +# plan de cours en tableau (public concerné, certification, prérequis, | |
| 10 | +# objectifs, méthode pédagogique, contenu par modules), sessions datées | |
| 11 | +# avec villes et horaires, prix PAR JOUR (le prix affiché sur le site est | |
| 12 | +# RegularPrice × nb de jours, « Sur demande » quand aucune session), | |
| 13 | +# thématique, éditeur, formateurs. Liste découverte via sitemap-0.xml. | |
| 14 | +# Fiches en cache, rafraîchies chaque semaine (clé « AAAA-WSS »). | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import datetime | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from bs4 import BeautifulSoup | |
| 22 | + | |
| 23 | +from ..schema import Formation, clean_text | |
| 24 | +from .base import BaseConnector | |
| 25 | + | |
| 26 | +BASE = "https://www.afiexpertise.com" | |
| 27 | +SITEMAP_URL = f"{BASE}/sitemap-0.xml" | |
| 28 | + | |
| 29 | +_COURSE_LOC_RE = re.compile( | |
| 30 | + r"<loc>https://www\.afiexpertise\.com(/fr/formation/([^<]+))</loc>") | |
| 31 | + | |
| 32 | + | |
| 33 | +def _cell_text(td) -> str: | |
| 34 | + return clean_text(td.get_text(" ")) if td is not None else "" | |
| 35 | + | |
| 36 | + | |
| 37 | +def _cell_items(td) -> list[str]: | |
| 38 | + items = [clean_text(li.get_text(" ")) for li in td.find_all("li")] | |
| 39 | + return [i for i in dict.fromkeys(items) if i] | |
| 40 | + | |
| 41 | + | |
| 42 | +class AfiConnector(BaseConnector): | |
| 43 | + source_id = "afi" | |
| 44 | + request_delay = 0.5 | |
| 45 | + limit: int | None = None # borne optionnelle (tests/débogage) | |
| 46 | + | |
| 47 | + def fetch(self) -> list[Formation]: | |
| 48 | + # 1) Liste des fiches françaises depuis le sitemap | |
| 49 | + xml = self.get(SITEMAP_URL).text | |
| 50 | + slugs = list(dict.fromkeys(m.group(2).strip("/") | |
| 51 | + for m in _COURSE_LOC_RE.finditer(xml))) | |
| 52 | + if self.limit: | |
| 53 | + slugs = slugs[: self.limit] | |
| 54 | + | |
| 55 | + # 2) JSON statique Gatsby par formation — cache hebdomadaire | |
| 56 | + week = datetime.date.today().strftime("%G-W%V") | |
| 57 | + out: list[Formation] = [] | |
| 58 | + for slug in slugs: | |
| 59 | + payload = self.detail(slug, week, | |
| 60 | + lambda s=slug: self._fetch_detail(s)) | |
| 61 | + if not payload: | |
| 62 | + continue | |
| 63 | + f = Formation( | |
| 64 | + source=self.source_id, | |
| 65 | + external_id=slug, | |
| 66 | + url=f"{BASE}/fr/formation/{slug}", | |
| 67 | + training_type="Formation continue", | |
| 68 | + language="fr", | |
| 69 | + ) | |
| 70 | + for k, v in payload.items(): | |
| 71 | + if hasattr(f, k) and v not in (None, "", []): | |
| 72 | + setattr(f, k, v) | |
| 73 | + out.append(f) | |
| 74 | + return out | |
| 75 | + | |
| 76 | + # -- fiche ---------------------------------------------------------------- | |
| 77 | + def _fetch_detail(self, slug: str) -> dict: | |
| 78 | + url = f"{BASE}/page-data/fr/formation/{slug}/page-data.json" | |
| 79 | + try: | |
| 80 | + data = self.get(url).json() | |
| 81 | + except Exception: | |
| 82 | + return {} | |
| 83 | + result = (data.get("result") or {}) | |
| 84 | + course = ((result.get("data") or {}).get("course") or {}) | |
| 85 | + if not course: | |
| 86 | + return {} | |
| 87 | + payload: dict = {} | |
| 88 | + details: dict = {} | |
| 89 | + | |
| 90 | + payload["title"] = clean_text( | |
| 91 | + (course.get("Name") or {}).get("fr") or "") | |
| 92 | + desc = BeautifulSoup( | |
| 93 | + (course.get("Description") or {}).get("fr") or "", "html.parser") | |
| 94 | + paragraphs = [clean_text(p.get_text(" ")) | |
| 95 | + for p in desc.find_all(["p", "li"])] | |
| 96 | + payload["description"] = "\n\n".join( | |
| 97 | + dict.fromkeys(p for p in paragraphs if p)) or clean_text( | |
| 98 | + desc.get_text(" ")) | |
| 99 | + if not payload["description"]: | |
| 100 | + payload["description"] = clean_text( | |
| 101 | + (course.get("MetaDescription") or {}).get("fr") or "") | |
| 102 | + | |
| 103 | + # plan de cours : tableau à deux colonnes (libellé -> contenu) | |
| 104 | + plan = BeautifulSoup((course.get("Plan") or {}).get("fr") or "", | |
| 105 | + "html.parser") | |
| 106 | + program: list[str] = [] | |
| 107 | + for tr in plan.find_all("tr"): | |
| 108 | + tds = tr.find_all("td") | |
| 109 | + if len(tds) < 2: | |
| 110 | + continue | |
| 111 | + label = clean_text(tds[0].get_text(" ")).lower() | |
| 112 | + cell = tds[1] | |
| 113 | + if "public" in label: | |
| 114 | + payload["audience"] = _cell_text(cell) | |
| 115 | + elif "prérequis" in label or "prealable" in label: | |
| 116 | + payload["prerequisites"] = _cell_text(cell) | |
| 117 | + elif "objectif" in label: | |
| 118 | + payload["objectives"] = _cell_items(cell) or \ | |
| 119 | + [t for t in (_cell_text(cell),) if t] | |
| 120 | + elif "certification" in label: | |
| 121 | + details["certification"] = _cell_text(cell) | |
| 122 | + elif "méthode" in label: | |
| 123 | + details["methode_pedagogique"] = _cell_text(cell) | |
| 124 | + elif "contenu" in label: | |
| 125 | + # titres de modules (<p>) quand ils structurent le contenu, | |
| 126 | + # sinon puces (<li>) | |
| 127 | + ptexts = [clean_text(p.get_text(" ")) | |
| 128 | + for p in cell.find_all("p")] | |
| 129 | + ptexts = [p for p in dict.fromkeys(ptexts) if p] | |
| 130 | + litexts = _cell_items(cell) | |
| 131 | + program = ptexts if len(ptexts) >= 2 else litexts or ptexts | |
| 132 | + if program: | |
| 133 | + payload["program"] = program | |
| 134 | + | |
| 135 | + # durée en jours + prix affiché = prix/jour × jours (min. 1 jour) | |
| 136 | + days = course.get("DurationInDays") | |
| 137 | + if days: | |
| 138 | + payload["duration"] = (f"{days:g} jour" | |
| 139 | + if days <= 1 else f"{days:g} jours") | |
| 140 | + payload["duration_hours"] = days * 7.0 | |
| 141 | + sessions_raw = course.get("Sessions") or [] | |
| 142 | + price = course.get("RegularPrice") | |
| 143 | + if price is not None and sessions_raw: | |
| 144 | + total = price * days if days and days >= 1 else price | |
| 145 | + payload["price"] = float(total) | |
| 146 | + payload["price_label"] = f"{total:g} $ + tx" | |
| 147 | + pref = course.get("PreferentialPrice") | |
| 148 | + if pref is not None: | |
| 149 | + details["prix_preferentiel"] = (pref * days | |
| 150 | + if days and days >= 1 else pref) | |
| 151 | + elif not sessions_raw: | |
| 152 | + payload["price_label"] = "Sur demande" | |
| 153 | + | |
| 154 | + # sessions datées (première journée de chaque cohorte) + villes | |
| 155 | + sessions, cities, langs = [], [], [] | |
| 156 | + for s in sessions_raw: | |
| 157 | + dates = s.get("Dates") or [] | |
| 158 | + if dates: | |
| 159 | + start = str(dates[0])[:10] | |
| 160 | + if re.match(r"20\d{2}-\d{2}-\d{2}", start): | |
| 161 | + sessions.append(start) | |
| 162 | + if s.get("City"): | |
| 163 | + cities.append(s["City"]) | |
| 164 | + if s.get("Language"): | |
| 165 | + langs.append(s["Language"]) | |
| 166 | + sessions = sorted(set(sessions)) | |
| 167 | + if sessions: | |
| 168 | + payload["sessions"] = sessions | |
| 169 | + payload["start_date"] = sessions[0] | |
| 170 | + cities = list(dict.fromkeys(cities)) | |
| 171 | + modes = ["en ligne" if c.lower() == "classe virtuelle" else "présentiel" | |
| 172 | + for c in cities] | |
| 173 | + modes = list(dict.fromkeys(modes)) | |
| 174 | + if not modes and course.get("IsVirtual"): | |
| 175 | + modes = ["en ligne"] | |
| 176 | + if modes: | |
| 177 | + payload["mode"] = modes[0] if len(modes) == 1 else "hybride" | |
| 178 | + details["modes_offerts"] = modes | |
| 179 | + real_cities = [c for c in cities if c.lower() != "classe virtuelle"] | |
| 180 | + if real_cities: | |
| 181 | + payload["city"] = real_cities[0] | |
| 182 | + if langs: | |
| 183 | + payload["language"] = "/".join(dict.fromkeys(langs)) | |
| 184 | + | |
| 185 | + # contexte de page : thématique (catégorie) et éditeur (Microsoft…) | |
| 186 | + ctx = result.get("pageContext") or {} | |
| 187 | + theme = ((ctx.get("theme") or {}).get("name") or {}).get("fr", "") | |
| 188 | + if theme: | |
| 189 | + payload["category"] = theme | |
| 190 | + editor = ((ctx.get("editor") or {}).get("name") or {}).get("fr", "") | |
| 191 | + if editor: | |
| 192 | + payload["tags"] = [editor] | |
| 193 | + | |
| 194 | + teachers = [clean_text(f"{t.get('FirstName', '')} {t.get('LastName', '')}") | |
| 195 | + for t in course.get("Teachers") or []] | |
| 196 | + teachers = [t for t in dict.fromkeys(teachers) if t] | |
| 197 | + if teachers: | |
| 198 | + payload["instructor"] = ", ".join(teachers) | |
| 199 | + if course.get("IsNew"): | |
| 200 | + details["nouveau"] = True | |
| 201 | + if details: | |
| 202 | + payload["details"] = details | |
| 203 | + return payload | |
added
formaka/connectors/alphanumerique.py
+155 −0
@@ -0,0 +1,155 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/alphanumerique.py : connecteur AlphaNumérique (alphanumerique.ca) | |
| 5 | +# Initiative de littératie numérique pilotée par des bibliothèques publiques | |
| 6 | +# québécoises — cours autonomes en ligne 100 % GRATUITS pour le grand public | |
| 7 | +# (bases de l'informatique, courriel, sécurité en ligne, fausses nouvelles, | |
| 8 | +# tablettes, intelligence artificielle…). | |
| 9 | +# Site WordPress rendu serveur : | |
| 10 | +# - liste : /espace-public/cours-autonomes/ — cartes regroupées par | |
| 11 | +# thématique (thème, niveau, titre, visuel) | |
| 12 | +# - fiche : page Elementor avec description, liste « Ce cours vous | |
| 13 | +# permettra de… » (objectifs) et vidéo du cours. | |
| 14 | +# Fiches en cache, rafraîchies chaque semaine (clé ISO). | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import datetime | |
| 19 | +import re | |
| 20 | +from urllib.parse import urljoin | |
| 21 | + | |
| 22 | +from bs4 import BeautifulSoup | |
| 23 | + | |
| 24 | +from ..schema import Formation, clean_text | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +BASE = "https://alphanumerique.ca" | |
| 28 | +LIST_URL = f"{BASE}/espace-public/cours-autonomes/" | |
| 29 | + | |
| 30 | +_SLUG_RE = re.compile(r"/ressources/([^/]+)/?") | |
| 31 | +# marqueurs de fin de la zone descriptive d'une fiche | |
| 32 | +_STOP_RE = re.compile(r"cliquer sur la vid[ée]o|cliquez sur le lien ci-dessous|" | |
| 33 | + r"ressources compl[ée]mentaires|[ée]valuer nos ressources", | |
| 34 | + re.I) | |
| 35 | + | |
| 36 | + | |
| 37 | +class AlphanumeriqueConnector(BaseConnector): | |
| 38 | + source_id = "alphanumerique" | |
| 39 | + request_delay = 0.5 | |
| 40 | + | |
| 41 | + def fetch(self) -> list[Formation]: | |
| 42 | + html = self.fetch_html(LIST_URL) | |
| 43 | + cards = self._parse_listing(html) | |
| 44 | + | |
| 45 | + # fiche détaillée par cours — cache hebdomadaire | |
| 46 | + week = datetime.date.today().strftime("%G-W%V") | |
| 47 | + out: list[Formation] = [] | |
| 48 | + for slug, card in cards.items(): | |
| 49 | + key = f"{week}:{card['title']}:{card['level']}" | |
| 50 | + payload = self.detail(slug, key, | |
| 51 | + lambda c=card: self._fetch_detail(c)) | |
| 52 | + f = Formation( | |
| 53 | + source=self.source_id, | |
| 54 | + external_id=slug, | |
| 55 | + url=card["url"], | |
| 56 | + title=card["title"], | |
| 57 | + training_type="Cours en ligne", | |
| 58 | + category=card["theme"], | |
| 59 | + mode="asynchrone", | |
| 60 | + language="fr", | |
| 61 | + price=0.0, | |
| 62 | + price_label="Gratuit", | |
| 63 | + is_free=True, | |
| 64 | + level=card["level"].lower(), | |
| 65 | + audience="Grand public", | |
| 66 | + tags=["littératie numérique"], | |
| 67 | + images=card["images"], | |
| 68 | + ) | |
| 69 | + for k, v in (payload or {}).items(): | |
| 70 | + if hasattr(f, k) and v not in (None, "", []): | |
| 71 | + setattr(f, k, v) | |
| 72 | + out.append(f) | |
| 73 | + return out | |
| 74 | + | |
| 75 | + # -- liste ------------------------------------------------------------------ | |
| 76 | + def _parse_listing(self, html: str) -> dict[str, dict]: | |
| 77 | + """Cartes de cours autonomes, indexées par slug (dédupliquées).""" | |
| 78 | + soup = BeautifulSoup(html, "html.parser") | |
| 79 | + cards: dict[str, dict] = {} | |
| 80 | + for box in soup.find_all("div", class_="ressources-container"): | |
| 81 | + a = box.find("a", href=_SLUG_RE) | |
| 82 | + if a is None: | |
| 83 | + continue | |
| 84 | + url = urljoin(BASE, a["href"]) | |
| 85 | + slug = _SLUG_RE.search(url).group(1) | |
| 86 | + if slug in cards: | |
| 87 | + continue | |
| 88 | + terms = [clean_text(t.get_text(" ")) | |
| 89 | + for t in box.find_all("div", class_="term")] | |
| 90 | + title = box.find("div", class_="title") | |
| 91 | + img = box.find("img") | |
| 92 | + src = (img.get("data-lazy-src") or img.get("src") or "") if img else "" | |
| 93 | + cards[slug] = { | |
| 94 | + "url": url, | |
| 95 | + "title": clean_text(title.get_text(" ")) if title else slug, | |
| 96 | + "theme": terms[0] if terms else "", | |
| 97 | + "level": terms[1] if len(terms) > 1 else "", | |
| 98 | + "images": [src] if src.startswith("http") else [], | |
| 99 | + } | |
| 100 | + return cards | |
| 101 | + | |
| 102 | + # -- fiche -------------------------------------------------------------------- | |
| 103 | + def _fetch_detail(self, card: dict) -> dict: | |
| 104 | + if not card.get("url"): | |
| 105 | + return {} | |
| 106 | + html = self.fetch_html(card["url"]) | |
| 107 | + soup = BeautifulSoup(html, "html.parser") | |
| 108 | + main = (soup.find(class_="elementor-location-single") | |
| 109 | + or soup.find("main") or soup.body) | |
| 110 | + if main is None: | |
| 111 | + return {} | |
| 112 | + payload: dict = {} | |
| 113 | + | |
| 114 | + # textes déjà connus (titre, thème, niveau) à exclure de la description | |
| 115 | + title = main.find(class_="elementor-heading-title") | |
| 116 | + skip = {clean_text(title.get_text(" ")) if title else "", | |
| 117 | + card.get("title", ""), card.get("theme", ""), | |
| 118 | + card.get("level", "")} | |
| 119 | + | |
| 120 | + desc_parts: list[str] = [] | |
| 121 | + objectives: list[str] = [] | |
| 122 | + program: list[str] = [] | |
| 123 | + for el in main.find_all(["p", "ul"]): | |
| 124 | + text = clean_text(el.get_text(" ")) | |
| 125 | + if not text or text in skip: | |
| 126 | + continue | |
| 127 | + if _STOP_RE.search(text): | |
| 128 | + break | |
| 129 | + if el.name == "ul": | |
| 130 | + items = [t for t in (clean_text(li.get_text(" ")) | |
| 131 | + for li in el.find_all("li")) | |
| 132 | + if t and t not in skip] | |
| 133 | + if not items: | |
| 134 | + continue | |
| 135 | + lead = desc_parts[-1] if desc_parts else "" | |
| 136 | + # « Ce cours vous permettra de… » = objectifs | |
| 137 | + if not objectives and re.search( | |
| 138 | + r"permettra|apprendrez|d[ée]couvrirez", lead, re.I): | |
| 139 | + objectives = items | |
| 140 | + desc_parts.pop() # phrase d'amorce retirée | |
| 141 | + # « …les grandes questions qui seront abordées » = plan du cours | |
| 142 | + elif not program and re.search(r"abord[ée]e?s?\b", lead, re.I): | |
| 143 | + program = items | |
| 144 | + else: | |
| 145 | + desc_parts += items | |
| 146 | + else: | |
| 147 | + desc_parts.append(text) | |
| 148 | + | |
| 149 | + if desc_parts: | |
| 150 | + payload["description"] = "\n\n".join(desc_parts) | |
| 151 | + if objectives: | |
| 152 | + payload["objectives"] = objectives | |
| 153 | + if program: | |
| 154 | + payload["program"] = program | |
| 155 | + return payload | |
added
formaka/connectors/base.py
+203 −0
@@ -0,0 +1,203 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/base.py : classe de base des connecteurs + backends de fetch | |
| 5 | +# 1. requests direct (sites rendus serveur) | |
| 6 | +# 2. Scrapfly (asp=anti-bot bypass, render_js) — backend robuste privilégié | |
| 7 | +# 3. Firecrawl (rendu JavaScript) — solution de repli / sites SPA | |
| 8 | +# ----------------------------------------------------------------------------- | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import json | |
| 12 | +import os | |
| 13 | +import re | |
| 14 | +import time | |
| 15 | +from urllib.parse import urlencode | |
| 16 | + | |
| 17 | +import requests | |
| 18 | + | |
| 19 | +from ..schema import Formation | |
| 20 | + | |
| 21 | +_LDJSON_RE = re.compile( | |
| 22 | + r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S | re.I) | |
| 23 | + | |
| 24 | + | |
| 25 | +def ldjson_objects(html: str) -> list[dict]: | |
| 26 | + """Extrait tous les objets JSON-LD d'une page (schema.org Course, etc.). | |
| 27 | + | |
| 28 | + Tolère les blocs contenant plusieurs objets concaténés (sans virgule), | |
| 29 | + fréquents sur les sites ASP.NET. | |
| 30 | + """ | |
| 31 | + dec = json.JSONDecoder() | |
| 32 | + out: list[dict] = [] | |
| 33 | + for m in _LDJSON_RE.finditer(html): | |
| 34 | + blob = m.group(1).strip() | |
| 35 | + i = 0 | |
| 36 | + while i < len(blob): | |
| 37 | + try: | |
| 38 | + obj, j = dec.raw_decode(blob, i) | |
| 39 | + if isinstance(obj, dict): | |
| 40 | + if "@graph" in obj: | |
| 41 | + out.extend(g for g in obj["@graph"] if isinstance(g, dict)) | |
| 42 | + else: | |
| 43 | + out.append(obj) | |
| 44 | + elif isinstance(obj, list): | |
| 45 | + out.extend(o for o in obj if isinstance(o, dict)) | |
| 46 | + i = j | |
| 47 | + while i < len(blob) and blob[i] in " \r\n\t,": | |
| 48 | + i += 1 | |
| 49 | + except ValueError: | |
| 50 | + i += 1 | |
| 51 | + return out | |
| 52 | + | |
| 53 | +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " | |
| 54 | + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 " | |
| 55 | + "FormaKaBot/1.0 (+https://www.forma-ka.com/bot; contact@spboucher.ai)") | |
| 56 | + | |
| 57 | +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape" | |
| 58 | +SCRAPFLY_API = "https://api.scrapfly.io/scrape" | |
| 59 | + | |
| 60 | + | |
| 61 | +class BaseConnector: | |
| 62 | + """Un connecteur = un adaptateur propre à un site de formation. | |
| 63 | + | |
| 64 | + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne | |
| 65 | + la liste complète des formations actuellement affichées sur le site. | |
| 66 | + Le pipeline (ingest.py) s'occupe du diff avec la base de données. | |
| 67 | + """ | |
| 68 | + | |
| 69 | + source_id: str = "" | |
| 70 | + request_delay: float = 0.6 # politesse entre requêtes | |
| 71 | + timeout: int = 30 | |
| 72 | + use_detail_cache: bool = True # cache BD des pages détail | |
| 73 | + | |
| 74 | + def __init__(self) -> None: | |
| 75 | + self.session = requests.Session() | |
| 76 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 77 | + self._last_request = 0.0 | |
| 78 | + self._detail_con = None | |
| 79 | + | |
| 80 | + # -- backends ------------------------------------------------------------- | |
| 81 | + def get(self, url: str, **kw) -> requests.Response: | |
| 82 | + """GET direct avec throttling poli.""" | |
| 83 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 84 | + if wait > 0: | |
| 85 | + time.sleep(wait) | |
| 86 | + resp = self.session.get(url, timeout=self.timeout, **kw) | |
| 87 | + self._last_request = time.time() | |
| 88 | + resp.raise_for_status() | |
| 89 | + return resp | |
| 90 | + | |
| 91 | + def post(self, url: str, **kw) -> requests.Response: | |
| 92 | + """POST direct avec throttling poli (APIs JSON internes).""" | |
| 93 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 94 | + if wait > 0: | |
| 95 | + time.sleep(wait) | |
| 96 | + resp = self.session.post(url, timeout=self.timeout, **kw) | |
| 97 | + self._last_request = time.time() | |
| 98 | + resp.raise_for_status() | |
| 99 | + return resp | |
| 100 | + | |
| 101 | + def get_scrapfly(self, url: str, render_js: bool = False, | |
| 102 | + asp: bool = True, country: str = "ca", | |
| 103 | + wait_for: str | None = None) -> str: | |
| 104 | + """Récupère le HTML via Scrapfly — backend robuste (anti-bot bypass). | |
| 105 | + | |
| 106 | + Nécessite SCRAPFLY_API_KEY dans l'environnement (.env). | |
| 107 | + `asp=True` active l'Anti Scraping Protection bypass (Cloudflare…), | |
| 108 | + `render_js=True` exécute le JavaScript (navigateur headless), | |
| 109 | + `wait_for` attend un sélecteur CSS avant de capturer le HTML. | |
| 110 | + """ | |
| 111 | + key = os.environ.get("SCRAPFLY_API_KEY") | |
| 112 | + if not key: | |
| 113 | + raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)") | |
| 114 | + params: dict = {"key": key, "url": url, "country": country} | |
| 115 | + if asp: | |
| 116 | + params["asp"] = "true" | |
| 117 | + if render_js: | |
| 118 | + params["render_js"] = "true" | |
| 119 | + if wait_for: | |
| 120 | + params["render_js"] = "true" | |
| 121 | + params["wait_for_selector"] = wait_for | |
| 122 | + resp = requests.get(f"{SCRAPFLY_API}?{urlencode(params)}", timeout=120) | |
| 123 | + resp.raise_for_status() | |
| 124 | + data = resp.json() | |
| 125 | + result = data.get("result") or {} | |
| 126 | + status = result.get("status_code") | |
| 127 | + if status and int(status) >= 400: | |
| 128 | + raise RuntimeError(f"Scrapfly: la cible a répondu {status} pour {url}") | |
| 129 | + return result.get("content", "") | |
| 130 | + | |
| 131 | + def get_firecrawl(self, url: str, formats: list[str] | None = None) -> dict: | |
| 132 | + """Récupère la page via Firecrawl (rendu JavaScript). | |
| 133 | + | |
| 134 | + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env). | |
| 135 | + Retourne le dict `data` de Firecrawl ({"html": …, "markdown": …}). | |
| 136 | + """ | |
| 137 | + key = os.environ.get("FIRECRAWL_API_KEY") | |
| 138 | + if not key: | |
| 139 | + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)") | |
| 140 | + resp = requests.post( | |
| 141 | + FIRECRAWL_API, | |
| 142 | + json={"url": url, "formats": formats or ["html"]}, | |
| 143 | + headers={"Authorization": f"Bearer {key}"}, | |
| 144 | + timeout=90, | |
| 145 | + ) | |
| 146 | + resp.raise_for_status() | |
| 147 | + return resp.json().get("data") or {} | |
| 148 | + | |
| 149 | + def get_rendered(self, url: str) -> str: | |
| 150 | + """HTML rendu (JavaScript exécuté) — Scrapfly d'abord (plus robuste), | |
| 151 | + Firecrawl en repli. À utiliser pour les sites SPA / derrière Cloudflare. | |
| 152 | + """ | |
| 153 | + try: | |
| 154 | + html = self.get_scrapfly(url, render_js=True) | |
| 155 | + if html: | |
| 156 | + return html | |
| 157 | + except Exception: | |
| 158 | + pass | |
| 159 | + return self.get_firecrawl(url).get("html", "") | |
| 160 | + | |
| 161 | + def fetch_html(self, url: str) -> str: | |
| 162 | + """HTML d'une page « normale » avec repli automatique : | |
| 163 | + requests direct -> Scrapfly (asp) -> Firecrawl. | |
| 164 | + """ | |
| 165 | + try: | |
| 166 | + resp = self.get(url) | |
| 167 | + if resp.status_code == 200 and len(resp.text) > 500: | |
| 168 | + return resp.text | |
| 169 | + except Exception: | |
| 170 | + pass | |
| 171 | + try: | |
| 172 | + html = self.get_scrapfly(url) | |
| 173 | + if html: | |
| 174 | + return html | |
| 175 | + except Exception: | |
| 176 | + pass | |
| 177 | + return self.get_firecrawl(url).get("html", "") | |
| 178 | + | |
| 179 | + def detail(self, external_id: str, key: str, fetch_fn) -> dict: | |
| 180 | + """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si | |
| 181 | + la formation est nouvelle ou si sa clé (hash du contenu liste) a changé. | |
| 182 | + | |
| 183 | + Permet d'extraire les champs riches (description, objectifs, plan de | |
| 184 | + cours, préalables…) sans revisiter chaque page à chaque synchronisation. | |
| 185 | + `fetch_fn` doit retourner un dict JSON-sérialisable. | |
| 186 | + """ | |
| 187 | + if not self.use_detail_cache: | |
| 188 | + return fetch_fn() or {} | |
| 189 | + from .. import db | |
| 190 | + if self._detail_con is None: | |
| 191 | + self._detail_con = db.connect() | |
| 192 | + cached = db.get_cached_detail(self._detail_con, self.source_id, | |
| 193 | + str(external_id), key) | |
| 194 | + if cached is not None: | |
| 195 | + return cached | |
| 196 | + payload = fetch_fn() or {} | |
| 197 | + db.put_cached_detail(self._detail_con, self.source_id, | |
| 198 | + str(external_id), key, payload) | |
| 199 | + return payload | |
| 200 | + | |
| 201 | + # -- contrat -------------------------------------------------------------- | |
| 202 | + def fetch(self) -> list[Formation]: | |
| 203 | + raise NotImplementedError | |
added
formaka/connectors/cegep_a_distance.py
+224 −0
@@ -0,0 +1,224 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/cegep_a_distance.py : connecteur Cégep à distance | |
| 5 | +# Guide de choix de cours du Cégep à distance (cegepadistance.ca) — ~150 | |
| 6 | +# cours collégiaux offerts à distance (formation générale et spécifique). | |
| 7 | +# Site WordPress rendu serveur (guidedechoixdecours.cegepadistance.ca) : | |
| 8 | +# - liste : /cours/ — TOUTES les cartes sur une seule page (préalables, | |
| 9 | +# heures/pondération, unités, compétences, option), regroupées par sigle | |
| 10 | +# (un même cours existe en plusieurs « options » : Internet, papier…) | |
| 11 | +# - fiche : sections titrées (Description, Infos avec compétences complètes, | |
| 12 | +# Option, Particularités et matériel, Droits de scolarité et prix du | |
| 13 | +# matériel). Fiches en cache, rafraîchies chaque semaine (clé ISO). | |
| 14 | +# Prix affiché = droits de scolarité + matériel (cours régulier). | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import datetime | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from bs4 import BeautifulSoup | |
| 22 | + | |
| 23 | +from ..schema import Formation, clean_text, parse_price | |
| 24 | +from .base import BaseConnector | |
| 25 | + | |
| 26 | +BASE = "https://guidedechoixdecours.cegepadistance.ca" | |
| 27 | +LIST_URL = f"{BASE}/cours/" | |
| 28 | + | |
| 29 | +# libellés des régimes de frais -> clés du dict details["frais"] | |
| 30 | +_FEE_LABELS = (("Droits de scolarité", "droits_scolarite"), | |
| 31 | + ("Matériel", "materiel"), | |
| 32 | + ("Total", "total")) | |
| 33 | + | |
| 34 | + | |
| 35 | +def _card_field(card, label_rx: str) -> str: | |
| 36 | + """Valeur d'un champ « <strong>Label :</strong> valeur » d'une carte.""" | |
| 37 | + strong = card.find("strong", string=re.compile(label_rx, re.I)) | |
| 38 | + if strong is None: | |
| 39 | + return "" | |
| 40 | + parent = strong.parent | |
| 41 | + txt = clean_text(parent.get_text(" ")) | |
| 42 | + return clean_text(re.sub(r"^[^:]*:?", "", txt, count=1)) | |
| 43 | + | |
| 44 | + | |
| 45 | +def _section_content(soup: BeautifulSoup, heading_rx: str): | |
| 46 | + """Bloc « content » qui suit un titre de section (h2) de la fiche.""" | |
| 47 | + h = soup.find(lambda t: t.name == "h2" | |
| 48 | + and re.search(heading_rx, t.get_text(" ", strip=True), re.I)) | |
| 49 | + if h is None: | |
| 50 | + return None | |
| 51 | + return h.find_next(lambda t: t.name == "div" | |
| 52 | + and "content" in (t.get("class") or [])) | |
| 53 | + | |
| 54 | + | |
| 55 | +class CegepADistanceConnector(BaseConnector): | |
| 56 | + source_id = "cegep_a_distance" | |
| 57 | + request_delay = 0.5 | |
| 58 | + | |
| 59 | + def fetch(self) -> list[Formation]: | |
| 60 | + html = self.fetch_html(LIST_URL) | |
| 61 | + courses = self._parse_listing(html) | |
| 62 | + | |
| 63 | + # fiche détaillée par sigle — cache hebdomadaire | |
| 64 | + week = datetime.date.today().strftime("%G-W%V") | |
| 65 | + out: list[Formation] = [] | |
| 66 | + for sigle, info in courses.items(): | |
| 67 | + key = f"{week}:{'|'.join(info['options'])}:{info['hours']}" | |
| 68 | + payload = self.detail(sigle, key, | |
| 69 | + lambda u=info["url"]: self._fetch_detail(u)) | |
| 70 | + # cours listés à titre indicatif mais suivis dans un autre cégep | |
| 71 | + if re.search(r"n[’']est pas offert au C[ée]gep à distance", | |
| 72 | + payload.get("description", "")): | |
| 73 | + continue | |
| 74 | + f = Formation( | |
| 75 | + source=self.source_id, | |
| 76 | + external_id=sigle, | |
| 77 | + url=info["url"], | |
| 78 | + title=info["title"], | |
| 79 | + training_type="Cours collégial", | |
| 80 | + category=info["category"], | |
| 81 | + mode="en ligne", | |
| 82 | + language="fr", | |
| 83 | + code=sigle, | |
| 84 | + duration=info["hours"], | |
| 85 | + credits=info["credits"], | |
| 86 | + prerequisites=info["prerequisites"], | |
| 87 | + details={k: v for k, v in (("options", info["options"]), | |
| 88 | + ("ponderation", info["ponderation"]), | |
| 89 | + ("competences", info["competences"])) | |
| 90 | + if v}, | |
| 91 | + ) | |
| 92 | + for k, v in (payload or {}).items(): | |
| 93 | + if k == "details": | |
| 94 | + f.details = {**f.details, **v} | |
| 95 | + elif hasattr(f, k) and v not in (None, "", []): | |
| 96 | + setattr(f, k, v) | |
| 97 | + out.append(f) | |
| 98 | + return out | |
| 99 | + | |
| 100 | + # -- liste ------------------------------------------------------------------ | |
| 101 | + def _parse_listing(self, html: str) -> dict[str, dict]: | |
| 102 | + """Cartes de la liste regroupées par sigle (fusion des options).""" | |
| 103 | + soup = BeautifulSoup(html, "html.parser") | |
| 104 | + courses: dict[str, dict] = {} | |
| 105 | + for a in soup.select("div.course-details a.gutter[href]"): | |
| 106 | + name = a.find("span", class_="name") | |
| 107 | + code = a.find("span", class_="code") | |
| 108 | + if name is None or code is None: | |
| 109 | + continue | |
| 110 | + sigle = clean_text(code.get_text()) | |
| 111 | + prereq = _card_field(a, r"Pr[ée]alables") | |
| 112 | + hp = _card_field(a, r"Heures\s*/\s*Pond[ée]ration") | |
| 113 | + hours, _, ponderation = (p.strip() for p in hp.partition("/")) | |
| 114 | + unites = _card_field(a, r"Unit[ée]s") | |
| 115 | + option = _card_field(a, r"Option") | |
| 116 | + comp = _card_field(a, r"Comp[ée]tences") | |
| 117 | + info = courses.setdefault(sigle, { | |
| 118 | + "url": a["href"].rstrip("/") + "/", | |
| 119 | + "title": clean_text(name.get_text()), | |
| 120 | + "category": self._discipline(sigle), | |
| 121 | + "hours": hours, | |
| 122 | + "ponderation": ponderation, | |
| 123 | + "credits": (f"{unites} unités" if unites not in ("", "1") | |
| 124 | + else "1 unité") if unites else "", | |
| 125 | + "prerequisites": "" if re.match(r"aucun", prereq, re.I) else prereq, | |
| 126 | + "competences": comp.split() if comp else [], | |
| 127 | + "options": [], | |
| 128 | + }) | |
| 129 | + if option and option not in info["options"]: | |
| 130 | + info["options"].append(option) | |
| 131 | + return courses | |
| 132 | + | |
| 133 | + @staticmethod | |
| 134 | + def _discipline(sigle: str) -> str: | |
| 135 | + """Grande famille de cours d'après le préfixe du sigle ministériel.""" | |
| 136 | + prefix = sigle.split("-", 1)[0] | |
| 137 | + return { | |
| 138 | + "109": "Éducation physique", "201": "Mathématiques", | |
| 139 | + "202": "Chimie", "203": "Physique", "101": "Biologie", | |
| 140 | + "320": "Géographie", "330": "Histoire", "340": "Philosophie", | |
| 141 | + "350": "Psychologie", "360": "Sciences humaines — méthodologie", | |
| 142 | + "383": "Économie", "385": "Science politique", "387": "Sociologie", | |
| 143 | + "401": "Administration", "410": "Techniques administratives", | |
| 144 | + "412": "Bureautique", "420": "Informatique", | |
| 145 | + "504": "Arts", "601": "Français, langue et littérature", | |
| 146 | + "602": "Français, langue seconde", "603": "Anglais, langue et littérature", | |
| 147 | + "604": "Anglais, langue seconde", "607": "Espagnol", | |
| 148 | + "861": "Renforcement", "961": "Cheminement", | |
| 149 | + }.get(prefix, "") | |
| 150 | + | |
| 151 | + # -- fiche -------------------------------------------------------------------- | |
| 152 | + def _fetch_detail(self, url: str) -> dict: | |
| 153 | + if not url: | |
| 154 | + return {} | |
| 155 | + html = self.fetch_html(url) | |
| 156 | + soup = BeautifulSoup(html, "html.parser") | |
| 157 | + payload: dict = {} | |
| 158 | + details: dict = {} | |
| 159 | + | |
| 160 | + # description complète | |
| 161 | + div = _section_content(soup, r"^Description") | |
| 162 | + if div is not None: | |
| 163 | + payload["description"] = clean_text(div.get_text(" ")) | |
| 164 | + | |
| 165 | + # compétences ministérielles complètes (section Infos) -> objectifs | |
| 166 | + div = _section_content(soup, r"^Infos") | |
| 167 | + if div is not None: | |
| 168 | + b = div.find("b", string=re.compile(r"Comp[ée]tences", re.I)) | |
| 169 | + if b is not None: | |
| 170 | + comp = clean_text(b.parent.get_text(" ")) | |
| 171 | + comp = re.sub(r"^Comp[ée]tences\s*", "", comp) | |
| 172 | + objectives = [c.strip() for c in re.split( | |
| 173 | + r"(?=\b\d?[0-9A-Z]{3,4}\s*:)", comp) if c.strip()] | |
| 174 | + if objectives: | |
| 175 | + payload["objectives"] = objectives | |
| 176 | + | |
| 177 | + # option de diffusion (« 60 - Cours plurimédia, devoirs par Internet… ») | |
| 178 | + div = _section_content(soup, r"^Option") | |
| 179 | + if div is not None: | |
| 180 | + label = clean_text(div.get_text(" ")) | |
| 181 | + if label: | |
| 182 | + details["option_label"] = label | |
| 183 | + if re.search(r"anglais", label, re.I): | |
| 184 | + payload["language"] = "en" | |
| 185 | + | |
| 186 | + # particularités et matériel obligatoire | |
| 187 | + div = _section_content(soup, r"Particularit[ée]s") | |
| 188 | + if div is not None: | |
| 189 | + txt = clean_text(div.get_text(" ")) | |
| 190 | + if txt: | |
| 191 | + details["particularites"] = txt | |
| 192 | + | |
| 193 | + # droits de scolarité et prix du matériel (par régime d'études) | |
| 194 | + div = _section_content(soup, r"Droits de scolarit[ée]") | |
| 195 | + if div is not None: | |
| 196 | + frais: dict[str, dict] = {} | |
| 197 | + for p in div.find_all("p"): | |
| 198 | + b = p.find("b") | |
| 199 | + if b is None: | |
| 200 | + continue | |
| 201 | + regime = clean_text(b.get_text()) | |
| 202 | + txt = p.get_text(" ", strip=True) | |
| 203 | + entry: dict = {} | |
| 204 | + for label, key in _FEE_LABELS: | |
| 205 | + m = re.search(label + r"\s*:\s*([\d\s ,.]+\$)", txt) | |
| 206 | + if m: | |
| 207 | + val = parse_price(m.group(1)) | |
| 208 | + if val is not None: | |
| 209 | + entry[key] = val | |
| 210 | + if entry: | |
| 211 | + frais[regime] = entry | |
| 212 | + if frais: | |
| 213 | + details["frais"] = frais | |
| 214 | + regulier = frais.get("Cours régulier") or next(iter(frais.values())) | |
| 215 | + total = regulier.get("total") | |
| 216 | + if total is not None: | |
| 217 | + payload["price"] = total | |
| 218 | + payload["price_label"] = (f"{total:.2f} $ " | |
| 219 | + "(droits + matériel, cours régulier)" | |
| 220 | + ).replace(".", ",") | |
| 221 | + | |
| 222 | + if details: | |
| 223 | + payload["details"] = details | |
| 224 | + return payload | |
added
formaka/connectors/crha.py
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/crha.py : connecteur CRHA — Ordre des conseillers en ressources | |
| 5 | +# humaines agréés (ordrecrha.org) — développement professionnel / Espace | |
| 6 | +# Formation (~175 formations et événements RH). | |
| 7 | +# Le catalogue public est servi par carrefourrh.org (portail de contenu de | |
| 8 | +# l'Ordre) via une API JSON interne propre : | |
| 9 | +# - /Formation/GetLaraFormations : toutes les formations (nom, description | |
| 10 | +# courte, mots-clés, heures de formation continue par compétence, format, | |
| 11 | +# prochaine date, lien vers la fiche Espace Formation, code d'événement) | |
| 12 | +# - /Formation/GetFormats et /Formation/GetCategories : référentiels | |
| 13 | +# (id chiffré -> libellé) des formats et des domaines de compétence. | |
| 14 | +# Les fiches détail (formation.ordrecrha.org, LMS « Lära ») sont une SPA | |
| 15 | +# fermée qui ne rend rien de plus publiquement — la liste JSON suffit. | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import html as _html | |
| 20 | + | |
| 21 | +from ..schema import Formation, clean_text | |
| 22 | +from .base import BaseConnector | |
| 23 | + | |
| 24 | + | |
| 25 | +def _txt(raw: str | None) -> str: | |
| 26 | + """Nettoie un champ texte de l'API (entités HTML « » comprises).""" | |
| 27 | + return clean_text(_html.unescape(raw or "")) | |
| 28 | + | |
| 29 | +BASE = "https://carrefourrh.org" | |
| 30 | +LIST_URL = f"{BASE}/Formation/GetLaraFormations" | |
| 31 | +FORMATS_URL = f"{BASE}/Formation/GetFormats" | |
| 32 | +CATEGORIES_URL = f"{BASE}/Formation/GetCategories" | |
| 33 | + | |
| 34 | +# libellé de format Lära -> (type de formation, mode) canoniques Forma-Ka | |
| 35 | +_FORMAT_MAP = { | |
| 36 | + "événements": ("Conférence", ""), | |
| 37 | + "formation en classe virtuelle": ("Formation continue", "en ligne"), | |
| 38 | + "formation en ligne": ("Cours en ligne", "asynchrone"), | |
| 39 | + "formation en ligne - gratuite": ("Cours en ligne", "asynchrone"), | |
| 40 | + "formation en salle": ("Formation continue", "présentiel"), | |
| 41 | + "groupe de discussion ou de codéveloppement": ("Atelier", ""), | |
| 42 | +} | |
| 43 | + | |
| 44 | + | |
| 45 | +class CrhaConnector(BaseConnector): | |
| 46 | + source_id = "crha" | |
| 47 | + request_delay = 0.5 | |
| 48 | + | |
| 49 | + def fetch(self) -> list[Formation]: | |
| 50 | + # 1) référentiels : formats et domaines de compétence (id -> nom) | |
| 51 | + formats = {o["Id"]: o["Name"] for o in self.get(FORMATS_URL).json()} | |
| 52 | + categories = {o["Id"]: o["Name"] for o in self.get(CATEGORIES_URL).json()} | |
| 53 | + | |
| 54 | + # 2) catalogue complet | |
| 55 | + rows = self.get(LIST_URL).json() | |
| 56 | + | |
| 57 | + out: list[Formation] = [] | |
| 58 | + for row in rows: | |
| 59 | + if not isinstance(row, dict) or not row.get("Name"): | |
| 60 | + continue | |
| 61 | + code = (row.get("CustomFields") or {}).get("CodeEvent", "") | |
| 62 | + external_id = code or row.get("Id", "") | |
| 63 | + url = row.get("CatalogLink") or row.get("ExternalLink") or "" | |
| 64 | + if not external_id or not url: | |
| 65 | + continue | |
| 66 | + | |
| 67 | + f = Formation( | |
| 68 | + source=self.source_id, | |
| 69 | + external_id=str(external_id), | |
| 70 | + url=url, | |
| 71 | + title=_txt(row["Name"]), | |
| 72 | + language="fr", | |
| 73 | + code=code, | |
| 74 | + ) | |
| 75 | + | |
| 76 | + # format -> type + mode ; les « en ligne » autoportants sont | |
| 77 | + # asynchrones, la classe virtuelle est synchrone à distance | |
| 78 | + fmt_name = formats.get(row.get("Type", ""), "") | |
| 79 | + ttype, mode = _FORMAT_MAP.get(fmt_name.lower(), | |
| 80 | + ("Formation continue", "")) | |
| 81 | + f.training_type = ttype | |
| 82 | + f.mode = mode | |
| 83 | + if fmt_name: | |
| 84 | + f.details["format"] = fmt_name | |
| 85 | + if fmt_name.lower() == "formation en ligne - gratuite": | |
| 86 | + f.price = 0.0 | |
| 87 | + f.is_free = True | |
| 88 | + f.price_label = "Gratuit" | |
| 89 | + | |
| 90 | + # description : courte (la fiche LMS n'expose rien de plus) | |
| 91 | + f.description = _txt(row.get("Description", "")) \ | |
| 92 | + or _txt(row.get("ShortDescription", "")) | |
| 93 | + | |
| 94 | + # heures de formation continue par domaine de compétence | |
| 95 | + hours = 0.0 | |
| 96 | + names = [] | |
| 97 | + for cat in row.get("Categories") or []: | |
| 98 | + name = categories.get(cat.get("Id", ""), "") | |
| 99 | + if name: | |
| 100 | + names.append(name) | |
| 101 | + hours += float(cat.get("Credits") or 0) | |
| 102 | + if names: | |
| 103 | + f.category = names[0] | |
| 104 | + f.tags.extend(names) | |
| 105 | + if hours > 0: | |
| 106 | + f.duration_hours = hours | |
| 107 | + f.duration = f"{hours:g} h" | |
| 108 | + f.credits = (f"{hours:g} h de formation continue" | |
| 109 | + .replace(".", ",")) | |
| 110 | + f.credential = "Heures de formation continue CRHA" | |
| 111 | + | |
| 112 | + # prochaine occurrence : « 12 AOÛT 2026 » (parse via finalize) | |
| 113 | + occ = _txt(row.get("OccurenceDate", "")) | |
| 114 | + if occ: | |
| 115 | + f.schedule_label = occ | |
| 116 | + | |
| 117 | + # mots-clés éditoriaux | |
| 118 | + keywords = [_txt(k) for k in | |
| 119 | + (row.get("Keywords") or "").split(",")] | |
| 120 | + f.tags.extend(k for k in keywords if k) | |
| 121 | + f.tags.append("Ressources humaines") | |
| 122 | + | |
| 123 | + out.append(f) | |
| 124 | + return out | |
added
formaka/connectors/eeq.py
+193 −0
@@ -0,0 +1,193 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/eeq.py : connecteur École des entrepreneurs du Québec (eequebec.com) | |
| 5 | +# ~50 formations pour entrepreneurs (démarrage, croissance, finances, | |
| 6 | +# fiscalité…), majoritairement GRATUITES ou subventionnées. | |
| 7 | +# Site WordPress rendu serveur : | |
| 8 | +# - liste : /formations/?trainings_page=N (pagination) — cartes avec titre, | |
| 9 | +# image, nombre de modules, durée (« 2 heures », « 11 minutes »), prix | |
| 10 | +# (« Gratuit » ou montant) et type de parcours (activité / programme) | |
| 11 | +# - fiche : intro, accordéon « Description » (avec liste d'objectifs), | |
| 12 | +# boîte de métadonnées (Type, Coaching, Phase de la croissance, Prix, | |
| 13 | +# clientèle) et menu déroulant des prochaines dates. | |
| 14 | +# Fiches en cache, rafraîchies chaque semaine (clé ISO). | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import datetime | |
| 19 | +import re | |
| 20 | +from urllib.parse import urljoin | |
| 21 | + | |
| 22 | +from bs4 import BeautifulSoup | |
| 23 | + | |
| 24 | +from ..schema import Formation, clean_text, parse_date_fr | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +BASE = "https://eequebec.com" | |
| 28 | +LIST_URL = f"{BASE}/formations/?trainings_page={{page}}" | |
| 29 | +MAX_PAGES = 30 # garde-fou (6 pages à l'écriture) | |
| 30 | + | |
| 31 | +_SLUG_RE = re.compile(r"/formation/([^/]+)/?") | |
| 32 | + | |
| 33 | + | |
| 34 | +def _txt(node, cls: str) -> str: | |
| 35 | + el = node.find(class_=cls) if node else None | |
| 36 | + return clean_text(el.get_text(" ")) if el else "" | |
| 37 | + | |
| 38 | + | |
| 39 | +class EeqConnector(BaseConnector): | |
| 40 | + source_id = "eeq" | |
| 41 | + request_delay = 0.5 | |
| 42 | + | |
| 43 | + def fetch(self) -> list[Formation]: | |
| 44 | + cards = self._fetch_cards() | |
| 45 | + | |
| 46 | + # fiche détaillée par formation — cache hebdomadaire | |
| 47 | + week = datetime.date.today().strftime("%G-W%V") | |
| 48 | + out: list[Formation] = [] | |
| 49 | + for slug, card in cards.items(): | |
| 50 | + key = f"{week}:{card['price_label']}:{card['duration']}" | |
| 51 | + payload = self.detail(slug, key, | |
| 52 | + lambda u=card["url"]: self._fetch_detail(u)) | |
| 53 | + f = Formation( | |
| 54 | + source=self.source_id, | |
| 55 | + external_id=slug, | |
| 56 | + url=card["url"], | |
| 57 | + title=card["title"], | |
| 58 | + training_type="Formation continue", | |
| 59 | + category="Entrepreneuriat", | |
| 60 | + # les « programmes » sont des autoformations sur la plateforme | |
| 61 | + mode="en ligne" if card["approach"] == "programme" else "", | |
| 62 | + language="fr", | |
| 63 | + duration=card["duration"], | |
| 64 | + price_label=card["price_label"], | |
| 65 | + is_free=(True if re.search(r"gratuit", card["price_label"], re.I) | |
| 66 | + else None), | |
| 67 | + images=card["images"], | |
| 68 | + details={k: v for k, v in (("approche", card["approach"]), | |
| 69 | + ("modules", card["modules"]), | |
| 70 | + ("statut", card["status"])) | |
| 71 | + if v}, | |
| 72 | + ) | |
| 73 | + for k, v in (payload or {}).items(): | |
| 74 | + if k == "details": | |
| 75 | + f.details = {**f.details, **v} | |
| 76 | + elif hasattr(f, k) and v not in (None, "", []): | |
| 77 | + setattr(f, k, v) | |
| 78 | + out.append(f) | |
| 79 | + return out | |
| 80 | + | |
| 81 | + # -- liste ------------------------------------------------------------------ | |
| 82 | + def _fetch_cards(self) -> dict[str, dict]: | |
| 83 | + """Cartes de toutes les pages de la liste, indexées par slug.""" | |
| 84 | + cards: dict[str, dict] = {} | |
| 85 | + for page in range(1, MAX_PAGES + 1): | |
| 86 | + html = self.fetch_html(LIST_URL.format(page=page)) | |
| 87 | + soup = BeautifulSoup(html, "html.parser") | |
| 88 | + new = 0 | |
| 89 | + for item in soup.find_all(class_="training-item"): | |
| 90 | + a = item.find("a", class_="training-item__link-to-single", | |
| 91 | + href=True) | |
| 92 | + if a is None: | |
| 93 | + continue | |
| 94 | + url = urljoin(BASE, a["href"]) | |
| 95 | + m = _SLUG_RE.search(url) | |
| 96 | + if not m or m.group(1) in cards: | |
| 97 | + continue | |
| 98 | + img = item.find("img", src=True) | |
| 99 | + cards[m.group(1)] = { | |
| 100 | + "url": url, | |
| 101 | + "title": _txt(item, "training-item__title"), | |
| 102 | + "duration": _txt(item, "training-item__days__text"), | |
| 103 | + "price_label": _txt(item, "training-item__price__nb"), | |
| 104 | + "modules": _txt(item, "training-item__modules__nb"), | |
| 105 | + "approach": _txt(item, "training-item__approach-type__text"), | |
| 106 | + "status": _txt(item, "training-item__encours"), | |
| 107 | + "images": [img["src"]] if img and img["src"].startswith("http") | |
| 108 | + else [], | |
| 109 | + } | |
| 110 | + new += 1 | |
| 111 | + if new == 0: # dernière page atteinte | |
| 112 | + break | |
| 113 | + return cards | |
| 114 | + | |
| 115 | + # -- fiche -------------------------------------------------------------------- | |
| 116 | + def _fetch_detail(self, url: str) -> dict: | |
| 117 | + if not url: | |
| 118 | + return {} | |
| 119 | + html = self.fetch_html(url) | |
| 120 | + soup = BeautifulSoup(html, "html.parser") | |
| 121 | + payload: dict = {} | |
| 122 | + details: dict = {} | |
| 123 | + | |
| 124 | + # phrase d'introduction (« Une formation pour passer de l'idée à l'action! ») | |
| 125 | + intro = _txt(soup, "single-training__intro") | |
| 126 | + | |
| 127 | + # accordéons de description | |
| 128 | + desc_zone = soup.find(class_="single-training__descriptions") | |
| 129 | + for acc in (desc_zone.find_all("section", class_="accordion") | |
| 130 | + if desc_zone else []): | |
| 131 | + title = _txt(acc, "accordion__header__title") | |
| 132 | + body = acc.find(class_="accordion__sub-rows") | |
| 133 | + if body is None: | |
| 134 | + continue | |
| 135 | + if re.match(r"description", title, re.I): | |
| 136 | + # objectifs : liste à puces de l'accordéon Description | |
| 137 | + ul = body.find("ul") | |
| 138 | + if ul is not None: | |
| 139 | + payload["objectives"] = [clean_text(li.get_text(" ")) | |
| 140 | + for li in ul.find_all("li")] | |
| 141 | + ul.extract() | |
| 142 | + text = clean_text(body.get_text(" ")) | |
| 143 | + text = re.sub(r"\s*Objectifs?\s*:?\s*$", "", text) | |
| 144 | + if intro and text.startswith(intro[:60]): | |
| 145 | + intro = "" # intro déjà reprise dans le corps | |
| 146 | + payload["description"] = "\n\n".join( | |
| 147 | + t for t in (intro, text) if t) | |
| 148 | + elif re.match(r"table des mati|contenu|programme", title, re.I): | |
| 149 | + payload["program"] = [clean_text(li.get_text(" ")) | |
| 150 | + for li in body.find_all("li")] | |
| 151 | + if "description" not in payload and intro: | |
| 152 | + payload["description"] = intro | |
| 153 | + | |
| 154 | + # boîte de métadonnées (Type, Coaching, Phase, Prix, clientèle…) | |
| 155 | + for row in soup.find_all(class_="single-metas-box__detail-wrapper"): | |
| 156 | + label = _txt(row, "single-training__detail-text").rstrip(" :") | |
| 157 | + value = _txt(row, "single-training__detail-content") | |
| 158 | + if not label or not value: | |
| 159 | + continue | |
| 160 | + low = label.lower() | |
| 161 | + if low.startswith("type"): | |
| 162 | + payload["training_type"] = value | |
| 163 | + elif low.startswith("prix"): | |
| 164 | + payload["price_label"] = value | |
| 165 | + elif re.search(r"client[èe]le|public", low): | |
| 166 | + payload["audience"] = value | |
| 167 | + elif low.startswith("dur"): | |
| 168 | + payload["duration"] = value | |
| 169 | + else: | |
| 170 | + details[low.replace(" ", "_")] = value | |
| 171 | + | |
| 172 | + # prochaines dates offertes (<select> avec valeurs ISO) | |
| 173 | + drop = soup.find(class_="training-dropdown") | |
| 174 | + if drop is not None: | |
| 175 | + sessions = [] | |
| 176 | + for opt in drop.find_all("option"): | |
| 177 | + iso = parse_date_fr(str(opt.get("value", ""))[:10]) \ | |
| 178 | + or parse_date_fr(clean_text(opt.get_text(" "))) | |
| 179 | + if iso: | |
| 180 | + sessions.append(iso) | |
| 181 | + if sessions: | |
| 182 | + payload["sessions"] = sorted(set(sessions)) | |
| 183 | + payload["start_date"] = payload["sessions"][0] | |
| 184 | + | |
| 185 | + # visuel de la fiche | |
| 186 | + cover = soup.find(class_="single-training__cover") | |
| 187 | + img = cover.find("img", src=True) if cover else None | |
| 188 | + if img is not None and img["src"].startswith("http"): | |
| 189 | + payload["images"] = [img["src"]] | |
| 190 | + | |
| 191 | + if details: | |
| 192 | + payload["details"] = details | |
| 193 | + return payload | |
added
formaka/connectors/ets_formation.py
+201 −0
@@ -0,0 +1,201 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/ets_formation.py : connecteur ÉTS Formation (perf.etsmtl.ca) | |
| 5 | +# Formation continue de l'École de technologie supérieure — ~600 formations | |
| 6 | +# professionnelles (technologie, gestion, construction, RH…). | |
| 7 | +# Site ASP.NET rendu serveur, très bien balisé schema.org : | |
| 8 | +# - liste : ItemList JSON-LD (toutes les formations, dédupliquées par sigle) | |
| 9 | +# - fiche : Course JSON-LD (description, UEC, séances datées avec prix, | |
| 10 | +# mode Onsite/Online, charge PTxxH, formateur, lieu) + sections HTML | |
| 11 | +# (objectifs pédagogiques, contenu, clientèle visée) + catégorie du | |
| 12 | +# fil d'Ariane. Fiches en cache, rafraîchies chaque semaine (clé ISO). | |
| 13 | +# ----------------------------------------------------------------------------- | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import datetime | |
| 17 | +import re | |
| 18 | + | |
| 19 | +from bs4 import BeautifulSoup | |
| 20 | + | |
| 21 | +from ..schema import Formation, clean_text | |
| 22 | +from .base import BaseConnector, ldjson_objects | |
| 23 | + | |
| 24 | +BASE = "https://www.perf.etsmtl.ca" | |
| 25 | +LIST_URL = f"{BASE}/Formations" | |
| 26 | + | |
| 27 | +_WORKLOAD_RE = re.compile(r"PT(\d+(?:\.\d+)?)H", re.I) | |
| 28 | + | |
| 29 | +# eventAttendanceMode / courseMode -> mode canonique Forma-Ka | |
| 30 | +_MODE_MAP = { | |
| 31 | + "onsite": "présentiel", | |
| 32 | + "online": "en ligne", | |
| 33 | + "offlineeventattendancemode": "présentiel", | |
| 34 | + "onlineeventattendancemode": "en ligne", | |
| 35 | + "mixedeventattendancemode": "hybride", | |
| 36 | +} | |
| 37 | + | |
| 38 | + | |
| 39 | +def _instance_mode(inst: dict) -> str: | |
| 40 | + for raw in (str(inst.get("eventAttendanceMode", "")), | |
| 41 | + str(inst.get("courseMode", ""))): | |
| 42 | + key = raw.rsplit("/", 1)[-1].lower() | |
| 43 | + if key in _MODE_MAP: | |
| 44 | + return _MODE_MAP[key] | |
| 45 | + return "" | |
| 46 | + | |
| 47 | + | |
| 48 | +def _flatten(seq) -> list[str]: | |
| 49 | + """educationalCredentialAwarded arrive parfois en listes imbriquées.""" | |
| 50 | + out: list[str] = [] | |
| 51 | + if isinstance(seq, str): | |
| 52 | + return [seq] | |
| 53 | + for item in seq or []: | |
| 54 | + out.extend(_flatten(item) if isinstance(item, (list, tuple)) else [str(item)]) | |
| 55 | + return out | |
| 56 | + | |
| 57 | + | |
| 58 | +def _section_items(soup: BeautifulSoup, heading_rx: str) -> list[str]: | |
| 59 | + """Items de liste (<li>) qui suivent un titre de section donné.""" | |
| 60 | + h = soup.find(["h2", "h3", "h4"], | |
| 61 | + string=re.compile(heading_rx, re.I)) | |
| 62 | + if h is None: | |
| 63 | + return [] | |
| 64 | + items: list[str] = [] | |
| 65 | + for sib in h.find_all_next(["ul", "ol", "h2", "h3", "h4"], limit=8): | |
| 66 | + if sib.name in ("h2", "h3", "h4"): | |
| 67 | + break | |
| 68 | + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] | |
| 69 | + if items: | |
| 70 | + break | |
| 71 | + return [i for i in items if i] | |
| 72 | + | |
| 73 | + | |
| 74 | +class EtsFormationConnector(BaseConnector): | |
| 75 | + source_id = "ets_formation" | |
| 76 | + request_delay = 0.5 | |
| 77 | + | |
| 78 | + def fetch(self) -> list[Formation]: | |
| 79 | + html = self.fetch_html(LIST_URL) | |
| 80 | + | |
| 81 | + # 1) Liste complète depuis l'ItemList JSON-LD (contient des doublons) | |
| 82 | + courses: dict[str, dict] = {} | |
| 83 | + for obj in ldjson_objects(html): | |
| 84 | + if obj.get("@type") != "ItemList": | |
| 85 | + continue | |
| 86 | + for li in obj.get("itemListElement", []): | |
| 87 | + item = li.get("item") or {} | |
| 88 | + code = item.get("courseCode") or "" | |
| 89 | + if item.get("@type") == "Course" and code and code not in courses: | |
| 90 | + courses[code] = item | |
| 91 | + | |
| 92 | + # 2) Fiche détaillée par formation — cache hebdomadaire | |
| 93 | + week = datetime.date.today().strftime("%G-W%V") | |
| 94 | + out: list[Formation] = [] | |
| 95 | + for code, item in courses.items(): | |
| 96 | + url = item.get("url") or item.get("@id") or "" | |
| 97 | + key = f"{week}:{item.get('name', '')}" | |
| 98 | + payload = self.detail(code, key, lambda u=url: self._fetch_detail(u)) | |
| 99 | + f = Formation( | |
| 100 | + source=self.source_id, | |
| 101 | + external_id=code, | |
| 102 | + url=url, | |
| 103 | + title=item.get("name", ""), | |
| 104 | + training_type="Formation continue", | |
| 105 | + language="fr", | |
| 106 | + code=code, | |
| 107 | + ) | |
| 108 | + for k, v in (payload or {}).items(): | |
| 109 | + if hasattr(f, k) and v not in (None, "", []): | |
| 110 | + setattr(f, k, v) | |
| 111 | + out.append(f) | |
| 112 | + return out | |
| 113 | + | |
| 114 | + # -- fiche ---------------------------------------------------------------- | |
| 115 | + def _fetch_detail(self, url: str) -> dict: | |
| 116 | + if not url: | |
| 117 | + return {} | |
| 118 | + html = self.fetch_html(url) | |
| 119 | + soup = BeautifulSoup(html, "html.parser") | |
| 120 | + payload: dict = {} | |
| 121 | + | |
| 122 | + course = next((o for o in ldjson_objects(html) | |
| 123 | + if o.get("@type") == "Course"), None) | |
| 124 | + if course: | |
| 125 | + payload["description"] = clean_text(course.get("description", "")) | |
| 126 | + creds = _flatten(course.get("educationalCredentialAwarded")) | |
| 127 | + payload["credential"] = " · ".join(dict.fromkeys(creds)) | |
| 128 | + | |
| 129 | + sessions, cities, modes, instructors = [], [], [], [] | |
| 130 | + price = None | |
| 131 | + hours = None | |
| 132 | + for inst in course.get("hasCourseInstance", []) or []: | |
| 133 | + if not isinstance(inst, dict): | |
| 134 | + continue | |
| 135 | + start = str(inst.get("startDate", ""))[:10] | |
| 136 | + if re.match(r"20\d{2}-\d{2}-\d{2}", start): | |
| 137 | + sessions.append(start) | |
| 138 | + mode = _instance_mode(inst) | |
| 139 | + if mode: | |
| 140 | + modes.append(mode) | |
| 141 | + loc = ((inst.get("location") or {}).get("address") or {}) | |
| 142 | + city = loc.get("addressLocality", "") | |
| 143 | + if city: | |
| 144 | + cities.append(city) | |
| 145 | + offer = inst.get("offers") or {} | |
| 146 | + if isinstance(offer, list): | |
| 147 | + offer = offer[0] if offer else {} | |
| 148 | + if price is None and offer.get("price"): | |
| 149 | + try: | |
| 150 | + price = float(str(offer["price"]).replace(",", ".")) | |
| 151 | + except ValueError: | |
| 152 | + pass | |
| 153 | + m = _WORKLOAD_RE.search(str(inst.get("courseWorkload", ""))) | |
| 154 | + if m and hours is None: | |
| 155 | + hours = float(m.group(1)) | |
| 156 | + for pers in inst.get("instructor", []) or []: | |
| 157 | + if isinstance(pers, dict) and pers.get("name"): | |
| 158 | + instructors.append(pers["name"]) | |
| 159 | + | |
| 160 | + sessions = sorted(set(sessions)) | |
| 161 | + if sessions: | |
| 162 | + payload["sessions"] = sessions | |
| 163 | + payload["start_date"] = sessions[0] | |
| 164 | + modes = list(dict.fromkeys(modes)) | |
| 165 | + if modes: | |
| 166 | + payload["mode"] = modes[0] if len(modes) == 1 else "hybride" | |
| 167 | + payload["details"] = {"modes_offerts": modes} | |
| 168 | + if cities: | |
| 169 | + payload["city"] = cities[0] | |
| 170 | + if price is not None: | |
| 171 | + payload["price"] = price | |
| 172 | + payload["price_label"] = f"{price:g} $ + tx" | |
| 173 | + if hours is not None: | |
| 174 | + payload["duration_hours"] = hours | |
| 175 | + payload["duration"] = f"{hours:g} h" | |
| 176 | + if instructors: | |
| 177 | + payload["instructor"] = ", ".join(dict.fromkeys(instructors)) | |
| 178 | + | |
| 179 | + # catégorie : lien du fil d'Ariane (« Technologies de l'information… ») | |
| 180 | + cat = soup.find(id="ContentPlaceHolder1_LinkFilArianeCat") | |
| 181 | + if cat: | |
| 182 | + payload["category"] = clean_text(cat.get_text(" ")) | |
| 183 | + | |
| 184 | + # sections riches de la fiche | |
| 185 | + objectives = _section_items(soup, r"objectifs? p[ée]dagogiques?") | |
| 186 | + if objectives: | |
| 187 | + payload["objectives"] = objectives | |
| 188 | + program = [] | |
| 189 | + for h in soup.find_all("h3"): | |
| 190 | + txt = clean_text(h.get_text(" ")) | |
| 191 | + if ((txt.endswith(":") or txt.endswith(" :")) | |
| 192 | + and not re.search(r"clients qui ont suivi", txt, re.I)): | |
| 193 | + program.append(txt.rstrip(" :")) | |
| 194 | + if program: | |
| 195 | + payload["program"] = program | |
| 196 | + aud = soup.find(["h2", "h3"], string=re.compile(r"client[èe]le", re.I)) | |
| 197 | + if aud: | |
| 198 | + nxt = aud.find_next(["p", "ul"]) | |
| 199 | + if nxt: | |
| 200 | + payload["audience"] = clean_text(nxt.get_text(" ")) | |
| 201 | + return payload | |
added
formaka/connectors/hec_dirigeants.py
+250 −0
@@ -0,0 +1,250 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/hec_dirigeants.py : connecteur École des dirigeant(e)s HEC Montréal | |
| 5 | +# (ecole-dirigeants.hec.ca — remplace l'ancien ecoledesdirigeants.hec.ca, | |
| 6 | +# dont le domaine ne résout plus). ~90 séminaires et certifications pour | |
| 7 | +# cadres et gestionnaires (leadership, finance, IA, stratégie…). | |
| 8 | +# Boutique Shopify : l'API publique /products.json fournit tout le catalogue | |
| 9 | +# (titre, type, prix, variantes avec mode + dates de séance, résumé HTML, | |
| 10 | +# images, formateurs en étiquettes). La fiche produit ajoute les sections | |
| 11 | +# riches (OBJECTIFS, EST-CE POUR VOUS?, MÉTHODE PÉDAGOGIQUE, Programme, | |
| 12 | +# Animé par, durée, fil d'Ariane -> catégorie). Fiches en cache, | |
| 13 | +# rafraîchies chaque semaine (clé ISO « AAAA-WSS »). | |
| 14 | +# ----------------------------------------------------------------------------- | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import datetime | |
| 18 | +import re | |
| 19 | + | |
| 20 | +from bs4 import BeautifulSoup | |
| 21 | + | |
| 22 | +from ..schema import Formation, clean_text | |
| 23 | + | |
| 24 | +from .base import BaseConnector | |
| 25 | + | |
| 26 | +BASE = "https://ecole-dirigeants.hec.ca" | |
| 27 | +PRODUCTS_URL = f"{BASE}/products.json" | |
| 28 | + | |
| 29 | +# les produits « Ticket »/« Event » sont des événements promotionnels gratuits | |
| 30 | +# (conférences, webinaires d'information) — pas des formations du catalogue | |
| 31 | +_SKIP_TYPES = {"ticket", "event"} | |
| 32 | + | |
| 33 | +# product_type Shopify -> type de formation Forma-Ka | |
| 34 | +_TYPE_MAP = { | |
| 35 | + "certification": "Certification", | |
| 36 | + "séminaire": "Séminaire", | |
| 37 | +} | |
| 38 | + | |
| 39 | +_DUREE_VAL_RE = re.compile(r"\d(?:[.,]\d)?\s*(?:jours?|journ[ée]es?|heures?|h\b|" | |
| 40 | + r"semaines?|mois)", re.I) | |
| 41 | + | |
| 42 | +# sections h3 du corps « Présentation » de la fiche | |
| 43 | +_SECTION_FIELDS = [ | |
| 44 | + (re.compile(r"^objectifs?", re.I), "objectives"), | |
| 45 | + (re.compile(r"est-ce pour vous", re.I), "audience"), | |
| 46 | + (re.compile(r"avantages distinctifs", re.I), "_avantages"), | |
| 47 | + (re.compile(r"m[ée]thodes? p[ée]dagogiques?", re.I), "_methode"), | |
| 48 | +] | |
| 49 | + | |
| 50 | + | |
| 51 | +def _strip_html(html: str) -> str: | |
| 52 | + """HTML de résumé Shopify -> texte propre en paragraphes.""" | |
| 53 | + soup = BeautifulSoup(html or "", "html.parser") | |
| 54 | + paras = [clean_text(p.get_text(" ")) for p in soup.find_all(["p", "li"])] | |
| 55 | + paras = [p for p in paras if p] | |
| 56 | + return "\n\n".join(paras) or clean_text(soup.get_text(" ")) | |
| 57 | + | |
| 58 | + | |
| 59 | +def _rich_sections(container) -> dict: | |
| 60 | + """Découpe un bloc riche (métachamp Shopify) selon ses titres h3/strong.""" | |
| 61 | + out: dict[str, list[str]] = {} | |
| 62 | + current: str | None = None | |
| 63 | + for el in container.find_all(["h3", "h4", "p", "ul", "ol"]): | |
| 64 | + if el.name in ("h3", "h4"): | |
| 65 | + heading = clean_text(el.get_text(" ")) | |
| 66 | + current = None | |
| 67 | + for rx, field in _SECTION_FIELDS: | |
| 68 | + if rx.search(heading): | |
| 69 | + current = field | |
| 70 | + out.setdefault(current, []) | |
| 71 | + break | |
| 72 | + continue | |
| 73 | + if current is None: | |
| 74 | + continue | |
| 75 | + if el.name in ("ul", "ol"): | |
| 76 | + out[current] += [clean_text(li.get_text(" ")) | |
| 77 | + for li in el.find_all("li") if clean_text(li.get_text(" "))] | |
| 78 | + else: | |
| 79 | + txt = clean_text(el.get_text(" ")) | |
| 80 | + if txt: | |
| 81 | + out[current].append(txt) | |
| 82 | + return out | |
| 83 | + | |
| 84 | + | |
| 85 | +class HecDirigeantsConnector(BaseConnector): | |
| 86 | + source_id = "hec_dirigeants" | |
| 87 | + request_delay = 0.5 | |
| 88 | + | |
| 89 | + def fetch(self) -> list[Formation]: | |
| 90 | + # 1) Catalogue complet via l'API Shopify /products.json (paginée) | |
| 91 | + products: list[dict] = [] | |
| 92 | + page = 1 | |
| 93 | + while True: | |
| 94 | + resp = self.get(PRODUCTS_URL, params={"limit": 250, "page": page}) | |
| 95 | + batch = resp.json().get("products", []) | |
| 96 | + products += batch | |
| 97 | + if len(batch) < 250: | |
| 98 | + break | |
| 99 | + page += 1 | |
| 100 | + | |
| 101 | + week = datetime.date.today().strftime("%G-W%V") | |
| 102 | + out: list[Formation] = [] | |
| 103 | + for prod in products: | |
| 104 | + ptype = (prod.get("product_type") or "").strip().lower() | |
| 105 | + if ptype in _SKIP_TYPES: | |
| 106 | + continue | |
| 107 | + handle = prod.get("handle", "") | |
| 108 | + url = f"{BASE}/products/{handle}" | |
| 109 | + | |
| 110 | + f = Formation( | |
| 111 | + source=self.source_id, | |
| 112 | + external_id=handle, | |
| 113 | + url=url, | |
| 114 | + title=clean_text(prod.get("title", "")), | |
| 115 | + training_type=_TYPE_MAP.get(ptype, "Séminaire"), | |
| 116 | + language="fr", | |
| 117 | + description=_strip_html(prod.get("body_html", "")), | |
| 118 | + images=[img["src"] for img in prod.get("images", []) | |
| 119 | + if img.get("src")][:3], | |
| 120 | + ) | |
| 121 | + | |
| 122 | + # variantes : prix + options (Localisation, Date) | |
| 123 | + prices = [] | |
| 124 | + for var in prod.get("variants", []): | |
| 125 | + try: | |
| 126 | + p = float(var.get("price") or 0) | |
| 127 | + except (TypeError, ValueError): | |
| 128 | + continue | |
| 129 | + if p > 0: | |
| 130 | + prices.append(p) | |
| 131 | + if prices: | |
| 132 | + f.price = min(prices) | |
| 133 | + f.price_label = f"{f.price:g} $ + tx" | |
| 134 | + if len(set(prices)) > 1: | |
| 135 | + f.details["price_from"] = True | |
| 136 | + | |
| 137 | + modes, dates = [], [] | |
| 138 | + for opt in prod.get("options", []): | |
| 139 | + name = (opt.get("name") or "").lower() | |
| 140 | + values = [v for v in opt.get("values", []) if v] | |
| 141 | + if "localisation" in name or "format" in name: | |
| 142 | + modes += values | |
| 143 | + elif "date" in name: | |
| 144 | + dates += values | |
| 145 | + if modes: | |
| 146 | + f.mode = modes[0] if len(set(modes)) == 1 else "hybride" | |
| 147 | + f.details["modes_offerts"] = sorted(set(modes)) | |
| 148 | + if dates: | |
| 149 | + f.schedule_label = " ; ".join(dates) | |
| 150 | + if re.search(r"présentiel|campus|montr[ée]al", f.mode, re.I): | |
| 151 | + f.city = "Montréal" | |
| 152 | + | |
| 153 | + # étiquettes : catégorie + formateurs (affinées par la fiche) | |
| 154 | + if prod.get("tags"): | |
| 155 | + f.tags = [t for t in prod["tags"] if isinstance(t, str)] | |
| 156 | + | |
| 157 | + # 2) Fiche produit — sections riches, cache hebdomadaire | |
| 158 | + key = f"{week}:{prod.get('updated_at', '')}" | |
| 159 | + payload = self.detail(handle, key, lambda u=url: self._fetch_detail(u)) | |
| 160 | + for k, v in (payload or {}).items(): | |
| 161 | + if k == "details": | |
| 162 | + f.details = {**f.details, **v} | |
| 163 | + elif hasattr(f, k) and v not in (None, "", []): | |
| 164 | + setattr(f, k, v) | |
| 165 | + out.append(f) | |
| 166 | + return out | |
| 167 | + | |
| 168 | + # -- fiche ---------------------------------------------------------------- | |
| 169 | + def _fetch_detail(self, url: str) -> dict: | |
| 170 | + html = self.fetch_html(url) | |
| 171 | + soup = BeautifulSoup(html, "html.parser") | |
| 172 | + payload: dict = {} | |
| 173 | + details: dict = {} | |
| 174 | + | |
| 175 | + # fil d'Ariane : « Accueil > Leadership > … » -> catégorie | |
| 176 | + bc = soup.select_one(".product-main__breadcrumbs") | |
| 177 | + if bc: | |
| 178 | + crumbs = [clean_text(a.get_text(" ")) for a in bc.find_all("a")] | |
| 179 | + crumbs = [c for c in crumbs if c and c.lower() != "accueil"] | |
| 180 | + if crumbs: | |
| 181 | + payload["category"] = crumbs[0] | |
| 182 | + | |
| 183 | + # contenus d'onglets : deux gabarits selon le type de produit — | |
| 184 | + # séminaires (.product-main__presentation-body / __programme) ou | |
| 185 | + # certifications (onglets dynamiques « hec-tab-* » appariés par ordre | |
| 186 | + # aux boutons Présentation / Programme / Animé par) | |
| 187 | + tabs: dict[str, "BeautifulSoup"] = {} | |
| 188 | + btns = soup.select('[class*="hec-tab-btn"]') | |
| 189 | + conts = soup.select('[class*="hec-tab-content"]') | |
| 190 | + for btn, cont in zip(btns, conts): | |
| 191 | + tabs[clean_text(btn.get_text(" ")).lower()] = cont | |
| 192 | + | |
| 193 | + # corps « Présentation » : OBJECTIFS / EST-CE POUR VOUS? / MÉTHODE… | |
| 194 | + body = soup.select_one(".product-main__presentation-body") or \ | |
| 195 | + tabs.get("présentation") | |
| 196 | + if body: | |
| 197 | + sections = _rich_sections(body) | |
| 198 | + if sections.get("objectives"): | |
| 199 | + # on écarte les phrases d'amorce (« Ce programme vous permettra de : ») | |
| 200 | + objs = [o for o in sections["objectives"] if not o.endswith(":")] | |
| 201 | + payload["objectives"] = objs or sections["objectives"] | |
| 202 | + if sections.get("audience"): | |
| 203 | + payload["audience"] = " ".join(sections["audience"]) | |
| 204 | + if sections.get("_avantages"): | |
| 205 | + details["avantages_distinctifs"] = sections["_avantages"] | |
| 206 | + if sections.get("_methode"): | |
| 207 | + details["methode_pedagogique"] = " ".join(sections["_methode"]) | |
| 208 | + | |
| 209 | + # onglet « Programme » : plan de la formation | |
| 210 | + prog = soup.select_one(".product-main__programme") or tabs.get("programme") | |
| 211 | + if prog: | |
| 212 | + items = [clean_text(li.get_text(" ")) for li in prog.find_all("li")] | |
| 213 | + items = [i for i in items if i] | |
| 214 | + if not items: | |
| 215 | + items = [clean_text(p.get_text(" ")) for p in prog.find_all("p") | |
| 216 | + if clean_text(p.get_text(" "))] | |
| 217 | + if items: | |
| 218 | + payload["program"] = items | |
| 219 | + | |
| 220 | + # « Animé par » : formateurs et formatrices | |
| 221 | + names = [clean_text(n.get_text(" ")) | |
| 222 | + for n in soup.select(".product-main__animateurs-expert-name")] | |
| 223 | + titles = [clean_text(t.get_text(" ")) | |
| 224 | + for t in soup.select(".product-main__animateurs-expert-title")] | |
| 225 | + experts = list(dict.fromkeys( | |
| 226 | + f"{n} ({t})" if t else n | |
| 227 | + for n, t in zip(names, titles + [""] * len(names)) if n)) | |
| 228 | + if not experts and tabs.get("animé par") is not None: | |
| 229 | + # gabarit certification : noms en <strong>, titres en <p> suivants | |
| 230 | + experts = list(dict.fromkeys( | |
| 231 | + clean_text(s.get_text(" ")) | |
| 232 | + for s in tabs["animé par"].find_all("strong") | |
| 233 | + if clean_text(s.get_text(" ")))) | |
| 234 | + if experts: | |
| 235 | + payload["instructor"] = ", ".join(experts) | |
| 236 | + | |
| 237 | + # encadré d'inscription : durée (« Durée » suivi de « 2 jours »…) | |
| 238 | + lbl = soup.find(string=re.compile(r"^\s*Durée\s*$")) | |
| 239 | + if lbl: | |
| 240 | + for nxt in lbl.find_all_next(string=True, limit=8): | |
| 241 | + val = clean_text(str(nxt)) | |
| 242 | + if _DUREE_VAL_RE.search(val): | |
| 243 | + payload["duration"] = val | |
| 244 | + break | |
| 245 | + if re.search(r"Tarif|Langue|Format", val): | |
| 246 | + break | |
| 247 | + | |
| 248 | + if details: | |
| 249 | + payload["details"] = details | |
| 250 | + return payload | |
added
formaka/connectors/institut_leadership.py
+247 −0
@@ -0,0 +1,247 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/institut_leadership.py : connecteur Institut de leadership | |
| 5 | +# (institutleadership.ca, campus de Montréal) — certifications, programmes | |
| 6 | +# et formations signatures en leadership et gestion (~30 programmes animés | |
| 7 | +# par des dirigeants et personnalités québécoises). | |
| 8 | +# Site WordPress multisite (sous-site /montreal/) : | |
| 9 | +# - liste : liens du menu « Programmes de formation » de la page d'accueil | |
| 10 | +# (slugs certification-*, programme-*, formation-signature-*, etc.) | |
| 11 | +# - fiche : REST /montreal/wp-json/wp/v2/pages?slug=… -> content.rendered | |
| 12 | +# propre (sans gabarit) : description, clientèle (« À QUI S'ADRESSE »), | |
| 13 | +# modules (h4), objectifs, tarifs « 5595$ + taxes », cohortes datées, | |
| 14 | +# mode en ligne / présentiel, formateurs (« animée par … »), image Yoast. | |
| 15 | +# Fiches en cache, rafraîchies chaque semaine (clé ISO AAAA-WSS). | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import datetime | |
| 20 | +import json | |
| 21 | +import re | |
| 22 | + | |
| 23 | +from bs4 import BeautifulSoup | |
| 24 | + | |
| 25 | +from ..schema import Formation, clean_text, parse_date_fr | |
| 26 | +from .base import BaseConnector | |
| 27 | + | |
| 28 | +BASE = "https://www.institutleadership.ca" | |
| 29 | +LIST_URL = f"{BASE}/montreal/" | |
| 30 | +API_URL = f"{BASE}/montreal/wp-json/wp/v2/pages" | |
| 31 | + | |
| 32 | +# slugs de pages « programme » dans le menu (le reste = pages corporatives) | |
| 33 | +_PROGRAM_SLUG_RE = re.compile( | |
| 34 | + r"^(certification-|programme-|formation-signature-|habiletes-politiques|" | |
| 35 | + r"lessentiel-|communiquer-|c-lmh|experience-immersive)") | |
| 36 | + | |
| 37 | +_PRICE_RE = re.compile(r"(\d[\d\s ]{2,9})\s*\$\s*(?:\*)?\s*\+\s*taxes", re.I) | |
| 38 | +_HOURS_RE = re.compile(r"de\s+(\d{1,2})\s*h\s*(\d{2})?\s*à\s+(\d{1,2})\s*h\s*(\d{2})?", | |
| 39 | + re.I) | |
| 40 | +_DAYS_RE = re.compile(r"(\d+)\s*(?:jours|journées)", re.I) | |
| 41 | +_INSTRUCTOR_RE = re.compile( | |
| 42 | + r"animée?s? par\s+((?:[A-ZÀ-Ý][\wà-ÿ'’.-]+\s+){1,3}[A-ZÀ-Ý][\wà-ÿ'’.-]+)") | |
| 43 | +_DAY_MONTH_RE = re.compile( | |
| 44 | + r"(\d{1,2}|1er)\s+(janvier|février|mars|avril|mai|juin|juillet|août|" | |
| 45 | + r"septembre|octobre|novembre|décembre)", re.I) | |
| 46 | +_YEAR_RE = re.compile(r"\b(20\d{2})\b") | |
| 47 | + | |
| 48 | +# mot-clé du slug/titre -> catégorie Forma-Ka | |
| 49 | +_CATEGORY_MAP = [ | |
| 50 | + (re.compile(r"\bia\b|chatgpt|copilot|claude|numérique|agentique", re.I), | |
| 51 | + "Intelligence artificielle"), | |
| 52 | + (re.compile(r"gouvernance", re.I), "Gouvernance"), | |
| 53 | + (re.compile(r"strat[ée]gi", re.I), "Stratégie"), | |
| 54 | + (re.compile(r"gestion de projet|gestion de projets", re.I), "Gestion de projet"), | |
| 55 | + (re.compile(r"n[ée]gociation", re.I), "Négociation"), | |
| 56 | + (re.compile(r"finance", re.I), "Finance"), | |
| 57 | + (re.compile(r"communiquer|parler en public|réunions|rétroaction", re.I), | |
| 58 | + "Communication"), | |
| 59 | + (re.compile(r"transfert d", re.I), "Transfert d'entreprise"), | |
| 60 | +] | |
| 61 | + | |
| 62 | + | |
| 63 | +def _json_lenient(raw: str): | |
| 64 | + """L'API WP du site ajoute parfois du bruit après le JSON — décodage tolérant.""" | |
| 65 | + return json.JSONDecoder().raw_decode(raw.lstrip(" \r\n"))[0] | |
| 66 | + | |
| 67 | + | |
| 68 | +class InstitutLeadershipConnector(BaseConnector): | |
| 69 | + source_id = "institut_leadership" | |
| 70 | + request_delay = 0.5 | |
| 71 | + | |
| 72 | + def fetch(self) -> list[Formation]: | |
| 73 | + # 1) liste des programmes depuis le menu de la page d'accueil Montréal | |
| 74 | + html = self.fetch_html(LIST_URL) | |
| 75 | + slugs = list(dict.fromkeys( | |
| 76 | + s for s in re.findall( | |
| 77 | + r'href="https?://(?:www\.)?institutleadership\.ca/montreal/([\w-]+)/"', | |
| 78 | + html) | |
| 79 | + if _PROGRAM_SLUG_RE.match(s))) | |
| 80 | + | |
| 81 | + # 2) fiche par programme via l'API REST — cache hebdomadaire | |
| 82 | + week = datetime.date.today().strftime("%G-W%V") | |
| 83 | + out: list[Formation] = [] | |
| 84 | + for slug in slugs: | |
| 85 | + payload = self.detail(slug, week, lambda s=slug: self._fetch_detail(s)) | |
| 86 | + if not payload: | |
| 87 | + continue | |
| 88 | + f = Formation( | |
| 89 | + source=self.source_id, | |
| 90 | + external_id=slug, | |
| 91 | + url=payload.get("url") or f"{BASE}/montreal/{slug}/", | |
| 92 | + language="fr", | |
| 93 | + ) | |
| 94 | + # type + accréditation selon la famille de programme | |
| 95 | + if slug.startswith(("certification-", "c-lmh")): | |
| 96 | + f.training_type = "Certification" | |
| 97 | + f.credential = "Certification de l'Institut de leadership" | |
| 98 | + elif slug.startswith(("programme-", "experience-immersive")): | |
| 99 | + f.training_type = "Programme" | |
| 100 | + else: | |
| 101 | + f.training_type = "Formation continue" | |
| 102 | + for k, v in payload.items(): | |
| 103 | + if k != "url" and hasattr(f, k) and v not in (None, "", []): | |
| 104 | + setattr(f, k, v) | |
| 105 | + # catégorie par mots-clés (défaut : Leadership) | |
| 106 | + probe = f"{slug} {f.title}" | |
| 107 | + f.category = next((cat for rx, cat in _CATEGORY_MAP | |
| 108 | + if rx.search(probe)), "Leadership") | |
| 109 | + f.tags = sorted({f.category, "Leadership"}) | |
| 110 | + out.append(f) | |
| 111 | + return out | |
| 112 | + | |
| 113 | + # -- fiche ---------------------------------------------------------------- | |
| 114 | + def _fetch_detail(self, slug: str) -> dict: | |
| 115 | + resp = self.get(API_URL, params={ | |
| 116 | + "slug": slug, | |
| 117 | + "_fields": "slug,link,title,content,excerpt,yoast_head_json.og_image", | |
| 118 | + }) | |
| 119 | + pages = _json_lenient(resp.text) | |
| 120 | + if not pages: | |
| 121 | + return {} | |
| 122 | + page = pages[0] | |
| 123 | + payload: dict = { | |
| 124 | + "title": clean_text(BeautifulSoup( | |
| 125 | + page.get("title", {}).get("rendered", ""), | |
| 126 | + "html.parser").get_text(" ")), | |
| 127 | + "url": page.get("link", ""), | |
| 128 | + } | |
| 129 | + soup = BeautifulSoup(page.get("content", {}).get("rendered", ""), | |
| 130 | + "html.parser") | |
| 131 | + text = soup.get_text("\n") | |
| 132 | + lines = [clean_text(l) for l in text.split("\n") if clean_text(l)] | |
| 133 | + | |
| 134 | + # description : paragraphes avant la première grande section (h3) | |
| 135 | + first_h3 = soup.find("h3") | |
| 136 | + desc_parts = [] | |
| 137 | + for p in soup.find_all("p"): | |
| 138 | + if first_h3 and p.sourceline and first_h3.sourceline \ | |
| 139 | + and p.sourceline >= first_h3.sourceline: | |
| 140 | + break | |
| 141 | + txt = clean_text(p.get_text(" ")) | |
| 142 | + if len(txt) > 60: | |
| 143 | + desc_parts.append(txt) | |
| 144 | + if len(" ".join(desc_parts)) > 900: | |
| 145 | + break | |
| 146 | + payload["description"] = "\n\n".join(desc_parts) | |
| 147 | + if not payload["description"]: | |
| 148 | + exc = BeautifulSoup(page.get("excerpt", {}).get("rendered", ""), | |
| 149 | + "html.parser").get_text(" ") | |
| 150 | + payload["description"] = clean_text(exc) | |
| 151 | + | |
| 152 | + # clientèle visée : section « À QUI S'ADRESSE … » | |
| 153 | + aud = soup.find(["h2", "h3"], string=re.compile(r"à qui s['’]adresse", re.I)) | |
| 154 | + if aud: | |
| 155 | + parts = [] | |
| 156 | + for sib in aud.find_all_next(["p", "ul", "h2", "h3"], limit=6): | |
| 157 | + if sib.name in ("h2", "h3"): | |
| 158 | + break | |
| 159 | + parts.append(clean_text(sib.get_text(" "))) | |
| 160 | + payload["audience"] = " ".join(x for x in parts if x)[:600] | |
| 161 | + | |
| 162 | + # objectifs : liste qui suit « Objectifs de la formation » | |
| 163 | + obj = soup.find(string=re.compile(r"objectifs de la (formation|certification)", | |
| 164 | + re.I)) | |
| 165 | + if obj: | |
| 166 | + ul = obj.find_parent().find_next("ul") | |
| 167 | + if ul: | |
| 168 | + payload["objectives"] = [clean_text(li.get_text(" ")) | |
| 169 | + for li in ul.find_all("li")] | |
| 170 | + | |
| 171 | + # plan : modules h4 (hors sections administratives) | |
| 172 | + program = [] | |
| 173 | + for h in soup.find_all("h4"): | |
| 174 | + txt = clean_text(h.get_text(" ")) | |
| 175 | + if txt and not re.search(r"à lire aussi|témoignages", txt, re.I): | |
| 176 | + program.append(txt) | |
| 177 | + if program: | |
| 178 | + payload["program"] = program | |
| 179 | + | |
| 180 | + # tarifs : « 5595$ + taxes » (minimum = tarif d'appel) | |
| 181 | + prices = [] | |
| 182 | + labels = [] | |
| 183 | + for line in lines: | |
| 184 | + for m in _PRICE_RE.finditer(line): | |
| 185 | + val = float(re.sub(r"[\s ]", "", m.group(1))) | |
| 186 | + if val >= 100: # ignore les frais annexes | |
| 187 | + prices.append(val) | |
| 188 | + if line not in labels: | |
| 189 | + labels.append(line) | |
| 190 | + if prices: | |
| 191 | + payload["price"] = min(prices) | |
| 192 | + payload["price_label"] = " | ".join(labels[:3]) | |
| 193 | + | |
| 194 | + # cohortes datées : « Automne 2026 – divisé : 30 septembre, … » | |
| 195 | + sessions = [] | |
| 196 | + cohortes = [] | |
| 197 | + for line in lines: | |
| 198 | + my = _YEAR_RE.search(line) | |
| 199 | + md = _DAY_MONTH_RE.search(line) | |
| 200 | + if md and (my or re.search(r"20\d{2}", line)): | |
| 201 | + cohortes.append(line) | |
| 202 | + year = my.group(1) if my else "" | |
| 203 | + iso = parse_date_fr(f"{md.group(1)} {md.group(2)} {year}") | |
| 204 | + if iso: | |
| 205 | + sessions.append(iso) | |
| 206 | + if sessions: | |
| 207 | + sessions = sorted(set(sessions)) | |
| 208 | + payload["sessions"] = sessions | |
| 209 | + payload["start_date"] = sessions[0] | |
| 210 | + if cohortes: | |
| 211 | + payload["schedule_label"] = cohortes[0] | |
| 212 | + payload["details"] = {"cohortes": cohortes[:12]} | |
| 213 | + | |
| 214 | + # mode et ville | |
| 215 | + online = re.search(r"en ligne", text, re.I) | |
| 216 | + onsite = re.search(r"pr[ée]sentiel", text, re.I) | |
| 217 | + if online and onsite: | |
| 218 | + payload["mode"] = "hybride" | |
| 219 | + elif online: | |
| 220 | + payload["mode"] = "en ligne" | |
| 221 | + elif onsite: | |
| 222 | + payload["mode"] = "présentiel" | |
| 223 | + if onsite and re.search(r"pr[ée]sentiel à Montréal|à Montréal", text, re.I): | |
| 224 | + payload["city"] = "Montréal" | |
| 225 | + | |
| 226 | + # durée : « de 8h30 à 13h00 » (demi-journée) ou « 6 jours » | |
| 227 | + mh = _HOURS_RE.search(text) | |
| 228 | + md = _DAYS_RE.search(text) | |
| 229 | + if mh: | |
| 230 | + start = int(mh.group(1)) + int(mh.group(2) or 0) / 60 | |
| 231 | + end = int(mh.group(3)) + int(mh.group(4) or 0) / 60 | |
| 232 | + if end > start: | |
| 233 | + payload["duration_hours"] = round(end - start, 2) | |
| 234 | + payload["duration"] = f"{end - start:g} h" | |
| 235 | + elif md: | |
| 236 | + payload["duration"] = f"{md.group(1)} jours" | |
| 237 | + | |
| 238 | + # formateur(s) : « La formation est animée par … » | |
| 239 | + mi = _INSTRUCTOR_RE.search(text) | |
| 240 | + if mi: | |
| 241 | + payload["instructor"] = clean_text(mi.group(1)) | |
| 242 | + | |
| 243 | + # image Yoast (og:image) | |
| 244 | + ogs = (page.get("yoast_head_json") or {}).get("og_image") or [] | |
| 245 | + payload["images"] = [o["url"] for o in ogs if isinstance(o, dict) | |
| 246 | + and o.get("url")] | |
| 247 | + return payload | |
added
formaka/connectors/isarta.py
+200 −0
@@ -0,0 +1,200 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/isarta.py : connecteur Isarta Formations (formations.isarta.com) | |
| 5 | +# Catalogue de formations professionnelles en marketing, communication, | |
| 6 | +# médias sociaux, IA, RH et gestion (Montréal — surtout en virtuel). | |
| 7 | +# Site rendu serveur : la page catalogue liste TOUTES les fiches dans des | |
| 8 | +# cartes <article class="training-card"> très riches (titre, formateur, | |
| 9 | +# durée, niveau, catégories en data-attributes, sommaire, offres EN DIRECT | |
| 10 | +# avec date/mode/prix et EN PRIVÉ sur mesure). Les libellés français des | |
| 11 | +# catégories sont repris des filtres de la page. Les fiches /cours/<slug>/<id> | |
| 12 | +# ajoutent description et objectifs, plan de formation et public cible. | |
| 13 | +# Fiches en cache, rafraîchies chaque semaine (clé « AAAA-WSS »). | |
| 14 | +# ----------------------------------------------------------------------------- | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import datetime | |
| 18 | +import re | |
| 19 | +from urllib.parse import urljoin | |
| 20 | + | |
| 21 | +from bs4 import BeautifulSoup | |
| 22 | + | |
| 23 | +from ..schema import Formation, clean_text | |
| 24 | +from .base import BaseConnector | |
| 25 | + | |
| 26 | +BASE = "https://formations.isarta.com" | |
| 27 | +LIST_URL = f"{BASE}/" | |
| 28 | + | |
| 29 | +_DURATION_RE = re.compile(r"\b(\d{1,2})\s*h\s*(\d{2})?\b") | |
| 30 | +_ID_RE = re.compile(r"/cours/[^/]+/(\d+)") | |
| 31 | + | |
| 32 | +# type de contenu Isarta -> type de formation Forma-Ka | |
| 33 | +_TYPE_MAP = {"training": "Formation continue", "conference": "Conférence", | |
| 34 | + "workshop": "Atelier"} | |
| 35 | +# libellé du mode de diffusion -> mode canonique | |
| 36 | +_MODE_MAP = {"virtuel interactif": "en ligne", "vidéo enregistrée": "asynchrone", | |
| 37 | + "en présentiel": "présentiel"} | |
| 38 | + | |
| 39 | + | |
| 40 | +def _category_labels(soup: BeautifulSoup) -> dict[str, str]: | |
| 41 | + """Code de catégorie -> libellé français, depuis les filtres de la page.""" | |
| 42 | + labels: dict[str, str] = {} | |
| 43 | + for inp in soup.find_all("input", attrs={"name": "categories[]"}): | |
| 44 | + span = inp.find_next("span", class_="catalog-filters__option-text") | |
| 45 | + if inp.get("value") and span: | |
| 46 | + labels[inp["value"]] = clean_text(span.get_text(" ")) | |
| 47 | + return labels | |
| 48 | + | |
| 49 | + | |
| 50 | +def _heading_section(soup: BeautifulSoup, heading_rx: str): | |
| 51 | + """Conteneur (parent direct) du <h3 class="gray-title"> correspondant.""" | |
| 52 | + for h in soup.find_all("h3"): | |
| 53 | + if re.search(heading_rx, clean_text(h.get_text(" ")), re.I): | |
| 54 | + return h.parent | |
| 55 | + return None | |
| 56 | + | |
| 57 | + | |
| 58 | +class IsartaConnector(BaseConnector): | |
| 59 | + source_id = "isarta" | |
| 60 | + request_delay = 0.5 | |
| 61 | + limit: int | None = None # borne optionnelle (tests/débogage) | |
| 62 | + | |
| 63 | + def fetch(self) -> list[Formation]: | |
| 64 | + html = self.fetch_html(LIST_URL) | |
| 65 | + soup = BeautifulSoup(html, "html.parser") | |
| 66 | + cat_labels = _category_labels(soup) | |
| 67 | + cards = soup.find_all("article", class_="training-card") | |
| 68 | + if self.limit: | |
| 69 | + cards = cards[: self.limit] | |
| 70 | + | |
| 71 | + week = datetime.date.today().strftime("%G-W%V") | |
| 72 | + out: list[Formation] = [] | |
| 73 | + for card in cards: | |
| 74 | + link = card.find("h2", class_="training-card__title") | |
| 75 | + link = link.find("a") if link else None | |
| 76 | + if link is None or not link.get("href"): | |
| 77 | + continue | |
| 78 | + url = urljoin(BASE, link["href"]) | |
| 79 | + m = _ID_RE.search(url) | |
| 80 | + external_id = m.group(1) if m else url.rsplit("/", 1)[-1] | |
| 81 | + | |
| 82 | + f = Formation( | |
| 83 | + source=self.source_id, | |
| 84 | + external_id=str(external_id), | |
| 85 | + url=url, | |
| 86 | + title=clean_text(link.get_text(" ")), | |
| 87 | + training_type=_TYPE_MAP.get(card.get("data-content-type", ""), | |
| 88 | + "Formation continue"), | |
| 89 | + language="fr", | |
| 90 | + ) | |
| 91 | + | |
| 92 | + # catégories (codes -> libellés français des filtres) | |
| 93 | + codes = [c for c in card.get("data-categories", "").split(",") if c] | |
| 94 | + names = [cat_labels.get(c, c.replace("_", " ").capitalize()) | |
| 95 | + for c in codes] | |
| 96 | + if names: | |
| 97 | + f.category = names[0] | |
| 98 | + f.tags = names | |
| 99 | + | |
| 100 | + trainers = card.find("p", class_="training-card__trainers") | |
| 101 | + if trainers: | |
| 102 | + f.instructor = clean_text( | |
| 103 | + trainers.get_text(" ")).removeprefix("par ").strip() | |
| 104 | + | |
| 105 | + # méta : durée (« 3h30 ») + niveau (« Débutant-Intermédiaire ») | |
| 106 | + meta = card.find(class_="training-card__meta") | |
| 107 | + if meta: | |
| 108 | + txt = clean_text(meta.get_text(" ")) | |
| 109 | + dm = _DURATION_RE.search(txt) | |
| 110 | + if dm: | |
| 111 | + f.duration = dm.group(0) | |
| 112 | + f.duration_hours = (int(dm.group(1)) | |
| 113 | + + int(dm.group(2) or 0) / 60.0) | |
| 114 | + txt = txt.replace(dm.group(0), "") | |
| 115 | + level = clean_text(txt) | |
| 116 | + if level and "pour tous" not in level.lower(): | |
| 117 | + f.level = level | |
| 118 | + elif level: | |
| 119 | + f.details["niveau"] = level | |
| 120 | + | |
| 121 | + summary = card.find("p", class_="training-card__summary") | |
| 122 | + if summary: | |
| 123 | + f.description = clean_text(summary.get_text(" ")) | |
| 124 | + img = card.find("img", class_="training-card__image") | |
| 125 | + if img and img.get("src"): | |
| 126 | + f.images = [urljoin(BASE, img["src"])] | |
| 127 | + | |
| 128 | + # offres : EN DIRECT / EN VIDÉO (date, mode, prix) + EN PRIVÉ | |
| 129 | + modes, sessions = [], [] | |
| 130 | + for offer in card.find_all(class_="training-card__offer"): | |
| 131 | + classes = " ".join(offer.get("class") or []) | |
| 132 | + if "offer--private" in classes: | |
| 133 | + f.details["offre_privee"] = "sur mesure" | |
| 134 | + continue | |
| 135 | + label = offer.find(class_="training-card__mode-label") | |
| 136 | + if label: | |
| 137 | + mode = _MODE_MAP.get(clean_text(label.get_text(" ")).lower()) | |
| 138 | + if mode: | |
| 139 | + modes.append(mode) | |
| 140 | + t = offer.find("time") | |
| 141 | + if t and t.get("datetime"): | |
| 142 | + sessions.append(t["datetime"][:10]) | |
| 143 | + price = offer.find(class_="training-card__price") | |
| 144 | + if price and not f.price_label: | |
| 145 | + txt = clean_text(price.get_text(" ")) | |
| 146 | + if txt and "demande" not in txt.lower(): | |
| 147 | + f.price_label = txt | |
| 148 | + modes = list(dict.fromkeys(modes)) | |
| 149 | + if modes: | |
| 150 | + f.mode = modes[0] | |
| 151 | + f.details["modes_offerts"] = modes | |
| 152 | + sessions = sorted(set(sessions)) | |
| 153 | + if sessions: | |
| 154 | + f.sessions = sessions | |
| 155 | + f.start_date = sessions[0] | |
| 156 | + | |
| 157 | + # fiche détaillée — cache hebdomadaire | |
| 158 | + key = f"{week}:{f.start_date}:{f.price_label}" | |
| 159 | + payload = self.detail(external_id, key, | |
| 160 | + lambda u=url: self._fetch_detail(u)) | |
| 161 | + for k, v in (payload or {}).items(): | |
| 162 | + if k == "details": | |
| 163 | + f.details.update(v or {}) | |
| 164 | + elif hasattr(f, k) and v not in (None, "", []): | |
| 165 | + setattr(f, k, v) | |
| 166 | + out.append(f) | |
| 167 | + return out | |
| 168 | + | |
| 169 | + # -- fiche ---------------------------------------------------------------- | |
| 170 | + def _fetch_detail(self, url: str) -> dict: | |
| 171 | + html = self.fetch_html(url) | |
| 172 | + soup = BeautifulSoup(html, "html.parser") | |
| 173 | + payload: dict = {} | |
| 174 | + | |
| 175 | + # « Description et objectifs » : paragraphes + puces (objectifs) | |
| 176 | + sec = _heading_section(soup, r"description et objectifs") | |
| 177 | + if sec is not None: | |
| 178 | + paragraphs = [clean_text(p.get_text(" ")) for p in sec.find_all("p")] | |
| 179 | + desc = "\n\n".join(dict.fromkeys(p for p in paragraphs if p)) | |
| 180 | + if desc: | |
| 181 | + payload["description"] = desc | |
| 182 | + objectives = [clean_text(li.get_text(" ")) | |
| 183 | + for li in sec.find_all("li")] | |
| 184 | + objectives = [o for o in dict.fromkeys(objectives) if o] | |
| 185 | + if objectives: | |
| 186 | + payload["objectives"] = objectives | |
| 187 | + | |
| 188 | + sec = _heading_section(soup, r"plan de (formation|cours)") | |
| 189 | + if sec is not None: | |
| 190 | + program = [clean_text(li.get_text(" ")) for li in sec.find_all("li")] | |
| 191 | + program = [p for p in dict.fromkeys(program) if p] | |
| 192 | + if program: | |
| 193 | + payload["program"] = program | |
| 194 | + | |
| 195 | + sec = _heading_section(soup, r"public cible") | |
| 196 | + if sec is not None: | |
| 197 | + txt = clean_text(sec.get_text(" ")) | |
| 198 | + payload["audience"] = re.sub(r"^public cible\s*", "", txt, | |
| 199 | + flags=re.I) | |
| 200 | + return payload | |
added
formaka/connectors/ithq.py
+233 −0
@@ -0,0 +1,233 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/ithq.py : connecteur ITHQ (ithq.qc.ca) | |
| 5 | +# Ateliers grand public (cours SAQ par ITHQ : vins, spiritueux, dégustation) | |
| 6 | +# et formations continues professionnelles (tourisme, hôtellerie, | |
| 7 | +# restauration) de l'Institut de tourisme et d'hôtellerie du Québec. | |
| 8 | +# Site WordPress : l'API REST publique expose deux types de contenus | |
| 9 | +# (« workshop » et « formation ») avec le HTML Gutenberg complet de chaque | |
| 10 | +# fiche + taxonomies (_embed) — AUCUNE page à visiter individuellement : | |
| 11 | +# - caractéristiques : Durée, Formule (en présence/en ligne), Langue, Coût | |
| 12 | +# - blocs libres : présentation, « Ce que vous découvrirez/apprendrez » | |
| 13 | +# (objectifs), Contenu, Préalable, Coût, Modalités (attestation) | |
| 14 | +# - bloc de dates : séances datées par ville (Montréal, Québec, Brossard…) | |
| 15 | +# - taxonomies : catégorie, langue, niveau, domaine, clientèle, formule. | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import json | |
| 20 | +import re | |
| 21 | + | |
| 22 | +from bs4 import BeautifulSoup | |
| 23 | + | |
| 24 | +from ..schema import Formation, clean_text, parse_date_fr | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +BASE = "https://www.ithq.qc.ca" | |
| 28 | +API_URL = f"{BASE}/wp-json/wp/v2/{{cpt}}?per_page=100&page={{page}}&_embed=1" | |
| 29 | + | |
| 30 | +# type de contenu WordPress -> type de formation Forma-Ka | |
| 31 | +_CPT_TYPES = {"workshop": "Atelier", "formation": "Formation continue"} | |
| 32 | + | |
| 33 | +# titres de sections (h2/h3/h4 Gutenberg) -> rôle dans le schéma | |
| 34 | +_SECTION_ROLES = [ | |
| 35 | + (re.compile(r"d[ée]couvrirez|apprendrez|objectifs?", re.I), "objectives"), | |
| 36 | + (re.compile(r"^contenu|programme|au menu|d[ée]roulement", re.I), "program"), | |
| 37 | + (re.compile(r"pr[ée]alable", re.I), "prerequisites"), | |
| 38 | + (re.compile(r"^co[ûu]t", re.I), "price"), | |
| 39 | + (re.compile(r"modalit[ée]s?", re.I), "modalites"), | |
| 40 | + (re.compile(r"s[’']adresse|client[èe]le|à qui", re.I), "audience"), | |
| 41 | + (re.compile(r"^dur[ée]e", re.I), "duration"), | |
| 42 | + (re.compile(r"^pr[ée]sentation", re.I), ""), | |
| 43 | + (re.compile(r"^dates?$|information compl[ée]mentaire", re.I), "skip"), | |
| 44 | +] | |
| 45 | + | |
| 46 | +# « Formule » / taxonomie activity-type -> mode canonique Forma-Ka | |
| 47 | +_FORMULE_MAP = {"en présence": "présentiel", "en ligne": "en ligne", | |
| 48 | + "hybride": "hybride"} | |
| 49 | + | |
| 50 | + | |
| 51 | +def _terms_by_taxonomy(item: dict) -> dict[str, list[str]]: | |
| 52 | + """Noms des termes de taxonomie ({'workshop-category': ['Cours vins']…}).""" | |
| 53 | + out: dict[str, list[str]] = {} | |
| 54 | + for group in (item.get("_embedded", {}).get("wp:term") or []): | |
| 55 | + for term in group or []: | |
| 56 | + if isinstance(term, dict) and term.get("name"): | |
| 57 | + out.setdefault(term.get("taxonomy", ""), []).append(term["name"]) | |
| 58 | + return out | |
| 59 | + | |
| 60 | + | |
| 61 | +def _section_role(title: str) -> str | None: | |
| 62 | + """Rôle d'un titre de section, ou None si ce n'est pas un titre connu.""" | |
| 63 | + for rx, role in _SECTION_ROLES: | |
| 64 | + if rx.search(title): | |
| 65 | + return role | |
| 66 | + return None | |
| 67 | + | |
| 68 | + | |
| 69 | +class IthqConnector(BaseConnector): | |
| 70 | + source_id = "ithq" | |
| 71 | + request_delay = 0.5 | |
| 72 | + | |
| 73 | + def fetch(self) -> list[Formation]: | |
| 74 | + out: list[Formation] = [] | |
| 75 | + for cpt, training_type in _CPT_TYPES.items(): | |
| 76 | + for item in self._fetch_items(cpt): | |
| 77 | + out.append(self._build(item, cpt, training_type)) | |
| 78 | + return out | |
| 79 | + | |
| 80 | + # -- API REST ----------------------------------------------------------------- | |
| 81 | + def _get_json(self, url: str) -> list: | |
| 82 | + """JSON de l'API avec repli : requests direct -> Scrapfly (le site | |
| 83 | + refuse certains agents utilisateurs, mais l'API reste publique).""" | |
| 84 | + try: | |
| 85 | + return self.get(url).json() | |
| 86 | + except Exception: | |
| 87 | + pass | |
| 88 | + text = self.get_scrapfly(url).strip() | |
| 89 | + if text.startswith("<"): # réponse enrobée de HTML | |
| 90 | + text = BeautifulSoup(text, "html.parser").get_text() | |
| 91 | + return json.loads(text) | |
| 92 | + | |
| 93 | + def _fetch_items(self, cpt: str) -> list[dict]: | |
| 94 | + """Tous les contenus publiés d'un type donné (pagination REST).""" | |
| 95 | + items: list[dict] = [] | |
| 96 | + page = 1 | |
| 97 | + while True: | |
| 98 | + batch = self._get_json(API_URL.format(cpt=cpt, page=page)) | |
| 99 | + items.extend(batch) | |
| 100 | + if len(batch) < 100: | |
| 101 | + break | |
| 102 | + page += 1 | |
| 103 | + return items | |
| 104 | + | |
| 105 | + # -- construction --------------------------------------------------------------- | |
| 106 | + def _build(self, item: dict, cpt: str, training_type: str) -> Formation: | |
| 107 | + taxes = _terms_by_taxonomy(item) | |
| 108 | + title = BeautifulSoup(item.get("title", {}).get("rendered", ""), | |
| 109 | + "html.parser").get_text() | |
| 110 | + f = Formation( | |
| 111 | + source=self.source_id, | |
| 112 | + external_id=f"{cpt}:{item.get('slug', item.get('id'))}", | |
| 113 | + url=item.get("link", ""), | |
| 114 | + title=clean_text(title), | |
| 115 | + training_type=training_type, | |
| 116 | + category=(taxes.get(f"{cpt}-category") or [""])[0], | |
| 117 | + mode=_FORMULE_MAP.get( | |
| 118 | + (taxes.get("activity-type") or [""])[0].lower(), | |
| 119 | + (taxes.get("activity-type") or [""])[0]), | |
| 120 | + language=(taxes.get(f"{cpt}-language") or [""])[0], | |
| 121 | + level=(taxes.get("workshop-level") or [""])[0], | |
| 122 | + audience=", ".join(taxes.get("formation-clientele") or []), | |
| 123 | + tags=(taxes.get("formation-domain") or []) | |
| 124 | + + (taxes.get("formation-family") or []), | |
| 125 | + ) | |
| 126 | + media = item.get("_embedded", {}).get("wp:featuredmedia") or [] | |
| 127 | + if media and isinstance(media[0], dict) and media[0].get("source_url"): | |
| 128 | + f.images = [media[0]["source_url"]] | |
| 129 | + self._parse_content(f, item.get("content", {}).get("rendered", "")) | |
| 130 | + return f | |
| 131 | + | |
| 132 | + # -- HTML Gutenberg de la fiche --------------------------------------------------- | |
| 133 | + def _parse_content(self, f: Formation, html: str) -> None: | |
| 134 | + soup = BeautifulSoup(html, "html.parser") | |
| 135 | + | |
| 136 | + # 1) bloc de caractéristiques (Durée / Formule / Langue / Coût / Domaine) | |
| 137 | + for it in soup.find_all(class_="activity-characteristics__item"): | |
| 138 | + label = clean_text((it.find(class_="label") or it).get_text(" ")) | |
| 139 | + content = it.find(class_="content") | |
| 140 | + value = clean_text(content.get_text(" ")) if content else "" | |
| 141 | + if not value: | |
| 142 | + continue | |
| 143 | + low = label.lower() | |
| 144 | + if low.startswith("dur"): | |
| 145 | + f.duration = value | |
| 146 | + elif low.startswith("formule") and not f.mode: | |
| 147 | + f.mode = _FORMULE_MAP.get(value.lower(), value) | |
| 148 | + elif low.startswith("langue") and not f.language: | |
| 149 | + f.language = value | |
| 150 | + elif low.startswith("co"): | |
| 151 | + f.price_label = value | |
| 152 | + elif low.startswith("domaine") and not f.category: | |
| 153 | + f.category = value | |
| 154 | + | |
| 155 | + # 2) blocs libres au premier niveau : description + sections titrées | |
| 156 | + # (les titres de section connus — Présentation, Objectifs, Contenu, | |
| 157 | + # Coût, Modalités… — routent les blocs qui les suivent ; les autres | |
| 158 | + # titres sont des sous-titres qui restent dans la section courante) | |
| 159 | + desc_parts: list[str] = [] | |
| 160 | + role = "" | |
| 161 | + for el in soup.find_all(recursive=False): | |
| 162 | + cls = " ".join(el.get("class") or []) | |
| 163 | + if el.name in ("h2", "h3", "h4") and "wp-block-heading" in cls: | |
| 164 | + title = clean_text(el.get_text(" ")) | |
| 165 | + known = _section_role(title) | |
| 166 | + if known is not None: | |
| 167 | + role = known | |
| 168 | + else: # sous-titre descriptif | |
| 169 | + role = "" | |
| 170 | + desc_parts.append(title) | |
| 171 | + continue | |
| 172 | + if "activity-dates-block" in cls or "activity-related-block" in cls: | |
| 173 | + role = "skip" | |
| 174 | + if role == "skip" or el.name in ("section", "hr", "figure"): | |
| 175 | + continue | |
| 176 | + if el.name not in ("p", "ul", "ol", "div"): | |
| 177 | + continue | |
| 178 | + items = [clean_text(li.get_text(" ")) for li in el.find_all("li")] | |
| 179 | + text = clean_text(el.get_text(" ")) | |
| 180 | + if not text: | |
| 181 | + continue | |
| 182 | + if role in ("objectives", "program"): | |
| 183 | + target = f.objectives if role == "objectives" else f.program | |
| 184 | + if items: | |
| 185 | + target += items | |
| 186 | + elif not text.endswith(":"): # phrase d'amorce ignorée | |
| 187 | + target.append(text) | |
| 188 | + elif role == "prerequisites": | |
| 189 | + f.prerequisites = (f.prerequisites + " " + text).strip() | |
| 190 | + elif role == "price": | |
| 191 | + if not f.price_label: | |
| 192 | + f.price_label = text | |
| 193 | + f.details.setdefault("cout_details", text) | |
| 194 | + elif role == "duration": | |
| 195 | + m = re.search(r"\d+\s*h(?:\s*\d{1,2})?\b|\d+\s*min\w*", text) | |
| 196 | + if m and not f.duration: | |
| 197 | + f.duration = m.group(0) | |
| 198 | + elif role == "modalites": | |
| 199 | + f.details.setdefault("modalites", []).extend(items or [text]) | |
| 200 | + if re.search(r"attestation", text, re.I): | |
| 201 | + f.credential = "Attestation de participation" | |
| 202 | + elif role == "audience": | |
| 203 | + f.audience = clean_text(f"{f.audience} {text}").strip() | |
| 204 | + elif role == "": | |
| 205 | + # « Cette formation s'adresse : … » (paragraphe ou bloc dédié) | |
| 206 | + m = re.search(r"s[’']adresse\s*:?", text) | |
| 207 | + if m: | |
| 208 | + rest = clean_text(text[m.end():]) | |
| 209 | + if rest: | |
| 210 | + f.audience = clean_text(f"{f.audience} {rest}").strip() | |
| 211 | + else: | |
| 212 | + role = "audience" | |
| 213 | + continue | |
| 214 | + desc_parts += items or [text] | |
| 215 | + f.description = "\n\n".join(desc_parts) | |
| 216 | + | |
| 217 | + # 3) séances datées par ville | |
| 218 | + sessions, cities = [], [] | |
| 219 | + for it in soup.find_all(class_="activity-dates-block__date-item"): | |
| 220 | + t = it.find(class_="activity-dates-block__date-item__title") | |
| 221 | + iso = parse_date_fr(clean_text(t.get_text(" "))) if t else None | |
| 222 | + if iso: | |
| 223 | + sessions.append(iso) | |
| 224 | + city = clean_text(it.get("data-location", "")) | |
| 225 | + if city and city not in cities: | |
| 226 | + cities.append(city) | |
| 227 | + if sessions: | |
| 228 | + f.sessions = sorted(set(sessions)) | |
| 229 | + f.start_date = f.sessions[0] | |
| 230 | + if cities: | |
| 231 | + f.city = cities[0] | |
| 232 | + if len(cities) > 1: | |
| 233 | + f.details["villes"] = cities | |
added
formaka/connectors/lesaffaires.py
+208 −0
@@ -0,0 +1,208 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/lesaffaires.py : connecteur Événements Les Affaires | |
| 5 | +# (evenements.lesaffaires.com) — conférences, formations et webinaires | |
| 6 | +# d'affaires du média Les Affaires (Montréal/Québec/en ligne). | |
| 7 | +# Boutique Shopify : l'API publique /products.json expose TOUT le catalogue | |
| 8 | +# (un produit = un événement) avec des tags très structurés : | |
| 9 | +# - Format_Conférence / Formation / Webinaire / Démos du midi… | |
| 10 | +# - Thématique_Technologies de l'information / Finances / RH… | |
| 11 | +# - start-date_/end-date_AAAA-MM-JJ HH:MM:SS, Emplacement_Montréal/En ligne, | |
| 12 | +# Expert_Prénom Nom (conférenciers), edition-Gratuit, « En différé ». | |
| 13 | +# Prix par variante (Présentiel / Virtuel), description dans body_html. | |
| 14 | +# Aucune page détail nécessaire : la liste JSON contient tout. | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import re | |
| 19 | + | |
| 20 | +from bs4 import BeautifulSoup | |
| 21 | + | |
| 22 | +from ..schema import Formation, clean_text | |
| 23 | +from .base import BaseConnector | |
| 24 | + | |
| 25 | +BASE = "https://evenements.lesaffaires.com" | |
| 26 | +PRODUCTS_URL = f"{BASE}/products.json" | |
| 27 | + | |
| 28 | +# préfixe de date dans les titres : « 2026-11-19***Ambition » | |
| 29 | +# (parfois double : « 2026-12-09 & 2026-12-10***Masterclass Défense ») | |
| 30 | +_TITLE_DATE_RE = re.compile(r"^\s*20\d{2}-\d{2}-\d{2}[^*]*\*+\s*") | |
| 31 | +_DATETIME_RE = re.compile(r"(20\d{2}-\d{2}-\d{2})[ T](\d{2}:\d{2})") | |
| 32 | + | |
| 33 | +# tag Format_… -> type de formation canonique Forma-Ka | |
| 34 | +_FORMAT_MAP = { | |
| 35 | + "conférence": "Conférence", | |
| 36 | + "inédit : souper-conférence": "Conférence", | |
| 37 | + "salon": "Conférence", | |
| 38 | + "webinaire": "Webinaire", | |
| 39 | + "démos du midi": "Webinaire", | |
| 40 | + "formation": "Formation continue", | |
| 41 | + "formation am": "Formation continue", | |
| 42 | + "formation pm": "Formation continue", | |
| 43 | +} | |
| 44 | + | |
| 45 | + | |
| 46 | +def _parse_tags(tags: list[str]) -> dict: | |
| 47 | + """Décompose les tags Shopify structurés (Format_, Expert_, start-date_…).""" | |
| 48 | + out: dict = {"experts": [], "formats": [], "themes": [], "flags": []} | |
| 49 | + for tag in tags: | |
| 50 | + if "_" in tag: | |
| 51 | + prefix, value = tag.split("_", 1) | |
| 52 | + prefix = prefix.strip().lower() | |
| 53 | + value = value.strip() | |
| 54 | + if prefix == "format": | |
| 55 | + out["formats"].append(value) | |
| 56 | + elif prefix in ("thématique", "thematique"): | |
| 57 | + out["themes"].append(value) | |
| 58 | + elif prefix == "expert": | |
| 59 | + out["experts"].append(value) | |
| 60 | + elif prefix == "emplacement": | |
| 61 | + out["emplacement"] = value | |
| 62 | + elif prefix == "start-date": | |
| 63 | + out["start"] = value | |
| 64 | + elif prefix == "end-date": | |
| 65 | + out["end"] = value | |
| 66 | + else: | |
| 67 | + out["flags"].append(tag) | |
| 68 | + else: | |
| 69 | + out["flags"].append(tag) | |
| 70 | + return out | |
| 71 | + | |
| 72 | + | |
| 73 | +class LesAffairesConnector(BaseConnector): | |
| 74 | + source_id = "lesaffaires" | |
| 75 | + request_delay = 0.5 | |
| 76 | + | |
| 77 | + def fetch(self) -> list[Formation]: | |
| 78 | + # 1) catalogue complet via l'API Shopify (paginée par sécurité) | |
| 79 | + products: list[dict] = [] | |
| 80 | + page = 1 | |
| 81 | + while True: | |
| 82 | + resp = self.get(PRODUCTS_URL, params={"limit": 250, "page": page}) | |
| 83 | + batch = resp.json().get("products", []) | |
| 84 | + products.extend(batch) | |
| 85 | + if len(batch) < 250: | |
| 86 | + break | |
| 87 | + page += 1 | |
| 88 | + | |
| 89 | + out: list[Formation] = [] | |
| 90 | + for prod in products: | |
| 91 | + tags = _parse_tags(prod.get("tags") or []) | |
| 92 | + if "hide" in [f.lower() for f in tags["flags"]]: | |
| 93 | + continue | |
| 94 | + f = self._build(prod, tags) | |
| 95 | + if f is not None: | |
| 96 | + out.append(f) | |
| 97 | + return out | |
| 98 | + | |
| 99 | + # -- construction d'une formation depuis un produit Shopify ---------------- | |
| 100 | + def _build(self, prod: dict, tags: dict) -> Formation | None: | |
| 101 | + handle = prod.get("handle") or "" | |
| 102 | + raw_title = prod.get("title") or "" | |
| 103 | + if not handle or not raw_title: | |
| 104 | + return None | |
| 105 | + title = _TITLE_DATE_RE.sub("", raw_title).strip() | |
| 106 | + | |
| 107 | + f = Formation( | |
| 108 | + source=self.source_id, | |
| 109 | + external_id=handle, | |
| 110 | + url=f"{BASE}/products/{handle}", | |
| 111 | + title=title, | |
| 112 | + language="fr", | |
| 113 | + ) | |
| 114 | + | |
| 115 | + # type d'événement depuis le tag Format_ | |
| 116 | + fmt = (tags["formats"][0] if tags["formats"] else "").lower() | |
| 117 | + f.training_type = _FORMAT_MAP.get(fmt, "Conférence") | |
| 118 | + if tags["formats"]: | |
| 119 | + f.details["format"] = tags["formats"][0] | |
| 120 | + | |
| 121 | + # thématique -> catégorie + tags | |
| 122 | + if tags["themes"]: | |
| 123 | + f.category = tags["themes"][0] | |
| 124 | + f.tags = tags["themes"] + tags["formats"] | |
| 125 | + | |
| 126 | + # conférenciers / experts | |
| 127 | + if tags["experts"]: | |
| 128 | + f.instructor = ", ".join(dict.fromkeys(tags["experts"])) | |
| 129 | + | |
| 130 | + # description : body_html du produit | |
| 131 | + body = prod.get("body_html") or "" | |
| 132 | + if body: | |
| 133 | + f.description = BeautifulSoup(body, "html.parser").get_text("\n").strip() | |
| 134 | + | |
| 135 | + # dates précises (tags start-date_/end-date_) | |
| 136 | + start = tags.get("start") | |
| 137 | + end = tags.get("end") | |
| 138 | + ms = _DATETIME_RE.search(start or "") | |
| 139 | + me = _DATETIME_RE.search(end or "") | |
| 140 | + if ms: | |
| 141 | + f.start_date = ms.group(1) | |
| 142 | + f.sessions = [ms.group(1)] | |
| 143 | + f.schedule_label = f"{ms.group(1)} {ms.group(2)}" | |
| 144 | + f.details["start_datetime"] = f"{ms.group(1)} {ms.group(2)}" | |
| 145 | + if me: | |
| 146 | + f.details["end_datetime"] = f"{me.group(1)} {me.group(2)}" | |
| 147 | + if ms and me: | |
| 148 | + if ms.group(1) == me.group(1): # même jour -> durée en heures | |
| 149 | + h1, m1 = map(int, ms.group(2).split(":")) | |
| 150 | + h2, m2 = map(int, me.group(2).split(":")) | |
| 151 | + hours = (h2 * 60 + m2 - h1 * 60 - m1) / 60.0 | |
| 152 | + if hours > 0: | |
| 153 | + f.duration_hours = round(hours, 2) | |
| 154 | + f.duration = f"{hours:g} h" | |
| 155 | + else: # plusieurs jours | |
| 156 | + import datetime | |
| 157 | + d1 = datetime.date.fromisoformat(ms.group(1)) | |
| 158 | + d2 = datetime.date.fromisoformat(me.group(1)) | |
| 159 | + days = (d2 - d1).days + 1 | |
| 160 | + f.duration = f"{days} jours" | |
| 161 | + | |
| 162 | + # événement en différé (rediffusion à la demande) | |
| 163 | + if any(fl.lower().startswith("en différé") for fl in tags["flags"]): | |
| 164 | + f.tags.append("En différé") | |
| 165 | + f.details["en_differe"] = True | |
| 166 | + | |
| 167 | + # mode / ville : Emplacement_ + variantes Présentiel/Virtuel | |
| 168 | + variants = [v for v in (prod.get("variants") or []) if isinstance(v, dict)] | |
| 169 | + vtitles = {clean_text(v.get("title", "")).lower() for v in variants} | |
| 170 | + emplacement = tags.get("emplacement", "") | |
| 171 | + has_pres = "présentiel" in vtitles | |
| 172 | + has_virt = "virtuel" in vtitles | |
| 173 | + if has_pres and has_virt: | |
| 174 | + f.mode = "hybride" | |
| 175 | + elif emplacement: | |
| 176 | + f.mode = "en ligne" if emplacement.lower() == "en ligne" else "présentiel" | |
| 177 | + if emplacement and emplacement.lower() != "en ligne": | |
| 178 | + f.city = emplacement | |
| 179 | + | |
| 180 | + # prix : minimum des variantes (le 1 $ des gratuits est un artefact) | |
| 181 | + prices = [] | |
| 182 | + labels = [] | |
| 183 | + for v in variants: | |
| 184 | + try: | |
| 185 | + p = float(v.get("price") or 0) | |
| 186 | + except (TypeError, ValueError): | |
| 187 | + continue | |
| 188 | + prices.append(p) | |
| 189 | + vt = clean_text(v.get("title", "")) | |
| 190 | + if vt and vt.lower() != "default title": | |
| 191 | + labels.append(f"{p:g} $ ({vt})") | |
| 192 | + gratuit = any(fl.lower() == "edition-gratuit" for fl in tags["flags"]) \ | |
| 193 | + or "edition-Gratuit" in (prod.get("tags") or []) | |
| 194 | + if gratuit or (prices and min(prices) == 0.0): | |
| 195 | + f.price = 0.0 | |
| 196 | + f.is_free = True | |
| 197 | + f.price_label = "Gratuit" | |
| 198 | + elif prices and min(prices) > 1.0: # 1 $ = placeholder Shopify | |
| 199 | + f.price = min(prices) | |
| 200 | + f.price_label = " / ".join(labels) if labels else f"{min(prices):g} $" | |
| 201 | + | |
| 202 | + # images du produit | |
| 203 | + for img in prod.get("images") or []: | |
| 204 | + src = img.get("src") if isinstance(img, dict) else None | |
| 205 | + if src: | |
| 206 | + f.images.append(src) | |
| 207 | + | |
| 208 | + return f | |
added
formaka/connectors/lewagon_mtl.py
+214 −0
@@ -0,0 +1,214 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/lewagon_mtl.py : connecteur Le Wagon — campus de Montréal | |
| 5 | +# (lewagon.com/fr/montreal) — bootcamps intensifs en développement web / | |
| 6 | +# IA / data (Web Development, Data Analytics, Data Science, Data Engineering). | |
| 7 | +# Site Rails rendu serveur (Hotwire/Turbo) : | |
| 8 | +# - liste : liens /fr/montreal/<slug>-course sur la page campus | |
| 9 | +# - fiche : HTML riche (h1, og:description, modules du cursus #curriculum, | |
| 10 | +# pré-requis de la section Admission, « X semaines ») | |
| 11 | +# - séances : turbo-frame /fr/local_courses/<id>/sections/upcoming_sessions | |
| 12 | +# (requiert l'en-tête « Turbo-Frame ») — rythme, dates, format campus/en | |
| 13 | +# ligne, prix en CAD et langue (onglets Anglais/Français) par cohorte. | |
| 14 | +# Fiches en cache, rafraîchies chaque semaine (clé ISO AAAA-WSS). | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import datetime | |
| 19 | +import json | |
| 20 | +import re | |
| 21 | + | |
| 22 | +from bs4 import BeautifulSoup | |
| 23 | + | |
| 24 | +from ..schema import Formation, clean_text | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +BASE = "https://www.lewagon.com" | |
| 28 | +LIST_URL = f"{BASE}/fr/montreal" | |
| 29 | + | |
| 30 | +# secours si la page campus change : les 4 formations connues de Montréal | |
| 31 | +_KNOWN_SLUGS = ["web-development-course", "data-analytics-course", | |
| 32 | + "data-science-course", "data-engineering-course"] | |
| 33 | + | |
| 34 | +_FRAME_RE = re.compile(r"/fr/local_courses/(\d+)/sections/upcoming_sessions") | |
| 35 | +_PRICE_RE = re.compile(r"([\d][\d\s ]*)\s*CAD") | |
| 36 | +_WEEKS_RE = re.compile(r"(\d+)\s*semaines", re.I) | |
| 37 | +# dates du tableau des séances : « oct. 12, 2026 » (mois abrégé français) | |
| 38 | +_SESSION_DATE_RE = re.compile(r"([a-zûé]+)\.?\s+(\d{1,2}),\s*(20\d{2})", re.I) | |
| 39 | + | |
| 40 | +_MONTHS_FR = {"jan": 1, "fév": 2, "fev": 2, "mar": 3, "avr": 4, "mai": 5, | |
| 41 | + "jui": 6, "juin": 6, "juil": 7, "aoû": 8, "aou": 8, "sep": 9, | |
| 42 | + "oct": 10, "nov": 11, "déc": 12, "dec": 12} | |
| 43 | + | |
| 44 | + | |
| 45 | +def _parse_session_date(text: str) -> str | None: | |
| 46 | + """« oct. 12, 2026 » -> « 2026-10-12 » (attention : juin/juil).""" | |
| 47 | + m = _SESSION_DATE_RE.search(text or "") | |
| 48 | + if not m: | |
| 49 | + return None | |
| 50 | + raw = m.group(1).lower() | |
| 51 | + mo = _MONTHS_FR.get(raw[:4]) or _MONTHS_FR.get(raw[:3]) | |
| 52 | + if not mo: | |
| 53 | + return None | |
| 54 | + return f"{int(m.group(3)):04d}-{mo:02d}-{int(m.group(2)):02d}" | |
| 55 | + | |
| 56 | + | |
| 57 | +class LeWagonMtlConnector(BaseConnector): | |
| 58 | + source_id = "lewagon_mtl" | |
| 59 | + request_delay = 0.5 | |
| 60 | + | |
| 61 | + def fetch(self) -> list[Formation]: | |
| 62 | + # 1) liste des formations offertes au campus de Montréal | |
| 63 | + html = self.fetch_html(LIST_URL) | |
| 64 | + slugs = list(dict.fromkeys( | |
| 65 | + m.rsplit("/", 1)[-1] | |
| 66 | + for m in re.findall(r'href="/fr/montreal/([\w-]+-course)"', html))) | |
| 67 | + if not slugs: | |
| 68 | + slugs = _KNOWN_SLUGS | |
| 69 | + | |
| 70 | + # 2) fiche + séances par formation — cache hebdomadaire | |
| 71 | + week = datetime.date.today().strftime("%G-W%V") | |
| 72 | + out: list[Formation] = [] | |
| 73 | + for slug in slugs: | |
| 74 | + url = f"{BASE}/fr/montreal/{slug}" | |
| 75 | + payload = self.detail(slug, week, lambda u=url: self._fetch_detail(u)) | |
| 76 | + f = Formation( | |
| 77 | + source=self.source_id, | |
| 78 | + external_id=slug, | |
| 79 | + url=url, | |
| 80 | + training_type="Bootcamp", | |
| 81 | + category="Informatique", | |
| 82 | + city="Montréal", | |
| 83 | + credential="Certificat Le Wagon", | |
| 84 | + ) | |
| 85 | + for k, v in (payload or {}).items(): | |
| 86 | + if hasattr(f, k) and v not in (None, "", []): | |
| 87 | + setattr(f, k, v) | |
| 88 | + out.append(f) | |
| 89 | + return out | |
| 90 | + | |
| 91 | + # -- fiche ---------------------------------------------------------------- | |
| 92 | + def _fetch_detail(self, url: str) -> dict: | |
| 93 | + html = self.fetch_html(url) | |
| 94 | + soup = BeautifulSoup(html, "html.parser") | |
| 95 | + payload: dict = {} | |
| 96 | + | |
| 97 | + # titre : og:title (« Bootcamp Développement Web à Montréal | Le Wagon ») | |
| 98 | + og = soup.find("meta", property="og:title") | |
| 99 | + title = (og.get("content", "") if og else "").split("|")[0] | |
| 100 | + if not title: | |
| 101 | + h1 = soup.find("h1") | |
| 102 | + title = h1.get_text(" ", strip=True) if h1 else "" | |
| 103 | + payload["title"] = clean_text(title) | |
| 104 | + | |
| 105 | + # description : og:description + phrase d'accroche du héros | |
| 106 | + ogd = soup.find("meta", property="og:description") | |
| 107 | + if ogd and ogd.get("content"): | |
| 108 | + payload["description"] = clean_text(ogd["content"]) | |
| 109 | + | |
| 110 | + # durée : « Formez-vous en 9 semaines… » | |
| 111 | + m = _WEEKS_RE.search(html) | |
| 112 | + if m: | |
| 113 | + payload["duration"] = f"{m.group(1)} semaines (temps plein)" | |
| 114 | + | |
| 115 | + # plan de cours : modules de la section #curriculum | |
| 116 | + cur = soup.find(id="curriculum") | |
| 117 | + if cur: | |
| 118 | + program = [] | |
| 119 | + for h in cur.find_all("h3"): | |
| 120 | + txt = clean_text(h.get_text(" ")) | |
| 121 | + if (txt and not txt.endswith(":") and txt not in program | |
| 122 | + and not _WEEKS_RE.search(txt)): # titre de section | |
| 123 | + program.append(txt) | |
| 124 | + if program: | |
| 125 | + payload["program"] = program | |
| 126 | + | |
| 127 | + # pré-requis : bloc « Pré-requis » de la section Admission | |
| 128 | + pre = soup.find(string=lambda s: s and s.strip().lower() | |
| 129 | + in ("pré-requis", "prérequis")) | |
| 130 | + if pre: | |
| 131 | + for p in pre.find_parent().find_all_next("p", limit=5): | |
| 132 | + txt = clean_text(p.get_text(" ")) | |
| 133 | + if (len(txt) < 60 or txt.lower().startswith("en france") | |
| 134 | + or txt.lower().startswith("découvrez")): | |
| 135 | + continue # bruit / mention hors Québec | |
| 136 | + payload["prerequisites"] = txt | |
| 137 | + break | |
| 138 | + | |
| 139 | + # séances : turbo-frame des prochaines sessions | |
| 140 | + mf = _FRAME_RE.search(html) | |
| 141 | + if mf: | |
| 142 | + payload.update(self._fetch_sessions(mf.group(0))) | |
| 143 | + return payload | |
| 144 | + | |
| 145 | + # -- séances (turbo-frame) -------------------------------------------------- | |
| 146 | + def _fetch_sessions(self, frame_path: str) -> dict: | |
| 147 | + try: | |
| 148 | + resp = self.get(f"{BASE}{frame_path}", | |
| 149 | + headers={"Turbo-Frame": "local_course_upcoming_sessions"}) | |
| 150 | + except Exception: | |
| 151 | + return {} | |
| 152 | + soup = BeautifulSoup(resp.text, "html.parser") | |
| 153 | + | |
| 154 | + sessions: list[dict] = [] | |
| 155 | + seen: set = set() | |
| 156 | + for pane_id, lang in (("en-tab-pane", "en"), ("fr-tab-pane", "fr")): | |
| 157 | + pane = soup.find(id=pane_id) | |
| 158 | + if pane is None: | |
| 159 | + continue | |
| 160 | + for art in pane.find_all("article", class_="upcoming-sessions-grid"): | |
| 161 | + cells = [clean_text(d.get_text(" ")) | |
| 162 | + for d in art.find_all("div", recursive=False)] | |
| 163 | + if len(cells) < 4: | |
| 164 | + continue | |
| 165 | + pace, dates, fmt, price_txt = cells[0], cells[1], cells[2], cells[3] | |
| 166 | + # métadonnées GTM du bouton (batch_id, format, campus/en ligne) | |
| 167 | + gtm = {} | |
| 168 | + btn = art.find("a", attrs={"data-gtm-data-layer-attributes-value": True}) | |
| 169 | + if btn: | |
| 170 | + try: | |
| 171 | + gtm = json.loads(btn["data-gtm-data-layer-attributes-value"]) | |
| 172 | + except (ValueError, KeyError): | |
| 173 | + gtm = {} | |
| 174 | + key = (gtm.get("batch_id"), lang) if gtm.get("batch_id") \ | |
| 175 | + else (pace, dates, lang) | |
| 176 | + if key in seen: | |
| 177 | + continue | |
| 178 | + seen.add(key) | |
| 179 | + found = _SESSION_DATE_RE.findall(dates) | |
| 180 | + start = _parse_session_date(dates) | |
| 181 | + end = _parse_session_date(dates[dates.find("->") + 2:]) \ | |
| 182 | + if "->" in dates else ( | |
| 183 | + _parse_session_date(" ".join( | |
| 184 | + f"{a} {b}, {c}" for a, b, c in found[1:2])) or None) | |
| 185 | + mprice = _PRICE_RE.search(price_txt) | |
| 186 | + sessions.append({ | |
| 187 | + "rythme": pace, | |
| 188 | + "debut": start, | |
| 189 | + "fin": end, | |
| 190 | + "format": fmt, | |
| 191 | + "langue": lang, | |
| 192 | + "prix": float(re.sub(r"[\s ]", "", mprice.group(1))) | |
| 193 | + if mprice else None, | |
| 194 | + "batch_id": gtm.get("batch_id"), | |
| 195 | + }) | |
| 196 | + | |
| 197 | + if not sessions: | |
| 198 | + return {} | |
| 199 | + payload: dict = {"details": {"cohortes": sessions}} | |
| 200 | + starts = sorted({s["debut"] for s in sessions if s["debut"]}) | |
| 201 | + if starts: | |
| 202 | + payload["sessions"] = starts | |
| 203 | + payload["start_date"] = starts[0] | |
| 204 | + payload["schedule_label"] = "Rentrées : " + ", ".join(starts) | |
| 205 | + modes = {("en ligne" if "ligne" in s["format"].lower() else "présentiel") | |
| 206 | + for s in sessions} | |
| 207 | + payload["mode"] = modes.pop() if len(modes) == 1 else "hybride" | |
| 208 | + langs = {s["langue"] for s in sessions} | |
| 209 | + payload["language"] = "fr/en" if len(langs) > 1 else langs.pop() | |
| 210 | + prices = [s["prix"] for s in sessions if s["prix"]] | |
| 211 | + if prices: | |
| 212 | + payload["price"] = min(prices) | |
| 213 | + payload["price_label"] = f"{min(prices):,.0f} CAD".replace(",", " ") | |
| 214 | + return payload | |
added
formaka/connectors/mcgill_scs.py
+269 −0
@@ -0,0 +1,269 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/mcgill_scs.py : connecteur McGill School of Continuing Studies | |
| 5 | +# (continuingstudies.mcgill.ca). Formation continue de l'Université McGill : | |
| 6 | +# cours de certificats de perfectionnement professionnel (PDC), ateliers | |
| 7 | +# publics, cours en ligne, webinaires… (~200-300 activités). | |
| 8 | +# Le site vitrine (www.mcgill.ca/continuingstudies) est derrière un WAF | |
| 9 | +# Azure, mais le portail d'inscription Destiny One est librement accessible : | |
| 10 | +# - liste : recherche avancée /search/publicCourseAdvancedSearch.do | |
| 11 | +# interrogée catégorie par catégorie (avec pagination de session | |
| 12 | +# « displaytag ») — titre, sigle, lieu, formats de diffusion | |
| 13 | +# - fiche : /search/publicCourseSearchDetails.do?courseId=… — Description, | |
| 14 | +# Topics Covered, Learning Outcomes, Who Should Attend?, Course Fee, | |
| 15 | +# Duration (hours), CEU et programmes auxquels le cours contribue. | |
| 16 | +# Fiches en cache, rafraîchies chaque semaine (clé ISO « AAAA-WSS »). | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import datetime | |
| 21 | +import re | |
| 22 | +from urllib.parse import urlencode | |
| 23 | + | |
| 24 | +from bs4 import BeautifulSoup | |
| 25 | + | |
| 26 | +from ..schema import Formation, clean_text | |
| 27 | + | |
| 28 | +from .base import BaseConnector | |
| 29 | + | |
| 30 | +BASE = "https://continuingstudies.mcgill.ca" | |
| 31 | +SEARCH_URL = f"{BASE}/search/publicCourseAdvancedSearch.do" | |
| 32 | +DETAIL_URL = f"{BASE}/search/publicCourseSearchDetails.do" | |
| 33 | + | |
| 34 | +# catégories Destiny One de type « formation » -> type de formation Forma-Ka | |
| 35 | +# (les catégories de tests de classement, services, séances d'information… | |
| 36 | +# sont volontairement exclues) | |
| 37 | +CATEGORIES = { | |
| 38 | + "21539211": ("Transcript PDC Course", "Formation continue"), | |
| 39 | + "21539210": ("Transcript Non-PDC Course", "Formation continue"), | |
| 40 | + "21539205": ("Public Workshop", "Atelier"), | |
| 41 | + "22158260": ("Short Program Course", "Formation continue"), | |
| 42 | + "21539087": ("Asynchronous Online Course", "Cours en ligne"), | |
| 43 | + "17116": ("Courses", "Formation continue"), | |
| 44 | + "17117": ("Online courses", "Cours en ligne"), | |
| 45 | + "17114": ("Workshops", "Atelier"), | |
| 46 | + "17120": ("Intensive courses", "Formation continue"), | |
| 47 | + "17119": ("Summer courses", "Formation continue"), | |
| 48 | + "17122": ("Lecture Series", "Conférence"), | |
| 49 | + "17124": ("Webinars", "Webinaire"), | |
| 50 | +} | |
| 51 | + | |
| 52 | +_FEE_RE = re.compile(r"Course Fee\s*:?\s*\$?\s*([\d,]+(?:\.\d{2})?)", re.I) | |
| 53 | +_HOURS_RE = re.compile(r"Duration\s*\(hours\)\s*:?\s*(\d+(?:\.\d+)?)", re.I) | |
| 54 | +_CEU_RE = re.compile(r"(\d+(?:\.\d+)?)\s*Continuing Education Units?", re.I) | |
| 55 | + | |
| 56 | +# formats de diffusion Destiny -> mode canonique Forma-Ka | |
| 57 | +_MODE_MAP = [ | |
| 58 | + (re.compile(r"blended", re.I), "hybride"), | |
| 59 | + (re.compile(r"asynchronous|self[- ]paced", re.I), "asynchrone"), | |
| 60 | + (re.compile(r"online|remote|virtual", re.I), "en ligne"), | |
| 61 | + (re.compile(r"in[- ]class|in[- ]person|classroom", re.I), "présentiel"), | |
| 62 | +] | |
| 63 | + | |
| 64 | + | |
| 65 | +def _mode_from(formats: list[str]) -> str: | |
| 66 | + modes = [] | |
| 67 | + for fmt in formats: | |
| 68 | + for rx, canon in _MODE_MAP: | |
| 69 | + if rx.search(fmt): | |
| 70 | + modes.append(canon) | |
| 71 | + break | |
| 72 | + modes = list(dict.fromkeys(modes)) | |
| 73 | + if not modes: | |
| 74 | + return "" | |
| 75 | + if len(modes) == 1: | |
| 76 | + return modes[0] | |
| 77 | + return "hybride" # plusieurs formats offerts (en classe et en ligne) | |
| 78 | + | |
| 79 | + | |
| 80 | +def _section_content(h2) -> tuple[list[str], list[str]]: | |
| 81 | + """(paragraphes, items) qui suivent un titre h2 de la fiche Destiny.""" | |
| 82 | + paras: list[str] = [] | |
| 83 | + items: list[str] = [] | |
| 84 | + for sib in h2.find_next_siblings(): | |
| 85 | + if sib.name in ("h1", "h2"): | |
| 86 | + break | |
| 87 | + if sib.name in ("ul", "ol"): | |
| 88 | + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] | |
| 89 | + elif sib.name == "p": | |
| 90 | + paras.append(clean_text(sib.get_text(" "))) | |
| 91 | + elif sib.name == "div": | |
| 92 | + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] | |
| 93 | + paras += [clean_text(p.get_text(" ")) for p in sib.find_all("p")] | |
| 94 | + if not paras and not items: | |
| 95 | + # certains gabarits mettent le texte directement dans le conteneur du h2 | |
| 96 | + # (ex. div#courseProfileRecommendedPrerequisites) | |
| 97 | + parent = h2.parent | |
| 98 | + if parent is not None: | |
| 99 | + items = [clean_text(li.get_text(" ")) for li in parent.find_all("li")] | |
| 100 | + txt = clean_text(parent.get_text(" ")) | |
| 101 | + txt = re.sub(r"^" + re.escape(clean_text(h2.get_text(" "))) + r"\s*", | |
| 102 | + "", txt) | |
| 103 | + for i in items: | |
| 104 | + txt = txt.replace(i, "") | |
| 105 | + txt = clean_text(txt) | |
| 106 | + if txt: | |
| 107 | + paras = [txt] | |
| 108 | + return [p for p in paras if p], [i for i in items if i] | |
| 109 | + | |
| 110 | + | |
| 111 | +class McgillScsConnector(BaseConnector): | |
| 112 | + source_id = "mcgill_scs" | |
| 113 | + request_delay = 0.6 | |
| 114 | + | |
| 115 | + def fetch(self) -> list[Formation]: | |
| 116 | + # amorce de session Destiny (cookie JSESSIONID pour la pagination) | |
| 117 | + self.get(f"{SEARCH_URL}?method=load") | |
| 118 | + | |
| 119 | + # 1) Liste : une recherche par catégorie, pagination de session | |
| 120 | + found: dict[str, dict] = {} # courseId -> infos de la liste | |
| 121 | + for cat_id, (cat_label, ttype) in CATEGORIES.items(): | |
| 122 | + params = { | |
| 123 | + "method": "doPaginatedSearch", | |
| 124 | + "showInternal": "false", | |
| 125 | + "courseSearch.courseCategoryStringArray": cat_id, | |
| 126 | + "courseSearch.filterString": "all", | |
| 127 | + } | |
| 128 | + html = self.get(f"{SEARCH_URL}?{urlencode(params)}").text | |
| 129 | + pages = [html] | |
| 130 | + # liens « displaytag » de pagination (d-XXXX-p=N), propres à la session | |
| 131 | + page_links = sorted(set(re.findall( | |
| 132 | + r'href="(/search/publicCourseAdvancedSearch\.do\?method=doPagination' | |
| 133 | + r'[^"]*?-p=(\d+))"', html)), key=lambda t: int(t[1])) | |
| 134 | + for link, _n in page_links: | |
| 135 | + pages.append(self.get(BASE + link.replace("&", "&")).text) | |
| 136 | + | |
| 137 | + for page_html in pages: | |
| 138 | + for row in self._parse_rows(page_html): | |
| 139 | + cid = row["course_id"] | |
| 140 | + if cid not in found: | |
| 141 | + row["category_label"] = cat_label | |
| 142 | + row["training_type"] = ttype | |
| 143 | + found[cid] = row | |
| 144 | + | |
| 145 | + # 2) Fiche détaillée par cours — cache hebdomadaire | |
| 146 | + week = datetime.date.today().strftime("%G-W%V") | |
| 147 | + out: list[Formation] = [] | |
| 148 | + for cid, row in found.items(): | |
| 149 | + url = f"{DETAIL_URL}?method=load&courseId={cid}" | |
| 150 | + key = f"{week}:{row['title']}" | |
| 151 | + payload = self.detail(cid, key, lambda u=url: self._fetch_detail(u)) | |
| 152 | + f = Formation( | |
| 153 | + source=self.source_id, | |
| 154 | + external_id=cid, | |
| 155 | + url=url, | |
| 156 | + title=row["title"], | |
| 157 | + training_type=row["training_type"], | |
| 158 | + language="en", | |
| 159 | + code=row.get("code", ""), | |
| 160 | + mode=_mode_from(row.get("formats", [])), | |
| 161 | + ) | |
| 162 | + if row.get("formats"): | |
| 163 | + f.details["formats_offerts"] = row["formats"] | |
| 164 | + f.details["categorie_scs"] = row["category_label"] | |
| 165 | + if row.get("location"): | |
| 166 | + f.details["lieu"] = row["location"] | |
| 167 | + if "montreal" in row["location"].lower(): | |
| 168 | + f.city = "Montréal" | |
| 169 | + if row.get("availability"): | |
| 170 | + f.details["disponibilite"] = row["availability"] | |
| 171 | + for k, v in (payload or {}).items(): | |
| 172 | + if k == "details": | |
| 173 | + f.details = {**f.details, **v} | |
| 174 | + elif hasattr(f, k) and v not in (None, "", []): | |
| 175 | + setattr(f, k, v) | |
| 176 | + out.append(f) | |
| 177 | + return out | |
| 178 | + | |
| 179 | + # -- liste ------------------------------------------------------------------ | |
| 180 | + @staticmethod | |
| 181 | + def _parse_rows(html: str) -> list[dict]: | |
| 182 | + soup = BeautifulSoup(html, "html.parser") | |
| 183 | + rows: list[dict] = [] | |
| 184 | + for td in soup.select("td.firstColumn"): | |
| 185 | + a = td.select_one(".courseName a[href]") | |
| 186 | + if a is None: | |
| 187 | + continue | |
| 188 | + m = re.search(r"courseId=(\d+)", a["href"]) | |
| 189 | + if not m: | |
| 190 | + continue | |
| 191 | + tr = td.find_parent("tr") | |
| 192 | + code_el = td.select_one(".courseCode") | |
| 193 | + tds = tr.find_all("td") if tr else [] | |
| 194 | + location = clean_text(tds[1].get_text(" ")) if len(tds) > 1 else "" | |
| 195 | + formats = [clean_text(x.get_text(" ")) | |
| 196 | + for x in tr.select(".courseDelivery a")] if tr else [] | |
| 197 | + avail_el = tr.select_one(".courseAvailability") if tr else None | |
| 198 | + rows.append({ | |
| 199 | + "course_id": m.group(1), | |
| 200 | + "title": clean_text(a.get_text(" ")), | |
| 201 | + "code": clean_text(code_el.get_text(" ")) if code_el else "", | |
| 202 | + "location": re.sub(r"\s*,\s*", ", ", location), | |
| 203 | + "formats": [f for f in formats if f], | |
| 204 | + "availability": clean_text(avail_el.get_text(" ")) if avail_el else "", | |
| 205 | + }) | |
| 206 | + return rows | |
| 207 | + | |
| 208 | + # -- fiche ------------------------------------------------------------------ | |
| 209 | + def _fetch_detail(self, url: str) -> dict: | |
| 210 | + html = self.fetch_html(url) | |
| 211 | + soup = BeautifulSoup(html, "html.parser") | |
| 212 | + payload: dict = {} | |
| 213 | + details: dict = {} | |
| 214 | + | |
| 215 | + for h2 in soup.find_all("h2"): | |
| 216 | + heading = clean_text(h2.get_text(" ")).lower() | |
| 217 | + if heading == "description": | |
| 218 | + paras, items = _section_content(h2) | |
| 219 | + if paras: | |
| 220 | + desc = "\n\n".join(paras) | |
| 221 | + payload["description"] = re.sub( | |
| 222 | + r"^Official Description\s*", "", desc) | |
| 223 | + ceu = _CEU_RE.search(" ".join(paras)) | |
| 224 | + if ceu: | |
| 225 | + payload["credits"] = f"{ceu.group(1)} CEU" | |
| 226 | + elif heading.startswith("topics covered"): | |
| 227 | + _, items = _section_content(h2) | |
| 228 | + if items: | |
| 229 | + payload["program"] = items | |
| 230 | + elif heading.startswith("learning outcomes"): | |
| 231 | + paras, items = _section_content(h2) | |
| 232 | + objs = [o for o in (items or paras) if not o.endswith(":")] | |
| 233 | + if objs: | |
| 234 | + payload["objectives"] = objs | |
| 235 | + elif heading.startswith("who should attend"): | |
| 236 | + paras, items = _section_content(h2) | |
| 237 | + aud = " ".join(paras) or "; ".join(items) | |
| 238 | + if aud: | |
| 239 | + payload["audience"] = aud | |
| 240 | + elif heading.startswith("applies towards"): | |
| 241 | + ul = h2.find_next("ul") | |
| 242 | + progs = [clean_text(a.get_text(" ")) | |
| 243 | + for a in (ul.find_all("a") if ul else [])] | |
| 244 | + progs = list(dict.fromkeys(p for p in progs if p)) | |
| 245 | + if progs: | |
| 246 | + details["programmes_lies"] = progs | |
| 247 | + # catégorie lisible : « PDC in Business Analysis » -> sujet | |
| 248 | + cat = re.sub(r"^(Professional Development |Graduate )?" | |
| 249 | + r"Certificate in ", "", progs[0]).strip(" :") | |
| 250 | + if cat: | |
| 251 | + payload["category"] = cat | |
| 252 | + | |
| 253 | + body_txt = clean_text(soup.get_text(" ")) | |
| 254 | + m = _FEE_RE.search(body_txt) | |
| 255 | + if m: | |
| 256 | + payload["price"] = float(m.group(1).replace(",", "")) | |
| 257 | + payload["price_label"] = f"Course Fee: ${m.group(1)}" | |
| 258 | + m = _HOURS_RE.search(body_txt) | |
| 259 | + if m: | |
| 260 | + payload["duration_hours"] = float(m.group(1)) | |
| 261 | + payload["duration"] = f"{m.group(1)} hours" | |
| 262 | + m = re.search(r"Discounts?\s*:\s*(.{10,220}?)(?:Thank you|Required fields|$)", | |
| 263 | + body_txt) | |
| 264 | + if m: | |
| 265 | + details["rabais"] = clean_text(m.group(1)) | |
| 266 | + | |
| 267 | + if details: | |
| 268 | + payload["details"] = details | |
| 269 | + return payload | |
added
formaka/connectors/technologia.py
+228 −0
@@ -0,0 +1,228 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/technologia.py : connecteur Technologia (technologia.com) | |
| 5 | +# Grande firme de formation professionnelle (Montréal/Québec) — ~600 | |
| 6 | +# formations en TI, IA, gestion de projets, leadership, manufacturier… | |
| 7 | +# Site Umbraco + Vue, mais très bien outillé côté données : | |
| 8 | +# - liste : API JSON interne /Api/Catalog/Browse (code, titre, thématique, | |
| 9 | +# sous-thématique, durée en jours ou en heures, prix régulier et | |
| 10 | +# préférentiel, formats ClasseVirtuelle/ELearning/EnClasse) | |
| 11 | +# - fiche : JSON complet embarqué (<script id="training-data"> : sommaire, | |
| 12 | +# objectif, plan de cours « syllabusHtml », clientèle visée, préalables, | |
| 13 | +# méthode pédagogique, formateurs) + EducationEvent JSON-LD (séances | |
| 14 | +# datées avec villes). Fiches en cache, rafraîchies chaque semaine. | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import datetime | |
| 19 | +import json | |
| 20 | +import re | |
| 21 | + | |
| 22 | +from bs4 import BeautifulSoup | |
| 23 | + | |
| 24 | +from ..schema import Formation, clean_text | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +BASE = "https://www.technologia.com" | |
| 28 | +API_BROWSE = f"{BASE}/Api/Catalog/Browse" | |
| 29 | +LIST_URL = f"{BASE}/formations" | |
| 30 | + | |
| 31 | +# le JSON riche embarqué dans chaque fiche | |
| 32 | +_TRAINING_DATA_RE = re.compile( | |
| 33 | + r'<script id="training-data" type="application/json">(.*?)</script>', re.S) | |
| 34 | +# les scripts JSON-LD ont un attribut type HTML-encodé (« ld+json ») | |
| 35 | +_LDJSON_RE = re.compile( | |
| 36 | + r'<script[^>]*type="application/ld(?:\+|+)json"[^>]*>(.*?)</script>', | |
| 37 | + re.S | re.I) | |
| 38 | + | |
| 39 | +# formats Technologia -> mode canonique Forma-Ka | |
| 40 | +_FORMAT_MAP = { | |
| 41 | + "classevirtuelle": "en ligne", | |
| 42 | + "elearning": "asynchrone", | |
| 43 | + "enclasse": "présentiel", | |
| 44 | +} | |
| 45 | + | |
| 46 | + | |
| 47 | +def _strip_html(fragment: str) -> str: | |
| 48 | + """Texte propre d'un fragment HTML (paragraphes séparés).""" | |
| 49 | + if not fragment: | |
| 50 | + return "" | |
| 51 | + soup = BeautifulSoup(fragment, "html.parser") | |
| 52 | + parts = [clean_text(p.get_text(" ")) for p in soup.find_all(["p", "li"])] | |
| 53 | + parts = [p for p in parts if p] | |
| 54 | + return "\n".join(dict.fromkeys(parts)) or clean_text(soup.get_text(" ")) | |
| 55 | + | |
| 56 | + | |
| 57 | +def _item_mode(formats: list[str] | None) -> tuple[str, list[str]]: | |
| 58 | + """(mode canonique, tous les modes offerts) depuis les formats de l'API.""" | |
| 59 | + modes = [_FORMAT_MAP[f.lower()] for f in (formats or []) | |
| 60 | + if f.lower() in _FORMAT_MAP] | |
| 61 | + modes = list(dict.fromkeys(modes)) | |
| 62 | + if not modes: | |
| 63 | + return "", [] | |
| 64 | + return (modes[0] if len(modes) == 1 else "hybride"), modes | |
| 65 | + | |
| 66 | + | |
| 67 | +class TechnologiaConnector(BaseConnector): | |
| 68 | + source_id = "technologia" | |
| 69 | + request_delay = 0.5 | |
| 70 | + limit: int | None = None # borne optionnelle (tests/débogage) | |
| 71 | + | |
| 72 | + def fetch(self) -> list[Formation]: | |
| 73 | + # 1) Catalogue complet via l'API JSON interne | |
| 74 | + resp = self.get(API_BROWSE, | |
| 75 | + params={"Count": 5000, "Page": 1, "Culture": "fr-CA"}) | |
| 76 | + items = resp.json().get("Items") or [] | |
| 77 | + if self.limit: | |
| 78 | + items = items[: self.limit] | |
| 79 | + | |
| 80 | + # 2) Fiche détaillée par formation — cache hebdomadaire | |
| 81 | + week = datetime.date.today().strftime("%G-W%V") | |
| 82 | + out: list[Formation] = [] | |
| 83 | + for item in items: | |
| 84 | + code = item.get("Code") or item.get("ID") or "" | |
| 85 | + url = BASE + (item.get("Uri") or "") | |
| 86 | + key = f"{week}:{item.get('Title', '')}:{item.get('RegularPrice')}" | |
| 87 | + payload = self.detail(code, key, lambda u=url: self._fetch_detail(u)) | |
| 88 | + | |
| 89 | + days = item.get("Duration") | |
| 90 | + hours = item.get("DurationInHours") | |
| 91 | + duration = (f"{hours:g} h" if hours | |
| 92 | + else f"{days:g} jour{'s' if days and days > 1 else ''}" | |
| 93 | + if days else "") | |
| 94 | + price = item.get("RegularPrice") | |
| 95 | + tags = [t for t in (item.get("SubThematic"),) if t] | |
| 96 | + if "NouveauCours" in (item.get("Tags") or []): | |
| 97 | + tags.append("Nouveau") | |
| 98 | + | |
| 99 | + f = Formation( | |
| 100 | + source=self.source_id, | |
| 101 | + external_id=str(code), | |
| 102 | + url=url, | |
| 103 | + title=clean_text(item.get("Title", "")), | |
| 104 | + training_type="Formation continue", | |
| 105 | + category=item.get("Thematic", ""), | |
| 106 | + language="fr", | |
| 107 | + code=item.get("Code", ""), | |
| 108 | + duration=duration, | |
| 109 | + duration_hours=float(hours) if hours else None, | |
| 110 | + price=float(price) if price is not None else None, | |
| 111 | + price_label=f"{price:g} $ + tx" if price is not None else "", | |
| 112 | + tags=tags, | |
| 113 | + ) | |
| 114 | + mode, modes = _item_mode(item.get("Formats")) | |
| 115 | + if mode: | |
| 116 | + f.mode = mode | |
| 117 | + f.details["modes_offerts"] = modes | |
| 118 | + if item.get("IsOnDemand"): | |
| 119 | + f.mode = f.mode or "asynchrone" | |
| 120 | + pref = item.get("PreferentialPrice") | |
| 121 | + if pref is not None: | |
| 122 | + f.details["prix_preferentiel"] = pref | |
| 123 | + for k, v in (payload or {}).items(): | |
| 124 | + if k == "details": | |
| 125 | + f.details.update(v or {}) | |
| 126 | + elif hasattr(f, k) and v not in (None, "", []): | |
| 127 | + setattr(f, k, v) | |
| 128 | + out.append(f) | |
| 129 | + return out | |
| 130 | + | |
| 131 | + # -- fiche ---------------------------------------------------------------- | |
| 132 | + def _fetch_detail(self, url: str) -> dict: | |
| 133 | + if not url: | |
| 134 | + return {} | |
| 135 | + html = self.fetch_html(url) | |
| 136 | + payload: dict = {} | |
| 137 | + details: dict = {} | |
| 138 | + | |
| 139 | + m = _TRAINING_DATA_RE.search(html) | |
| 140 | + if m: | |
| 141 | + try: | |
| 142 | + data = json.loads(m.group(1)) | |
| 143 | + except ValueError: | |
| 144 | + data = {} | |
| 145 | + # description : sommaire + corps de la fiche | |
| 146 | + desc = [clean_text(data.get("summary", ""))] | |
| 147 | + body = BeautifulSoup(data.get("content") or "", "html.parser") | |
| 148 | + desc += [clean_text(p.get_text(" ")) for p in body.find_all("p")] | |
| 149 | + payload["description"] = "\n\n".join( | |
| 150 | + dict.fromkeys(d for d in desc if d and d != "\xa0")) | |
| 151 | + | |
| 152 | + # objectifs : objectif général + « Ce que vous saurez faire » | |
| 153 | + objectives = [] | |
| 154 | + if data.get("goal"): | |
| 155 | + objectives.append(clean_text(data["goal"])) | |
| 156 | + arch = BeautifulSoup(data.get("courseArchitecture") or "", | |
| 157 | + "html.parser") | |
| 158 | + objectives += [clean_text(p.get_text(" ")) | |
| 159 | + for p in arch.find_all(["p", "li"])] | |
| 160 | + payload["objectives"] = [o for o in dict.fromkeys(objectives) | |
| 161 | + if o and o != "\xa0"] | |
| 162 | + | |
| 163 | + if data.get("prerequisite"): | |
| 164 | + payload["prerequisites"] = _strip_html(str(data["prerequisite"])) | |
| 165 | + if data.get("targetCustomer"): | |
| 166 | + payload["audience"] = clean_text(str(data["targetCustomer"])) | |
| 167 | + if data.get("subTitle"): | |
| 168 | + details["sous_titre"] = clean_text(data["subTitle"]) | |
| 169 | + if data.get("teachingMethod"): | |
| 170 | + details["methode_pedagogique"] = clean_text(data["teachingMethod"]) | |
| 171 | + if data.get("satisfaction"): | |
| 172 | + details["satisfaction_pct"] = data["satisfaction"] | |
| 173 | + if data.get("pduCount"): | |
| 174 | + details["pdu"] = data["pduCount"] | |
| 175 | + | |
| 176 | + # plan de cours : section « Contenu de la formation » du syllabus | |
| 177 | + syllabus = BeautifulSoup(data.get("syllabusHtml") or "", | |
| 178 | + "html.parser") | |
| 179 | + program, in_content = [], False | |
| 180 | + for h in syllabus.find_all(["h2", "h3"]): | |
| 181 | + txt = clean_text(h.get_text(" ")) | |
| 182 | + if h.name == "h2": | |
| 183 | + in_content = bool(re.search(r"contenu", txt, re.I)) | |
| 184 | + elif in_content and txt: | |
| 185 | + program.append(re.sub(r"^\d+\s*", "", txt)) | |
| 186 | + if not program: # repli : puces du corps de la fiche | |
| 187 | + program = [clean_text(li.get_text(" ")) | |
| 188 | + for li in body.find_all("li")] | |
| 189 | + payload["program"] = [p for p in dict.fromkeys(program) if p] | |
| 190 | + | |
| 191 | + teachers = [t.get("name") for t in data.get("teachers") or [] | |
| 192 | + if isinstance(t, dict) and t.get("name")] | |
| 193 | + if teachers: | |
| 194 | + payload["instructor"] = ", ".join(dict.fromkeys(teachers)) | |
| 195 | + images = [t.get("image") for t in data.get("teachers") or [] | |
| 196 | + if isinstance(t, dict) and t.get("image")] | |
| 197 | + if images: | |
| 198 | + payload["images"] = list(dict.fromkeys(images)) | |
| 199 | + | |
| 200 | + # séances datées + villes via les EducationEvent JSON-LD | |
| 201 | + sessions, cities = [], [] | |
| 202 | + for mm in _LDJSON_RE.finditer(html): | |
| 203 | + try: | |
| 204 | + obj = json.loads(mm.group(1)) | |
| 205 | + except ValueError: | |
| 206 | + continue | |
| 207 | + events = obj if isinstance(obj, list) else [obj] | |
| 208 | + for ev in events: | |
| 209 | + if not isinstance(ev, dict) or ev.get("@type") != "EducationEvent": | |
| 210 | + continue | |
| 211 | + start = str(ev.get("startDate", ""))[:10] | |
| 212 | + if re.match(r"20\d{2}-\d{2}-\d{2}", start): | |
| 213 | + sessions.append(start) | |
| 214 | + loc = ev.get("location") or {} | |
| 215 | + addr = loc.get("address") if isinstance(loc, dict) else {} | |
| 216 | + city = (addr.get("addressLocality", "") | |
| 217 | + if isinstance(addr, dict) else "") | |
| 218 | + if city and city.lower() != "virtuelle": | |
| 219 | + cities.append(city) | |
| 220 | + sessions = sorted(set(sessions)) | |
| 221 | + if sessions: | |
| 222 | + payload["sessions"] = sessions | |
| 223 | + payload["start_date"] = sessions[0] | |
| 224 | + if cities: | |
| 225 | + payload["city"] = cities[0] | |
| 226 | + if details: | |
| 227 | + payload["details"] = details | |
| 228 | + return payload | |
added
formaka/connectors/teluq.py
+187 −0
@@ -0,0 +1,187 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/teluq.py : connecteur Université TÉLUQ (teluq.ca) | |
| 5 | +# Université publique entièrement à distance — ~540 cours en ligne, tous | |
| 6 | +# crédités (1er, 2e et 3e cycles), inscription à la carte ou par programme. | |
| 7 | +# Site rendu serveur, très propre : | |
| 8 | +# - liste : /etudes/cours — un <li> par cours avec sigle | |
| 9 | +# (.listing-offre__code), titre (.listing-offre__titre a) et étiquettes | |
| 10 | +# (département, discipline, cycle, crédits) | |
| 11 | +# - fiche : sections « En bref » (h3 Objectifs / Contenu / Évaluation / | |
| 12 | +# Particularités d'inscription…), encadré « Préalables » (.messagebox), | |
| 13 | +# bloc .info-offre (crédits, cycle, département) et section | |
| 14 | +# « Responsable » (professeur). Fiches en cache, rafraîchies chaque | |
| 15 | +# semaine (clé ISO « AAAA-WSS »). | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import datetime | |
| 20 | +import re | |
| 21 | + | |
| 22 | +from bs4 import BeautifulSoup | |
| 23 | + | |
| 24 | +from ..schema import Formation, clean_text | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +BASE = "https://www.teluq.ca" | |
| 28 | +LIST_URL = f"{BASE}/etudes/cours" | |
| 29 | + | |
| 30 | +_CYCLE_RE = re.compile(r"\bcycle\b", re.I) | |
| 31 | +_CREDITS_RE = re.compile(r"cr[ée]dits?", re.I) | |
| 32 | + | |
| 33 | + | |
| 34 | +def _clean_cycle(txt: str) -> str: | |
| 35 | + """« 1 er cycle » (espace introduit par le <sup>) -> « 1er cycle ».""" | |
| 36 | + return re.sub(r"(\d)\s+(er|e)\b", r"\1\2", txt) | |
| 37 | + | |
| 38 | + | |
| 39 | +def _section_texts(h3) -> tuple[list[str], list[str]]: | |
| 40 | + """Contenu (paragraphes, items de liste) qui suit un <h3> de la fiche, | |
| 41 | + jusqu'au prochain titre de section. Retourne (paragraphes, items).""" | |
| 42 | + paras: list[str] = [] | |
| 43 | + items: list[str] = [] | |
| 44 | + for sib in h3.find_next_siblings(): | |
| 45 | + if sib.name in ("h1", "h2", "h3"): | |
| 46 | + break | |
| 47 | + if sib.name in ("ul", "ol"): | |
| 48 | + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] | |
| 49 | + elif sib.name == "p": | |
| 50 | + paras.append(clean_text(sib.get_text(" "))) | |
| 51 | + return [p for p in paras if p], [i for i in items if i] | |
| 52 | + | |
| 53 | + | |
| 54 | +class TeluqConnector(BaseConnector): | |
| 55 | + source_id = "teluq" | |
| 56 | + request_delay = 0.5 | |
| 57 | + | |
| 58 | + def fetch(self) -> list[Formation]: | |
| 59 | + html = self.fetch_html(LIST_URL) | |
| 60 | + soup = BeautifulSoup(html, "html.parser") | |
| 61 | + | |
| 62 | + # 1) Liste complète : un <li> par cours (sigle + titre + étiquettes) | |
| 63 | + week = datetime.date.today().strftime("%G-W%V") | |
| 64 | + out: list[Formation] = [] | |
| 65 | + seen: set[str] = set() | |
| 66 | + for p_titre in soup.find_all("p", class_="listing-offre__titre"): | |
| 67 | + a = p_titre.find("a", href=True) | |
| 68 | + if a is None: | |
| 69 | + continue | |
| 70 | + url = a["href"] if a["href"].startswith("http") else BASE + a["href"] | |
| 71 | + card = p_titre.find_parent("li") | |
| 72 | + code_el = card.find("p", class_="listing-offre__code") if card else None | |
| 73 | + code = clean_text(code_el.get_text(" ")) if code_el else "" | |
| 74 | + external_id = code or url.rstrip("/").rsplit("/", 1)[-1] | |
| 75 | + if external_id in seen: | |
| 76 | + continue | |
| 77 | + seen.add(external_id) | |
| 78 | + | |
| 79 | + f = Formation( | |
| 80 | + source=self.source_id, | |
| 81 | + external_id=external_id, | |
| 82 | + url=url, | |
| 83 | + title=clean_text(a.get_text(" ")), | |
| 84 | + training_type="Cours universitaire", | |
| 85 | + mode="en ligne", | |
| 86 | + language="fr", | |
| 87 | + code=code, | |
| 88 | + ) | |
| 89 | + | |
| 90 | + # étiquettes de la carte : département, discipline, cycle, crédits | |
| 91 | + for tag in (card.select("ul.tags li.tags__item") if card else []): | |
| 92 | + txt = clean_text(tag.get_text(" ")) | |
| 93 | + if not txt: | |
| 94 | + continue | |
| 95 | + if _CREDITS_RE.search(txt): | |
| 96 | + f.credits = txt | |
| 97 | + elif _CYCLE_RE.search(txt): | |
| 98 | + f.details["cycle"] = _clean_cycle(txt) | |
| 99 | + elif any(c.startswith("tags--") for c in tag.get("class", [])): | |
| 100 | + f.details["departement"] = txt | |
| 101 | + else: | |
| 102 | + f.category = txt | |
| 103 | + | |
| 104 | + # 2) Fiche détaillée — cache hebdomadaire | |
| 105 | + key = f"{week}:{f.title}" | |
| 106 | + payload = self.detail(external_id, key, | |
| 107 | + lambda u=url: self._fetch_detail(u)) | |
| 108 | + for k, v in (payload or {}).items(): | |
| 109 | + if k == "details": | |
| 110 | + f.details = {**f.details, **v} | |
| 111 | + elif hasattr(f, k) and v not in (None, "", []): | |
| 112 | + setattr(f, k, v) | |
| 113 | + out.append(f) | |
| 114 | + return out | |
| 115 | + | |
| 116 | + # -- fiche ---------------------------------------------------------------- | |
| 117 | + def _fetch_detail(self, url: str) -> dict: | |
| 118 | + if not url: | |
| 119 | + return {} | |
| 120 | + html = self.fetch_html(url) | |
| 121 | + soup = BeautifulSoup(html, "html.parser") | |
| 122 | + payload: dict = {} | |
| 123 | + details: dict = {} | |
| 124 | + | |
| 125 | + # bloc .info-offre : crédits, cycle, département | |
| 126 | + for info in soup.select(".info-offre .info"): | |
| 127 | + titre = info.find("p", class_="info-titre") | |
| 128 | + valeur = info.find("p", class_="info-valeur") | |
| 129 | + if titre is None or valeur is None: | |
| 130 | + continue | |
| 131 | + t = clean_text(titre.get_text(" ")).lower() | |
| 132 | + v = clean_text(valeur.get_text(" ")) | |
| 133 | + if "crédit" in t and v: | |
| 134 | + if v.isdigit(): | |
| 135 | + payload["credits"] = f"{v} crédit" + ("s" if int(v) > 1 else "") | |
| 136 | + else: | |
| 137 | + payload["credits"] = v | |
| 138 | + elif "cycle" in t: | |
| 139 | + details["cycle"] = _clean_cycle(v) | |
| 140 | + elif "département" in t: | |
| 141 | + details["departement"] = v | |
| 142 | + | |
| 143 | + # sections « En bref » : Objectifs, Contenu, Évaluation… | |
| 144 | + for h3 in soup.find_all("h3"): | |
| 145 | + heading = clean_text(h3.get_text(" ")).lower() | |
| 146 | + paras, items = _section_texts(h3) | |
| 147 | + if heading.startswith("objectifs"): | |
| 148 | + payload["objectives"] = items or paras | |
| 149 | + elif heading.startswith("contenu"): | |
| 150 | + if items: | |
| 151 | + payload["program"] = items | |
| 152 | + # certains cours décrivent le contenu en liste plutôt qu'en | |
| 153 | + # paragraphes — on garde quand même une description lisible | |
| 154 | + payload["description"] = ("\n\n".join(paras) or | |
| 155 | + "\n".join(f"– {i}" for i in items)) | |
| 156 | + elif heading.startswith("évaluation"): | |
| 157 | + if paras: | |
| 158 | + details["evaluation"] = " ".join(paras) | |
| 159 | + elif heading.startswith("particularités"): | |
| 160 | + if paras: | |
| 161 | + details["particularites_inscription"] = " ".join(paras) | |
| 162 | + | |
| 163 | + # encadré « Préalables » | |
| 164 | + for box in soup.select(".messagebox"): | |
| 165 | + title = box.find(["h2", "h3"]) | |
| 166 | + if title and re.search(r"pr[ée]alable", title.get_text(), re.I): | |
| 167 | + txt = clean_text(" ".join( | |
| 168 | + p.get_text(" ") for p in box.find_all("p"))) | |
| 169 | + if txt: | |
| 170 | + payload["prerequisites"] = txt | |
| 171 | + | |
| 172 | + # section « Responsable » : professeur(s) responsable(s) du cours | |
| 173 | + resp = soup.find("h2", string=re.compile(r"^\s*Responsables?\s*$", re.I)) | |
| 174 | + if resp: | |
| 175 | + names = [] | |
| 176 | + for h3 in resp.find_all_next("h3", limit=4): | |
| 177 | + name = clean_text(h3.get_text(" ")) | |
| 178 | + # on s'arrête dès qu'on retombe sur un titre de section | |
| 179 | + if not name or re.search(r"inscrire|programme|commencer", name, re.I): | |
| 180 | + break | |
| 181 | + names.append(name) | |
| 182 | + if names: | |
| 183 | + payload["instructor"] = ", ".join(dict.fromkeys(names)) | |
| 184 | + | |
| 185 | + if details: | |
| 186 | + payload["details"] = details | |
| 187 | + return payload | |
added
formaka/connectors/ulaval_distance.py
+202 −0
@@ -0,0 +1,202 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/ulaval_distance.py : connecteur Formation à distance — Université | |
| 5 | +# Laval (distance.ulaval.ca). ~1 700 cours crédités offerts à distance, | |
| 6 | +# hybrides ou comodaux (tous cycles, toutes facultés). | |
| 7 | +# Site rendu serveur, très propre : | |
| 8 | +# - liste : /etudes/cours — un lien .list__link par cours avec sigle + titre | |
| 9 | +# (.oe-title), cycle (.oe-cycle) et formule d'enseignement (aria-label) | |
| 10 | +# - fiche : Description (h2 + paragraphes), Responsables (faculté, | |
| 11 | +# département), bloc .credits-and-cycles, section Horaire (.sessions : | |
| 12 | +# sessions offertes, formule, section-groupe, NRC, enseignants) et | |
| 13 | +# programmes auxquels le cours contribue. Fiches en cache, rafraîchies | |
| 14 | +# chaque semaine (clé ISO « AAAA-WSS »). | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import datetime | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from bs4 import BeautifulSoup | |
| 22 | + | |
| 23 | +from ..schema import Formation, clean_text | |
| 24 | + | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +BASE = "https://www.distance.ulaval.ca" | |
| 28 | +LIST_URL = f"{BASE}/etudes/cours" | |
| 29 | + | |
| 30 | +_CODE_TITLE_RE = re.compile(r"^([A-Z]{2,4}-\d{4}[A-Z]?)\s+(.*)$") | |
| 31 | +_FORMULE_RE = re.compile(r"formules? d'enseignement\s*:\s*(.+)", re.I) | |
| 32 | + | |
| 33 | +# formule d'enseignement ULaval -> mode canonique Forma-Ka | |
| 34 | +_MODE_MAP = [ | |
| 35 | + (re.compile(r"hybride|comodal", re.I), "hybride"), | |
| 36 | + (re.compile(r"distance|en ligne", re.I), "en ligne"), | |
| 37 | + (re.compile(r"pr[ée]sentiel|en classe", re.I), "présentiel"), | |
| 38 | +] | |
| 39 | + | |
| 40 | + | |
| 41 | +def _mode_from(formule: str) -> str: | |
| 42 | + for rx, canon in _MODE_MAP: | |
| 43 | + if rx.search(formule or ""): | |
| 44 | + return canon | |
| 45 | + return "" | |
| 46 | + | |
| 47 | + | |
| 48 | +class UlavalDistanceConnector(BaseConnector): | |
| 49 | + source_id = "ulaval_distance" | |
| 50 | + request_delay = 0.5 | |
| 51 | + | |
| 52 | + def fetch(self) -> list[Formation]: | |
| 53 | + html = self.fetch_html(LIST_URL) | |
| 54 | + soup = BeautifulSoup(html, "html.parser") | |
| 55 | + | |
| 56 | + # 1) Liste complète (une seule page rendue serveur, ~1 700 cours) | |
| 57 | + week = datetime.date.today().strftime("%G-W%V") | |
| 58 | + out: list[Formation] = [] | |
| 59 | + seen: set[str] = set() | |
| 60 | + for a in soup.select("a.list__link[href]"): | |
| 61 | + title_el = a.find(class_="oe-title") | |
| 62 | + if title_el is None: | |
| 63 | + continue | |
| 64 | + raw_title = clean_text(title_el.get_text(" ")) | |
| 65 | + m = _CODE_TITLE_RE.match(raw_title) | |
| 66 | + code = m.group(1) if m else "" | |
| 67 | + title = m.group(2) if m else raw_title | |
| 68 | + href = a["href"] | |
| 69 | + url = href if href.startswith("http") else BASE + href | |
| 70 | + external_id = code or url.rstrip("/").rsplit("/", 1)[-1] | |
| 71 | + if external_id in seen: | |
| 72 | + continue | |
| 73 | + seen.add(external_id) | |
| 74 | + | |
| 75 | + f = Formation( | |
| 76 | + source=self.source_id, | |
| 77 | + external_id=external_id, | |
| 78 | + url=url, | |
| 79 | + title=title, | |
| 80 | + training_type="Cours universitaire", | |
| 81 | + language="fr", | |
| 82 | + code=code, | |
| 83 | + ) | |
| 84 | + cycle_el = a.find(class_="oe-cycle") | |
| 85 | + if cycle_el: | |
| 86 | + f.details["cycle"] = clean_text(cycle_el.get_text(" ")) | |
| 87 | + formula_el = a.find(class_="oe-teaching-formulas") | |
| 88 | + fm = _FORMULE_RE.search(formula_el.get("aria-label", "")) \ | |
| 89 | + if formula_el else None | |
| 90 | + if fm: | |
| 91 | + formule = clean_text(fm.group(1)) | |
| 92 | + f.details["formule_enseignement"] = formule | |
| 93 | + f.mode = _mode_from(formule) | |
| 94 | + | |
| 95 | + # 2) Fiche détaillée — cache hebdomadaire | |
| 96 | + key = f"{week}:{raw_title}" | |
| 97 | + payload = self.detail(external_id, key, | |
| 98 | + lambda u=url: self._fetch_detail(u)) | |
| 99 | + for k, v in (payload or {}).items(): | |
| 100 | + if k == "details": | |
| 101 | + f.details = {**f.details, **v} | |
| 102 | + elif hasattr(f, k) and v not in (None, "", []): | |
| 103 | + setattr(f, k, v) | |
| 104 | + out.append(f) | |
| 105 | + return out | |
| 106 | + | |
| 107 | + # -- fiche ---------------------------------------------------------------- | |
| 108 | + def _fetch_detail(self, url: str) -> dict: | |
| 109 | + html = self.fetch_html(url) | |
| 110 | + soup = BeautifulSoup(html, "html.parser") | |
| 111 | + payload: dict = {} | |
| 112 | + details: dict = {} | |
| 113 | + | |
| 114 | + # description : paragraphes qui suivent le h2 « Description » | |
| 115 | + h2 = soup.find("h2", string=re.compile(r"^\s*Description\s*$", re.I)) | |
| 116 | + if h2: | |
| 117 | + paras = [] | |
| 118 | + for sib in h2.find_next_siblings(): | |
| 119 | + if sib.name in ("h1", "h2", "h3"): | |
| 120 | + break | |
| 121 | + if sib.name == "p": | |
| 122 | + t = clean_text(sib.get_text(" ")) | |
| 123 | + # on écarte le lien « Consulter la description officielle… » | |
| 124 | + if t and not t.lower().startswith("consulter la description"): | |
| 125 | + paras.append(t) | |
| 126 | + if paras: | |
| 127 | + payload["description"] = "\n\n".join(paras) | |
| 128 | + | |
| 129 | + # crédits et cycle (bloc latéral) | |
| 130 | + cred = soup.select_one(".credits-and-cycles .credits") | |
| 131 | + if cred: | |
| 132 | + n = clean_text(cred.find("p").get_text(" ")) if cred.find("p") else "" | |
| 133 | + if n: | |
| 134 | + if n.isdigit(): | |
| 135 | + payload["credits"] = f"{n} crédit" + ("s" if int(n) > 1 else "") | |
| 136 | + else: | |
| 137 | + payload["credits"] = n | |
| 138 | + cyc = soup.select_one(".credits-and-cycles .cycles") | |
| 139 | + if cyc: | |
| 140 | + details["cycle"] = clean_text(cyc.get_text(" ")) | |
| 141 | + | |
| 142 | + # responsables : faculté (-> catégorie) et département | |
| 143 | + resp = soup.find("h2", string=re.compile(r"^\s*Responsables?\s*$", re.I)) | |
| 144 | + if resp: | |
| 145 | + block = resp.find_parent(["section", "div"]) | |
| 146 | + txt = clean_text(block.get_text(" ")) if block else "" | |
| 147 | + m = re.search(r"Faculté\s*:\s*([^:]+?)(?:\s+Courriel|\s+Département|$)", txt) | |
| 148 | + if m: | |
| 149 | + payload["category"] = clean_text(m.group(1)) | |
| 150 | + m = re.search(r"Département\s*:\s*([^:]+?)(?:\s+Courriel|$)", txt) | |
| 151 | + if m: | |
| 152 | + details["departement"] = clean_text(m.group(1)) | |
| 153 | + | |
| 154 | + # horaire : sessions offertes, formules, sections-groupes, enseignants | |
| 155 | + sched = soup.select_one(".schedule .sessions") | |
| 156 | + if sched: | |
| 157 | + sessions: list[dict] = [] | |
| 158 | + instructors: list[str] = [] | |
| 159 | + current: dict | None = None | |
| 160 | + for el in sched.find_all(["h3", "div"], recursive=False): | |
| 161 | + if el.name == "h3": | |
| 162 | + current = {"session": clean_text(el.get_text(" ")), | |
| 163 | + "sections": []} | |
| 164 | + sessions.append(current) | |
| 165 | + elif current is not None and "session" in (el.get("class") or []): | |
| 166 | + sec: dict = {} | |
| 167 | + formule = el.select_one(".teaching-formulas .text") | |
| 168 | + if formule: | |
| 169 | + sec["formule"] = clean_text(formule.get_text(" ")) | |
| 170 | + sigle = el.select_one(".sigle-tag") | |
| 171 | + if sigle: | |
| 172 | + sec["groupe"] = clean_text(sigle.get_text(" ")) | |
| 173 | + nrc = el.select_one(".c-tag") | |
| 174 | + if nrc: | |
| 175 | + sec["nrc"] = clean_text(nrc.get_text(" ")) | |
| 176 | + ens = el.find("p") | |
| 177 | + if ens: | |
| 178 | + names = clean_text(ens.get_text(" ")) | |
| 179 | + names = re.sub(r"^Enseignant\(e\)s?\s*:\s*", "", names) | |
| 180 | + if names: | |
| 181 | + sec["enseignants"] = names | |
| 182 | + instructors.append(names) | |
| 183 | + current["sections"].append(sec) | |
| 184 | + if sessions: | |
| 185 | + details["sessions_offertes"] = sessions | |
| 186 | + payload["schedule_label"] = ", ".join(s["session"] for s in sessions) | |
| 187 | + if instructors: | |
| 188 | + payload["instructor"] = ", ".join(dict.fromkeys(instructors)) | |
| 189 | + | |
| 190 | + # programmes auxquels le cours contribue | |
| 191 | + contrib = soup.find("h2", string=re.compile(r"contributoire", re.I)) | |
| 192 | + if contrib: | |
| 193 | + block = contrib.find_parent(["section", "div"]) | |
| 194 | + progs = [clean_text(a.get_text(" ")) for a in block.find_all("a")] \ | |
| 195 | + if block else [] | |
| 196 | + progs = [p for p in progs if p] | |
| 197 | + if progs: | |
| 198 | + details["programmes_lies"] = progs | |
| 199 | + | |
| 200 | + if details: | |
| 201 | + payload["details"] = details | |
| 202 | + return payload | |
added
formaka/connectors/uqam_perfectionnement.py
+225 −0
@@ -0,0 +1,225 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/uqam_perfectionnement.py : connecteur Formation continue UQAM | |
| 5 | +# (formation.uqam.ca — remplace l'ancien perfectionnement.uqam.ca, dont le | |
| 6 | +# domaine ne résout plus). ~50 formations publiques courtes (6-14 h) avec | |
| 7 | +# UEC, animées par le corps enseignant de l'UQAM. | |
| 8 | +# Site WordPress (WooCommerce + LMS) rendu serveur : | |
| 9 | +# - liste : /formations/ — grille de liens /courses/<slug>/ | |
| 10 | +# - fiche : h1 titre, h2 sous-titre, encadré Tarif, liste « Modalités » | |
| 11 | +# (Durée, Horaire, Mode de diffusion, UEC), sections h3 (Objectifs, | |
| 12 | +# Principaux éléments de contenu, Approches pédagogiques, Évaluation des | |
| 13 | +# apprentissages), « Prochaine séance » (dates) et biographie de la | |
| 14 | +# personne formatrice. Fiches en cache, rafraîchies chaque semaine | |
| 15 | +# (clé ISO « AAAA-WSS »). | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import datetime | |
| 20 | +import re | |
| 21 | + | |
| 22 | +from bs4 import BeautifulSoup | |
| 23 | + | |
| 24 | +from ..schema import Formation, clean_text, parse_date_fr | |
| 25 | + | |
| 26 | +from .base import BaseConnector | |
| 27 | + | |
| 28 | +BASE = "https://formation.uqam.ca" | |
| 29 | +LIST_URL = f"{BASE}/formations/" | |
| 30 | + | |
| 31 | +_COURSE_HREF_RE = re.compile(r"https?://formation\.uqam\.ca/courses/([^/?#]+)/?") | |
| 32 | +_UEC_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*unité", re.I) | |
| 33 | +_BIO_RE = re.compile(r"Biographie\s+(?:de\s+la|du|de\s+l['’])\s*" | |
| 34 | + r"(?:formatrice|formateur|personne formatrice)?\s*:?\s*(.*)", re.I) | |
| 35 | +_DATE_LINE_RE = re.compile(r"\b(1er|\d{1,2})\s+[a-zéûôî]+\.?\s+20\d{2}", re.I) | |
| 36 | + | |
| 37 | +# sections h3 de la fiche -> champ Formation | |
| 38 | +_SECTIONS = [ | |
| 39 | + (re.compile(r"^objectifs?", re.I), "objectives"), | |
| 40 | + (re.compile(r"principaux [ée]l[ée]ments de contenu|^contenu", re.I), "program"), | |
| 41 | + (re.compile(r"approches? p[ée]dagogiques?", re.I), "_approches"), | |
| 42 | + (re.compile(r"[ée]valuation des apprentissages", re.I), "_evaluation"), | |
| 43 | + (re.compile(r"client[èe]le", re.I), "audience"), | |
| 44 | +] | |
| 45 | + | |
| 46 | + | |
| 47 | +def _section_content(h3) -> tuple[list[str], list[str]]: | |
| 48 | + """(paragraphes, items) qui suivent un titre de section, jusqu'au suivant.""" | |
| 49 | + paras: list[str] = [] | |
| 50 | + items: list[str] = [] | |
| 51 | + for sib in h3.find_next_siblings(): | |
| 52 | + if sib.name in ("h1", "h2", "h3"): | |
| 53 | + break | |
| 54 | + if sib.name in ("ul", "ol"): | |
| 55 | + items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] | |
| 56 | + elif sib.name == "p": | |
| 57 | + paras.append(clean_text(sib.get_text(" "))) | |
| 58 | + return [p for p in paras if p], [i for i in items if i] | |
| 59 | + | |
| 60 | + | |
| 61 | +class UqamPerfectionnementConnector(BaseConnector): | |
| 62 | + source_id = "uqam_perfectionnement" | |
| 63 | + request_delay = 0.6 | |
| 64 | + | |
| 65 | + def fetch(self) -> list[Formation]: | |
| 66 | + html = self.fetch_html(LIST_URL) | |
| 67 | + soup = BeautifulSoup(html, "html.parser") | |
| 68 | + | |
| 69 | + # 1) Liste : liens /courses/<slug>/ de la grille (dédupliqués) | |
| 70 | + cards: dict[str, str] = {} # slug -> titre affiché | |
| 71 | + for a in soup.find_all("a", href=_COURSE_HREF_RE): | |
| 72 | + slug = _COURSE_HREF_RE.search(a["href"]).group(1) | |
| 73 | + title = clean_text(a.get_text(" ")) | |
| 74 | + if slug not in cards or (title and not cards[slug]): | |
| 75 | + cards[slug] = title | |
| 76 | + | |
| 77 | + # 2) Fiche détaillée par formation — cache hebdomadaire | |
| 78 | + week = datetime.date.today().strftime("%G-W%V") | |
| 79 | + out: list[Formation] = [] | |
| 80 | + for slug, title in cards.items(): | |
| 81 | + url = f"{BASE}/courses/{slug}/" | |
| 82 | + payload = self.detail(slug, f"{week}:{title}", | |
| 83 | + lambda u=url: self._fetch_detail(u)) | |
| 84 | + f = Formation( | |
| 85 | + source=self.source_id, | |
| 86 | + external_id=slug, | |
| 87 | + url=url, | |
| 88 | + title=title, | |
| 89 | + training_type="Formation continue", | |
| 90 | + language="fr", | |
| 91 | + ) | |
| 92 | + for k, v in (payload or {}).items(): | |
| 93 | + if k == "details": | |
| 94 | + f.details = {**f.details, **v} | |
| 95 | + elif hasattr(f, k) and v not in (None, "", []): | |
| 96 | + setattr(f, k, v) | |
| 97 | + out.append(f) | |
| 98 | + return out | |
| 99 | + | |
| 100 | + # -- fiche ---------------------------------------------------------------- | |
| 101 | + def _fetch_detail(self, url: str) -> dict: | |
| 102 | + html = self.fetch_html(url) | |
| 103 | + soup = BeautifulSoup(html, "html.parser") | |
| 104 | + payload: dict = {} | |
| 105 | + details: dict = {} | |
| 106 | + | |
| 107 | + h1 = soup.find("h1") | |
| 108 | + if h1: | |
| 109 | + payload["title"] = clean_text(h1.get_text(" ")) | |
| 110 | + | |
| 111 | + # tarif (WooCommerce) : « Tarif » suivi du montant | |
| 112 | + tarif = soup.find(string=re.compile(r"^\s*Tarif\s*$")) | |
| 113 | + if tarif: | |
| 114 | + block = tarif.find_parent(["div", "p", "li", "td"]) | |
| 115 | + label = clean_text(block.get_text(" ")) if block else "" | |
| 116 | + label = re.sub(r"^Tarif\s*", "", label) | |
| 117 | + if label: | |
| 118 | + label = label.replace("$CA", "$").strip() | |
| 119 | + payload["price_label"] = label | |
| 120 | + # produit à prix variable (« 26,09 $ – 490 $ ») : le premier | |
| 121 | + # montant est un acompte — le vrai tarif est le plus élevé | |
| 122 | + amounts = [float(a.replace(",", "").replace(" ", "")) | |
| 123 | + for a in re.findall(r"\$\s*([\d, ]+(?:\.\d{2})?)", label)] | |
| 124 | + if amounts: | |
| 125 | + payload["price"] = max(amounts) | |
| 126 | + | |
| 127 | + # liste « Modalités » : Durée, Horaire, Mode de diffusion, UEC… | |
| 128 | + h_mod = soup.find(["h2", "h3"], string=re.compile(r"^\s*Modalit[ée]s?", re.I)) | |
| 129 | + desc_start = None | |
| 130 | + if h_mod: | |
| 131 | + for sib in h_mod.find_next_siblings(): | |
| 132 | + if sib.name in ("h1", "h2", "h3"): | |
| 133 | + break | |
| 134 | + if sib.name in ("ul", "ol"): | |
| 135 | + for li in sib.find_all("li"): | |
| 136 | + txt = clean_text(li.get_text(" ")) | |
| 137 | + low = txt.lower() | |
| 138 | + if low.startswith("durée"): | |
| 139 | + payload["duration"] = txt.split(":", 1)[-1].strip() | |
| 140 | + elif low.startswith("horaire"): | |
| 141 | + details["horaire"] = txt.split(":", 1)[-1].strip() | |
| 142 | + elif low.startswith("mode de diffusion"): | |
| 143 | + payload["mode"] = txt.split(":", 1)[-1].strip() | |
| 144 | + elif "uec" in low or "unité" in low: | |
| 145 | + payload["credential"] = txt.split(":", 1)[-1].strip() | |
| 146 | + m = _UEC_RE.search(txt) | |
| 147 | + if m: | |
| 148 | + uec = m.group(1).replace(",", ".") | |
| 149 | + payload["credits"] = f"{uec} UEC".replace(".", ",") | |
| 150 | + elif low.startswith("nombre de personnes"): | |
| 151 | + details["taille_groupe"] = txt.split(":", 1)[-1].strip() | |
| 152 | + desc_start = sib | |
| 153 | + break | |
| 154 | + | |
| 155 | + # description : paragraphes entre la liste des modalités et « Objectifs » | |
| 156 | + if desc_start is not None: | |
| 157 | + paras = [] | |
| 158 | + for sib in desc_start.find_next_siblings(): | |
| 159 | + if sib.name in ("h1", "h2", "h3"): | |
| 160 | + break | |
| 161 | + if sib.name == "p": | |
| 162 | + t = clean_text(sib.get_text(" ")) | |
| 163 | + if t: | |
| 164 | + paras.append(t) | |
| 165 | + if paras: | |
| 166 | + payload["description"] = "\n\n".join(paras) | |
| 167 | + | |
| 168 | + # sous-titre (h2 sous le h1) : complète la description | |
| 169 | + if h1: | |
| 170 | + h2 = h1.find_next("h2") | |
| 171 | + if h2: | |
| 172 | + sub = clean_text(h2.get_text(" ")) | |
| 173 | + if sub and not re.search(r"modalit|séance|tarif|attestation|statut", | |
| 174 | + sub, re.I): | |
| 175 | + details["sous_titre"] = sub | |
| 176 | + | |
| 177 | + # sections h3 : objectifs, contenu, approches, évaluation, clientèle | |
| 178 | + for h3 in soup.find_all("h3"): | |
| 179 | + heading = clean_text(h3.get_text(" ")) | |
| 180 | + for rx, field in _SECTIONS: | |
| 181 | + if not rx.search(heading): | |
| 182 | + continue | |
| 183 | + paras, items = _section_content(h3) | |
| 184 | + if field == "objectives": | |
| 185 | + payload["objectives"] = items or paras | |
| 186 | + elif field == "program": | |
| 187 | + payload["program"] = items or paras | |
| 188 | + elif field == "audience": | |
| 189 | + payload["audience"] = " ".join(paras) or "; ".join(items) | |
| 190 | + elif field == "_approches": | |
| 191 | + if items or paras: | |
| 192 | + details["approches_pedagogiques"] = items or paras | |
| 193 | + elif field == "_evaluation": | |
| 194 | + if items: | |
| 195 | + details["evaluation"] = items | |
| 196 | + break | |
| 197 | + | |
| 198 | + # biographie -> personne formatrice | |
| 199 | + m = _BIO_RE.match(heading) | |
| 200 | + if m and m.group(1): | |
| 201 | + payload["instructor"] = clean_text(m.group(1)) | |
| 202 | + | |
| 203 | + # « Prochaine séance » : dates des prochaines cohortes (si affichées) | |
| 204 | + h_seance = soup.find(string=re.compile(r"Prochaines? s[ée]ances?", re.I)) | |
| 205 | + if h_seance: | |
| 206 | + block = h_seance.find_parent(["div", "section"]) | |
| 207 | + if block: | |
| 208 | + txt = clean_text(block.get_text(" ")) | |
| 209 | + dates = [] | |
| 210 | + for dm in _DATE_LINE_RE.finditer(txt): | |
| 211 | + iso = parse_date_fr(dm.group(0)) | |
| 212 | + if iso: | |
| 213 | + dates.append(iso) | |
| 214 | + if dates: | |
| 215 | + payload["sessions"] = sorted(set(dates)) | |
| 216 | + payload["start_date"] = payload["sessions"][0] | |
| 217 | + payload["schedule_label"] = txt[:200] | |
| 218 | + | |
| 219 | + # lieu : les formations en personne se donnent au campus de l'UQAM | |
| 220 | + if re.search(r"en personne|présentiel", str(payload.get("mode", "")), re.I): | |
| 221 | + payload["city"] = "Montréal" | |
| 222 | + | |
| 223 | + if details: | |
| 224 | + payload["details"] = details | |
| 225 | + return payload | |
added
formaka/connectors/versalys.py
+201 −0
@@ -0,0 +1,201 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/versalys.py : connecteur Versalys (versalys.com) | |
| 5 | +# Firme de formation bureautique, TI, langues et développement professionnel | |
| 6 | +# (Montréal, Québec, Laval, Brossard + classes virtuelles) — ~260 fiches | |
| 7 | +# françaises. Site WordPress rendu serveur : | |
| 8 | +# - liste : sitemap dédié formation-sitemap1.xml (URLs /formation/<slug>/ | |
| 9 | +# avec lastmod — utilisé comme clé de cache : la fiche n'est revisitée | |
| 10 | +# que si elle a été modifiée chez la source) | |
| 11 | +# - fiche : sections HTML (Description, Prérequis, Éléments du contenu de | |
| 12 | +# la formation en <h3>), encadré Durée / Tarif régulier / Tarif | |
| 13 | +# préférentiel, prochaines dates par ville (blocs event-date-item avec | |
| 14 | +# JSON encodé), fil d'Ariane BreadcrumbList JSON-LD pour la catégorie. | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import datetime | |
| 19 | +import json | |
| 20 | +import re | |
| 21 | +from urllib.parse import unquote_plus | |
| 22 | + | |
| 23 | +from bs4 import BeautifulSoup | |
| 24 | + | |
| 25 | +from ..schema import Formation, clean_text, parse_price | |
| 26 | +from .base import BaseConnector, ldjson_objects | |
| 27 | + | |
| 28 | +BASE = "https://www.versalys.com" | |
| 29 | +SITEMAP_URL = f"{BASE}/formation-sitemap1.xml" | |
| 30 | +LIST_URL = f"{BASE}/formations/" | |
| 31 | + | |
| 32 | +_URL_RE = re.compile(r"<url>\s*<loc>(https://www\.versalys\.com/formation/" | |
| 33 | + r"([^<]+?)/?)</loc>(?:\s*<lastmod>([^<]+)</lastmod>)?") | |
| 34 | +# sigle du cours à la fin du slug (« …-wo-039 » -> « WO-039 ») | |
| 35 | +_CODE_RE = re.compile(r"-([a-z]{2})-?(\d{3})$") | |
| 36 | +_SIDEBAR_RE = re.compile( | |
| 37 | + r"<strong>\s*(Durée|Tarif régulier|Tarif préférentiel)\s*:?\s*</strong>" | |
| 38 | + r"\s*:?\s*([^<]*)", re.I) | |
| 39 | + | |
| 40 | + | |
| 41 | +def _section_text(soup: BeautifulSoup, heading_rx: str) -> str: | |
| 42 | + """Paragraphes qui suivent un <h2> donné, jusqu'au <h2> suivant.""" | |
| 43 | + h = soup.find("h2", string=re.compile(heading_rx, re.I)) | |
| 44 | + if h is None: | |
| 45 | + return "" | |
| 46 | + parts: list[str] = [] | |
| 47 | + for sib in h.find_all_next(["p", "li", "h2"]): | |
| 48 | + if sib.name == "h2": | |
| 49 | + break | |
| 50 | + txt = clean_text(sib.get_text(" ")) | |
| 51 | + if txt: | |
| 52 | + parts.append(txt) | |
| 53 | + return "\n".join(dict.fromkeys(parts)) | |
| 54 | + | |
| 55 | + | |
| 56 | +class VersalysConnector(BaseConnector): | |
| 57 | + source_id = "versalys" | |
| 58 | + request_delay = 0.5 | |
| 59 | + limit: int | None = None # borne optionnelle (tests/débogage) | |
| 60 | + | |
| 61 | + def fetch(self) -> list[Formation]: | |
| 62 | + # 1) Fiches françaises du sitemap dédié (avec date de modification) | |
| 63 | + xml = self.get(SITEMAP_URL).text | |
| 64 | + pages = [(url, slug.strip("/"), lastmod) | |
| 65 | + for url, slug, lastmod in _URL_RE.findall(xml)] | |
| 66 | + if self.limit: | |
| 67 | + pages = pages[: self.limit] | |
| 68 | + | |
| 69 | + # 2) Fiche par formation — cache invalidé par lastmod du sitemap | |
| 70 | + week = datetime.date.today().strftime("%G-W%V") | |
| 71 | + out: list[Formation] = [] | |
| 72 | + for url, slug, lastmod in pages: | |
| 73 | + payload = self.detail(slug, lastmod or week, | |
| 74 | + lambda u=url: self._fetch_detail(u)) | |
| 75 | + m = _CODE_RE.search(slug) | |
| 76 | + f = Formation( | |
| 77 | + source=self.source_id, | |
| 78 | + external_id=slug, | |
| 79 | + url=url, | |
| 80 | + training_type="Formation continue", | |
| 81 | + language="fr", | |
| 82 | + code=f"{m.group(1).upper()}-{m.group(2)}" if m else "", | |
| 83 | + ) | |
| 84 | + for k, v in (payload or {}).items(): | |
| 85 | + if hasattr(f, k) and v not in (None, "", []): | |
| 86 | + setattr(f, k, v) | |
| 87 | + out.append(f) | |
| 88 | + return out | |
| 89 | + | |
| 90 | + # -- fiche ---------------------------------------------------------------- | |
| 91 | + def _fetch_detail(self, url: str) -> dict: | |
| 92 | + html = self.fetch_html(url) | |
| 93 | + soup = BeautifulSoup(html, "html.parser") | |
| 94 | + payload: dict = {} | |
| 95 | + details: dict = {} | |
| 96 | + | |
| 97 | + h1 = soup.find("h1") | |
| 98 | + if h1: | |
| 99 | + payload["title"] = clean_text(h1.get_text(" ")) | |
| 100 | + | |
| 101 | + # sections rédactionnelles de la fiche | |
| 102 | + desc = _section_text(soup, r"^\s*description\s*$") | |
| 103 | + if desc: | |
| 104 | + payload["description"] = desc | |
| 105 | + prereq = _section_text(soup, r"pr[ée]requis") | |
| 106 | + if prereq: | |
| 107 | + payload["prerequisites"] = prereq | |
| 108 | + | |
| 109 | + # plan de cours : <h3> entre « Éléments du contenu » et l'encadré dates | |
| 110 | + h = soup.find("h2", string=re.compile(r"[ée]l[ée]ments du contenu", re.I)) | |
| 111 | + if h: | |
| 112 | + program = [] | |
| 113 | + for sib in h.find_all_next("h3"): | |
| 114 | + txt = clean_text(sib.get_text(" ")) | |
| 115 | + if not txt or re.search(r"PROCHAINE", txt): | |
| 116 | + break | |
| 117 | + program.append(txt.rstrip(" :;")) | |
| 118 | + if program: | |
| 119 | + payload["program"] = program | |
| 120 | + | |
| 121 | + # encadré : durée + tarifs | |
| 122 | + for label, value in _SIDEBAR_RE.findall(html): | |
| 123 | + value = clean_text(value) | |
| 124 | + if not value: | |
| 125 | + continue | |
| 126 | + key = label.lower() | |
| 127 | + if "durée" in key: | |
| 128 | + payload["duration"] = value | |
| 129 | + elif "régulier" in key: | |
| 130 | + payload["price"] = parse_price(value) | |
| 131 | + payload["price_label"] = value | |
| 132 | + elif "préférentiel" in key: | |
| 133 | + details["prix_preferentiel"] = parse_price(value) | |
| 134 | + | |
| 135 | + # prochaines dates par ville (JSON encodé dans des champs cachés) | |
| 136 | + sessions, cities = [], [] | |
| 137 | + for item in soup.find_all("div", class_="event-date-item"): | |
| 138 | + inp = item.find("input", attrs={"name": "event_date_time[]"}) | |
| 139 | + try: | |
| 140 | + slots = json.loads(unquote_plus(inp["value"])) if inp else {} | |
| 141 | + except (ValueError, KeyError): | |
| 142 | + slots = {} | |
| 143 | + for slot in slots.values(): | |
| 144 | + start = str(slot.get("startDate", ""))[:10] | |
| 145 | + if re.match(r"20\d{2}-\d{2}-\d{2}", start): | |
| 146 | + sessions.append(start) | |
| 147 | + h3 = item.find("h3") | |
| 148 | + if h3: | |
| 149 | + city = clean_text(h3.get_text(" ")) | |
| 150 | + if city: | |
| 151 | + cities.append(city) | |
| 152 | + sessions = sorted(set(sessions)) | |
| 153 | + if sessions: | |
| 154 | + payload["sessions"] = sessions | |
| 155 | + payload["start_date"] = sessions[0] | |
| 156 | + cities = list(dict.fromkeys(cities)) | |
| 157 | + modes = ["en ligne" if "virtuelle" in c.lower() else "présentiel" | |
| 158 | + for c in cities] | |
| 159 | + modes = list(dict.fromkeys(modes)) | |
| 160 | + real_cities = [c for c in cities if "virtuelle" not in c.lower()] | |
| 161 | + if real_cities: | |
| 162 | + payload["city"] = real_cities[0] | |
| 163 | + | |
| 164 | + # fil d'Ariane JSON-LD : catégorie + mode (eLearning, classe virtuelle) | |
| 165 | + for obj in ldjson_objects(html): | |
| 166 | + if obj.get("@type") == "BreadcrumbList": | |
| 167 | + names = [clean_text(it.get("name", "")) | |
| 168 | + for it in obj.get("itemListElement", []) | |
| 169 | + if isinstance(it, dict)] | |
| 170 | + names = [n for n in names[1:] if n] # sans « Accueil » | |
| 171 | + if names: | |
| 172 | + names.pop() # le dernier item = la page courante | |
| 173 | + if names: | |
| 174 | + payload["category"] = names[-1] | |
| 175 | + if len(names) > 1: | |
| 176 | + payload["tags"] = names[:-1] | |
| 177 | + crumb = " ".join(names).lower() | |
| 178 | + if "conférence" in crumb: | |
| 179 | + payload["training_type"] = "Conférence" | |
| 180 | + elif "atelier" in crumb: | |
| 181 | + payload["training_type"] = "Atelier" | |
| 182 | + if "elearning" in crumb or "e-learning" in crumb: | |
| 183 | + modes = ["asynchrone"] | |
| 184 | + elif not modes and "virtuelle" in crumb: | |
| 185 | + modes = ["en ligne"] | |
| 186 | + elif obj.get("@type") == "Course": | |
| 187 | + if (len(payload.get("description", "")) < 30 | |
| 188 | + and obj.get("description")): | |
| 189 | + payload["description"] = clean_text(obj["description"]) | |
| 190 | + if obj.get("inLanguage", "").lower().startswith("en"): | |
| 191 | + payload["language"] = "en" | |
| 192 | + if modes: | |
| 193 | + payload["mode"] = modes[0] if len(modes) == 1 else "hybride" | |
| 194 | + details["modes_offerts"] = modes | |
| 195 | + | |
| 196 | + og = soup.find("meta", property="og:image") | |
| 197 | + if og and og.get("content"): | |
| 198 | + payload["images"] = [og["content"]] | |
| 199 | + if details: | |
| 200 | + payload["details"] = details | |
| 201 | + return payload | |
added
formaka/db.py
+325 −0
@@ -0,0 +1,325 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# db.py : persistance SQLite — upsert avec détection de changements, | |
| 5 | +# cycle de vie avec délai de grâce (2 syncs), détection de dérive, | |
| 6 | +# cache des pages détail, historique de prix (quand un prix existe). | |
| 7 | +# ----------------------------------------------------------------------------- | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import json | |
| 11 | +import sqlite3 | |
| 12 | +import statistics | |
| 13 | +import time | |
| 14 | +from pathlib import Path | |
| 15 | + | |
| 16 | +from .schema import Formation | |
| 17 | + | |
| 18 | +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "formaka.db" | |
| 19 | + | |
| 20 | +# Nombre d'exécutions consécutives où une formation doit être absente de la | |
| 21 | +# source avant d'être désactivée (délai de grâce contre les ratés ponctuels). | |
| 22 | +MISS_GRACE = 2 | |
| 23 | + | |
| 24 | +# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique | |
| 25 | +# (médiane >= DRIFT_MIN_BASE formations), on alerte et on suspend les retraits. | |
| 26 | +DRIFT_RATIO = 0.25 | |
| 27 | +DRIFT_MIN_BASE = 8 | |
| 28 | +DRIFT_HISTORY = 5 | |
| 29 | + | |
| 30 | +_SCHEMA = """ | |
| 31 | +CREATE TABLE IF NOT EXISTS formations ( | |
| 32 | + uid TEXT PRIMARY KEY, | |
| 33 | + source TEXT NOT NULL, | |
| 34 | + external_id TEXT NOT NULL, | |
| 35 | + url TEXT, | |
| 36 | + title TEXT, | |
| 37 | + training_type TEXT, | |
| 38 | + category TEXT, | |
| 39 | + mode TEXT, | |
| 40 | + city TEXT, | |
| 41 | + language TEXT, | |
| 42 | + price REAL, | |
| 43 | + price_label TEXT, | |
| 44 | + is_free INTEGER, | |
| 45 | + duration TEXT, | |
| 46 | + duration_hours REAL, | |
| 47 | + start_date TEXT, | |
| 48 | + schedule_label TEXT, | |
| 49 | + sessions TEXT, -- JSON (dates ISO) | |
| 50 | + level TEXT, | |
| 51 | + credits TEXT, | |
| 52 | + credential TEXT, | |
| 53 | + instructor TEXT, | |
| 54 | + code TEXT, | |
| 55 | + description TEXT, | |
| 56 | + objectives TEXT, -- JSON | |
| 57 | + prerequisites TEXT, | |
| 58 | + audience TEXT, | |
| 59 | + program TEXT, -- JSON (plan de cours) | |
| 60 | + tags TEXT, -- JSON | |
| 61 | + details TEXT, -- JSON (champs structurés : uec, crédits, niveau…) | |
| 62 | + images TEXT, -- JSON | |
| 63 | + content_hash TEXT, | |
| 64 | + first_seen REAL, | |
| 65 | + last_seen REAL, | |
| 66 | + updated_at REAL, | |
| 67 | + miss_count INTEGER DEFAULT 0, | |
| 68 | + active INTEGER DEFAULT 1 | |
| 69 | +); | |
| 70 | +CREATE INDEX IF NOT EXISTS idx_formations_source ON formations(source); | |
| 71 | +CREATE INDEX IF NOT EXISTS idx_formations_type ON formations(training_type); | |
| 72 | +CREATE INDEX IF NOT EXISTS idx_formations_active ON formations(active); | |
| 73 | + | |
| 74 | +CREATE TABLE IF NOT EXISTS sync_log ( | |
| 75 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 76 | + source TEXT, | |
| 77 | + ts REAL, | |
| 78 | + found INTEGER, | |
| 79 | + added INTEGER, | |
| 80 | + updated INTEGER, | |
| 81 | + removed INTEGER, | |
| 82 | + ok INTEGER, | |
| 83 | + message TEXT, | |
| 84 | + stats TEXT -- JSON : taux de champs null, missed, alerte… | |
| 85 | +); | |
| 86 | + | |
| 87 | +CREATE TABLE IF NOT EXISTS detail_cache ( | |
| 88 | + source TEXT NOT NULL, | |
| 89 | + external_id TEXT NOT NULL, | |
| 90 | + key TEXT, -- hash du contenu « liste » de la formation | |
| 91 | + payload TEXT, -- JSON opaque propre au connecteur | |
| 92 | + fetched_at REAL, | |
| 93 | + PRIMARY KEY (source, external_id) | |
| 94 | +); | |
| 95 | + | |
| 96 | +CREATE TABLE IF NOT EXISTS price_log ( | |
| 97 | + uid TEXT NOT NULL, | |
| 98 | + ts REAL NOT NULL, | |
| 99 | + price REAL -- prix observé (NULL = retiré de l'affichage) | |
| 100 | +); | |
| 101 | +CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid); | |
| 102 | +""" | |
| 103 | + | |
| 104 | +# Colonnes ajoutées après la v1 — migration automatique des bases existantes. | |
| 105 | +_MIGRATIONS: dict[str, dict[str, str]] = { | |
| 106 | + "formations": {}, | |
| 107 | + "sync_log": {}, | |
| 108 | +} | |
| 109 | + | |
| 110 | + | |
| 111 | +def connect() -> sqlite3.Connection: | |
| 112 | + DB_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| 113 | + con = sqlite3.connect(DB_PATH) | |
| 114 | + con.row_factory = sqlite3.Row | |
| 115 | + con.executescript(_SCHEMA) | |
| 116 | + for table, cols in _MIGRATIONS.items(): | |
| 117 | + if not cols: | |
| 118 | + continue | |
| 119 | + existing = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")} | |
| 120 | + for col, decl in cols.items(): | |
| 121 | + if col not in existing: | |
| 122 | + con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}") | |
| 123 | + con.commit() | |
| 124 | + return con | |
| 125 | + | |
| 126 | + | |
| 127 | +# --------------------------------------------------------------------------- | |
| 128 | +# Synchronisation d'une source | |
| 129 | +# --------------------------------------------------------------------------- | |
| 130 | + | |
| 131 | +def _drift_alert(con: sqlite3.Connection, source: str, found: int, | |
| 132 | + null_desc_rate: float) -> str | None: | |
| 133 | + """Détecte une dérive du connecteur (chute du volume ou des descriptions). | |
| 134 | + | |
| 135 | + Le prix étant optionnel dans le domaine de la formation, la dérive se | |
| 136 | + mesure sur le volume et sur la richesse des fiches (description vide). | |
| 137 | + """ | |
| 138 | + hist = con.execute( | |
| 139 | + "SELECT found, stats FROM sync_log WHERE source=? AND ok=1" | |
| 140 | + " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall() | |
| 141 | + if len(hist) < 3: | |
| 142 | + return None | |
| 143 | + med_found = statistics.median(r["found"] for r in hist) | |
| 144 | + if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found: | |
| 145 | + return (f"dérive: {found} formation(s) trouvée(s) contre une médiane de " | |
| 146 | + f"{med_found:.0f} — retraits suspendus, vérifier le connecteur") | |
| 147 | + if found >= DRIFT_MIN_BASE and null_desc_rate >= 0.8: | |
| 148 | + rates = [] | |
| 149 | + for r in hist: | |
| 150 | + try: | |
| 151 | + rates.append(json.loads(r["stats"] or "{}")["null_desc_rate"]) | |
| 152 | + except (KeyError, ValueError, TypeError): | |
| 153 | + continue | |
| 154 | + if rates and statistics.median(rates) <= 0.3: | |
| 155 | + return (f"dérive: {null_desc_rate:.0%} des formations sans description " | |
| 156 | + f"(habituellement {statistics.median(rates):.0%}) — " | |
| 157 | + "le format de la source a probablement changé") | |
| 158 | + return None | |
| 159 | + | |
| 160 | + | |
| 161 | +def sync_source(con: sqlite3.Connection, source: str, | |
| 162 | + formations: list[Formation]) -> dict: | |
| 163 | + """Synchronise les formations d'une source. | |
| 164 | + | |
| 165 | + - nouvelle formation -> insertion | |
| 166 | + - formation modifiée -> mise à jour (comparaison de content_hash) | |
| 167 | + - formation disparue -> miss_count += 1, puis active=0 après MISS_GRACE | |
| 168 | + exécutions consécutives (délai de grâce) | |
| 169 | + - dérive détectée -> alerte consignée, retraits suspendus | |
| 170 | + """ | |
| 171 | + now = time.time() | |
| 172 | + added = updated = 0 | |
| 173 | + seen_uids = set() | |
| 174 | + | |
| 175 | + n = len(formations) | |
| 176 | + null_desc = sum(1 for f in formations if not f.description) | |
| 177 | + null_title = sum(1 for f in formations if not f.title) | |
| 178 | + null_desc_rate = round(null_desc / n, 3) if n else 0.0 | |
| 179 | + | |
| 180 | + alert = _drift_alert(con, source, n, null_desc_rate) | |
| 181 | + | |
| 182 | + for fmt in formations: | |
| 183 | + seen_uids.add(fmt.uid) | |
| 184 | + h = fmt.content_hash() | |
| 185 | + row = con.execute("SELECT content_hash, price FROM formations WHERE uid=?", | |
| 186 | + (fmt.uid,)).fetchone() | |
| 187 | + params = dict( | |
| 188 | + uid=fmt.uid, source=fmt.source, external_id=fmt.external_id, | |
| 189 | + url=fmt.url, title=fmt.title, training_type=fmt.training_type, | |
| 190 | + category=fmt.category, mode=fmt.mode, city=fmt.city, | |
| 191 | + language=fmt.language, price=fmt.price, price_label=fmt.price_label, | |
| 192 | + is_free=(None if fmt.is_free is None else int(fmt.is_free)), | |
| 193 | + duration=fmt.duration, duration_hours=fmt.duration_hours, | |
| 194 | + start_date=fmt.start_date, schedule_label=fmt.schedule_label, | |
| 195 | + sessions=json.dumps(fmt.sessions, ensure_ascii=False), | |
| 196 | + level=fmt.level, credits=fmt.credits, credential=fmt.credential, | |
| 197 | + instructor=fmt.instructor, code=fmt.code, | |
| 198 | + description=fmt.description, | |
| 199 | + objectives=json.dumps(fmt.objectives, ensure_ascii=False), | |
| 200 | + prerequisites=fmt.prerequisites, audience=fmt.audience, | |
| 201 | + program=json.dumps(fmt.program, ensure_ascii=False), | |
| 202 | + tags=json.dumps(fmt.tags, ensure_ascii=False), | |
| 203 | + details=json.dumps(fmt.details, ensure_ascii=False), | |
| 204 | + images=json.dumps(fmt.images, ensure_ascii=False), | |
| 205 | + content_hash=h, now=now, | |
| 206 | + ) | |
| 207 | + if row is None: | |
| 208 | + con.execute( | |
| 209 | + """INSERT INTO formations (uid, source, external_id, url, title, | |
| 210 | + training_type, category, mode, city, language, price, | |
| 211 | + price_label, is_free, duration, duration_hours, start_date, | |
| 212 | + schedule_label, sessions, level, credits, credential, | |
| 213 | + instructor, code, description, objectives, prerequisites, | |
| 214 | + audience, program, tags, details, images, content_hash, | |
| 215 | + first_seen, last_seen, updated_at, miss_count, active) | |
| 216 | + VALUES (:uid,:source,:external_id,:url,:title,:training_type, | |
| 217 | + :category,:mode,:city,:language,:price,:price_label,:is_free, | |
| 218 | + :duration,:duration_hours,:start_date,:schedule_label, | |
| 219 | + :sessions,:level,:credits,:credential,:instructor,:code, | |
| 220 | + :description,:objectives,:prerequisites,:audience,:program, | |
| 221 | + :tags,:details,:images,:content_hash,:now,:now,:now,0,1)""", | |
| 222 | + params) | |
| 223 | + if fmt.price is not None: # prix initial = départ de l'historique | |
| 224 | + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)", | |
| 225 | + (fmt.uid, now, fmt.price)) | |
| 226 | + added += 1 | |
| 227 | + elif row["content_hash"] != h: | |
| 228 | + con.execute( | |
| 229 | + """UPDATE formations SET url=:url, title=:title, | |
| 230 | + training_type=:training_type, category=:category, mode=:mode, | |
| 231 | + city=:city, language=:language, price=:price, | |
| 232 | + price_label=:price_label, is_free=:is_free, | |
| 233 | + duration=:duration, duration_hours=:duration_hours, | |
| 234 | + start_date=:start_date, schedule_label=:schedule_label, | |
| 235 | + sessions=:sessions, level=:level, credits=:credits, | |
| 236 | + credential=:credential, instructor=:instructor, code=:code, | |
| 237 | + description=:description, objectives=:objectives, | |
| 238 | + prerequisites=:prerequisites, audience=:audience, | |
| 239 | + program=:program, tags=:tags, details=:details, | |
| 240 | + images=:images, content_hash=:content_hash, last_seen=:now, | |
| 241 | + updated_at=:now, miss_count=0, active=1 | |
| 242 | + WHERE uid=:uid""", params) | |
| 243 | + if fmt.price != row["price"]: # changement de prix -> historique | |
| 244 | + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)", | |
| 245 | + (fmt.uid, now, fmt.price)) | |
| 246 | + updated += 1 | |
| 247 | + else: | |
| 248 | + con.execute( | |
| 249 | + "UPDATE formations SET last_seen=?, miss_count=0, active=1 WHERE uid=?", | |
| 250 | + (now, fmt.uid)) | |
| 251 | + | |
| 252 | + # Formations de cette source qui n'apparaissent plus : délai de grâce, | |
| 253 | + # puis désactivation. Suspendu si une dérive est détectée. | |
| 254 | + removed = missed = 0 | |
| 255 | + if not alert: | |
| 256 | + for r in con.execute( | |
| 257 | + "SELECT uid, miss_count FROM formations WHERE source=? AND active=1", | |
| 258 | + (source,)).fetchall(): | |
| 259 | + if r["uid"] in seen_uids: | |
| 260 | + continue | |
| 261 | + missed += 1 | |
| 262 | + if r["miss_count"] + 1 >= MISS_GRACE: | |
| 263 | + con.execute( | |
| 264 | + "UPDATE formations SET active=0, miss_count=?, updated_at=?" | |
| 265 | + " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"])) | |
| 266 | + removed += 1 | |
| 267 | + else: | |
| 268 | + con.execute("UPDATE formations SET miss_count=miss_count+1 WHERE uid=?", | |
| 269 | + (r["uid"],)) | |
| 270 | + | |
| 271 | + stats = { | |
| 272 | + "null_desc_rate": null_desc_rate, | |
| 273 | + "null_title_rate": round(null_title / n, 3) if n else 0.0, | |
| 274 | + "missed": missed, | |
| 275 | + } | |
| 276 | + if alert: | |
| 277 | + stats["alert"] = alert | |
| 278 | + con.execute( | |
| 279 | + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok," | |
| 280 | + " message, stats) VALUES (?,?,?,?,?,?,1,?,?)", | |
| 281 | + (source, now, n, added, updated, removed, alert or "ok", | |
| 282 | + json.dumps(stats, ensure_ascii=False))) | |
| 283 | + con.commit() | |
| 284 | + out = {"source": source, "found": n, "added": added, | |
| 285 | + "updated": updated, "removed": removed} | |
| 286 | + if alert: | |
| 287 | + out["alert"] = alert | |
| 288 | + return out | |
| 289 | + | |
| 290 | + | |
| 291 | +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None: | |
| 292 | + con.execute( | |
| 293 | + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)" | |
| 294 | + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message)) | |
| 295 | + con.commit() | |
| 296 | + | |
| 297 | + | |
| 298 | +# --------------------------------------------------------------------------- | |
| 299 | +# Cache des pages détail (« détail si nouveau/modifié ») | |
| 300 | +# --------------------------------------------------------------------------- | |
| 301 | + | |
| 302 | +def get_cached_detail(con: sqlite3.Connection, source: str, | |
| 303 | + external_id: str, key: str) -> dict | None: | |
| 304 | + """Payload détail mis en cache si la clé (hash liste) n'a pas changé.""" | |
| 305 | + row = con.execute( | |
| 306 | + "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?", | |
| 307 | + (source, external_id)).fetchone() | |
| 308 | + if row and row["key"] == key and row["payload"]: | |
| 309 | + try: | |
| 310 | + return json.loads(row["payload"]) | |
| 311 | + except ValueError: | |
| 312 | + return None | |
| 313 | + return None | |
| 314 | + | |
| 315 | + | |
| 316 | +def put_cached_detail(con: sqlite3.Connection, source: str, | |
| 317 | + external_id: str, key: str, payload: dict) -> None: | |
| 318 | + con.execute( | |
| 319 | + "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)" | |
| 320 | + " VALUES (?,?,?,?,?)" | |
| 321 | + " ON CONFLICT(source, external_id) DO UPDATE SET" | |
| 322 | + " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at", | |
| 323 | + (source, external_id, key, json.dumps(payload, ensure_ascii=False), | |
| 324 | + time.time())) | |
| 325 | + con.commit() | |
added
formaka/ingest.py
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# ingest.py : pipeline d'ingestion — exécute les connecteurs et synchronise | |
| 5 | +# la base (ajouts / mises à jour / retraits) = contenu toujours à jour | |
| 6 | +# ----------------------------------------------------------------------------- | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +import sys | |
| 10 | +import time | |
| 11 | +import traceback | |
| 12 | + | |
| 13 | +from . import db | |
| 14 | +from .connectors import CONNECTORS | |
| 15 | + | |
| 16 | + | |
| 17 | +def run(sources: list[str] | None = None) -> list[dict]: | |
| 18 | + """Exécute l'ingestion pour toutes les sources (ou celles demandées).""" | |
| 19 | + con = db.connect() | |
| 20 | + results = [] | |
| 21 | + targets = sources or list(CONNECTORS.keys()) | |
| 22 | + for sid in targets: | |
| 23 | + cls = CONNECTORS.get(sid) | |
| 24 | + if cls is None: | |
| 25 | + print(f"[forma-ka] connecteur inconnu : {sid}", file=sys.stderr) | |
| 26 | + continue | |
| 27 | + t0 = time.time() | |
| 28 | + print(f"[forma-ka] sync {sid} ...") | |
| 29 | + try: | |
| 30 | + formations = cls().fetch() | |
| 31 | + finalized, dropped = [], 0 | |
| 32 | + for fmt in formations: | |
| 33 | + try: | |
| 34 | + finalized.append(fmt.finalize()) | |
| 35 | + except Exception: # une fiche malformée ne bloque pas la source | |
| 36 | + dropped += 1 | |
| 37 | + stats = db.sync_source(con, sid, finalized) | |
| 38 | + stats["seconds"] = round(time.time() - t0, 1) | |
| 39 | + if dropped: | |
| 40 | + stats["dropped"] = dropped | |
| 41 | + if stats.get("alert"): | |
| 42 | + print(f"[forma-ka] ⚠ ALERTE {sid} : {stats['alert']}") | |
| 43 | + print(f"[forma-ka] {stats}") | |
| 44 | + results.append(stats) | |
| 45 | + except Exception as exc: # robustesse : une source ne bloque pas les autres | |
| 46 | + db.log_failure(con, sid, f"{exc}") | |
| 47 | + traceback.print_exc() | |
| 48 | + results.append({"source": sid, "error": str(exc)}) | |
| 49 | + con.close() | |
| 50 | + return results | |
| 51 | + | |
| 52 | + | |
| 53 | +def watch(interval_seconds: int = 3600) -> None: | |
| 54 | + """Boucle de rafraîchissement périodique (pseudo-webhook par sondage).""" | |
| 55 | + while True: | |
| 56 | + run() | |
| 57 | + print(f"[forma-ka] prochaine synchronisation dans {interval_seconds}s") | |
| 58 | + time.sleep(interval_seconds) | |
| 59 | + | |
| 60 | + | |
| 61 | +if __name__ == "__main__": | |
| 62 | + run(sys.argv[1:] or None) | |
added
formaka/normalize.py
+258 −0
@@ -0,0 +1,258 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# normalize.py : couche de normalisation commune — chaque connecteur remplit | |
| 5 | +# des champs bruts, cette couche les canonise (prix optionnel, durées en | |
| 6 | +# heures, modes de diffusion, types de formation, dates ISO, langues…). | |
| 7 | +# ----------------------------------------------------------------------------- | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import re | |
| 11 | +import unicodedata | |
| 12 | + | |
| 13 | +__all__ = [ | |
| 14 | + "strip_accents", "clean_text", "parse_price", "price_is_from", | |
| 15 | + "parse_duration_hours", "normalize_mode", "normalize_type", | |
| 16 | + "normalize_language", "parse_date_fr", "extract_details", "merge_details", | |
| 17 | +] | |
| 18 | + | |
| 19 | + | |
| 20 | +def strip_accents(s: str) -> str: | |
| 21 | + return "".join(c for c in unicodedata.normalize("NFD", s or "") | |
| 22 | + if unicodedata.category(c) != "Mn") | |
| 23 | + | |
| 24 | + | |
| 25 | +def clean_text(s: str) -> str: | |
| 26 | + """Espaces multiples/insécables -> simple espace, trim.""" | |
| 27 | + return re.sub(r"[\s ]+", " ", s or "").strip() | |
| 28 | + | |
| 29 | + | |
| 30 | +# --------------------------------------------------------------------------- | |
| 31 | +# Prix — OPTIONNEL : beaucoup de formations (cours universitaires, catalogue | |
| 32 | +# de cégep…) n'affichent aucun prix ; None est une valeur normale, pas un bug. | |
| 33 | +# --------------------------------------------------------------------------- | |
| 34 | + | |
| 35 | +_FREE_RE = re.compile(r"\b(gratuit|gratuite|free|sans frais|0\s*\$)\b", re.I) | |
| 36 | +_PRICE_RE = re.compile(r"(\d{1,3}(?:[ ,]\d{3})*(?:[.,]\d{2})?)\s*\$" | |
| 37 | + r"|\$\s*(\d{1,3}(?:[, ]\d{3})*(?:[.,]\d{2})?)") | |
| 38 | + | |
| 39 | + | |
| 40 | +def parse_price(label: str) -> float | None: | |
| 41 | + """« 1 295,00 $ + tx » -> 1295.0 ; « Gratuit » -> 0.0 ; sinon None.""" | |
| 42 | + if not label: | |
| 43 | + return None | |
| 44 | + if _FREE_RE.search(label): | |
| 45 | + return 0.0 | |
| 46 | + m = _PRICE_RE.search(label) | |
| 47 | + if not m: | |
| 48 | + return None | |
| 49 | + raw = (m.group(1) or m.group(2) or "") | |
| 50 | + raw = raw.replace(" ", "").replace(" ", "").replace(" ", "") | |
| 51 | + # « 1,295.00 » (anglais) vs « 1295,00 » (français) | |
| 52 | + if "," in raw and "." in raw: | |
| 53 | + raw = raw.replace(",", "") | |
| 54 | + elif "," in raw: | |
| 55 | + raw = raw.replace(",", ".") if re.search(r",\d{2}$", raw) else raw.replace(",", "") | |
| 56 | + try: | |
| 57 | + return float(raw) | |
| 58 | + except ValueError: | |
| 59 | + return None | |
| 60 | + | |
| 61 | + | |
| 62 | +def price_is_from(label: str) -> bool: | |
| 63 | + return bool(re.search(r"à partir de|a partir de|from|dès|des\s+\d", label or "", re.I)) | |
| 64 | + | |
| 65 | + | |
| 66 | +# --------------------------------------------------------------------------- | |
| 67 | +# Durée -> heures (1 jour = 7 h, 1 semaine laissée telle quelle sauf mention) | |
| 68 | +# --------------------------------------------------------------------------- | |
| 69 | + | |
| 70 | +_H_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:h(?:eures?|res?)?|hours?)\b", re.I) | |
| 71 | +_D_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:jours?|journ[ée]es?|days?)\b", re.I) | |
| 72 | +_HALF_DAY_RE = re.compile(r"demi[- ]journ[ée]e", re.I) | |
| 73 | +_MIN_RE = re.compile(r"(\d+)\s*(?:min(?:utes?)?)\b", re.I) | |
| 74 | + | |
| 75 | +HOURS_PER_DAY = 7.0 | |
| 76 | + | |
| 77 | + | |
| 78 | +def parse_duration_hours(text: str) -> float | None: | |
| 79 | + """« 2 jours », « 14 h », « 90 minutes », « demi-journée » -> heures.""" | |
| 80 | + if not text: | |
| 81 | + return None | |
| 82 | + m = _H_RE.search(text) | |
| 83 | + if m: | |
| 84 | + return float(m.group(1).replace(",", ".")) | |
| 85 | + m = _D_RE.search(text) | |
| 86 | + if m: | |
| 87 | + return float(m.group(1).replace(",", ".")) * HOURS_PER_DAY | |
| 88 | + if _HALF_DAY_RE.search(text): | |
| 89 | + return HOURS_PER_DAY / 2 | |
| 90 | + m = _MIN_RE.search(text) | |
| 91 | + if m: | |
| 92 | + return round(int(m.group(1)) / 60, 2) | |
| 93 | + return None | |
| 94 | + | |
| 95 | + | |
| 96 | +# --------------------------------------------------------------------------- | |
| 97 | +# Mode de diffusion | |
| 98 | +# --------------------------------------------------------------------------- | |
| 99 | + | |
| 100 | +_MODE_MAP = [ | |
| 101 | + (re.compile(r"hybride|comodal|bimodal|mixte|blended", re.I), "hybride"), | |
| 102 | + (re.compile(r"asynchrone|à votre rythme|a votre rythme|autoportant|" | |
| 103 | + r"self[- ]paced|autorythmé", re.I), "asynchrone"), | |
| 104 | + (re.compile(r"en ligne|à distance|a distance|virtuel|webinaire|webdiffusion|" | |
| 105 | + r"online|distanciel|remote|zoom|teams|classe virtuelle", re.I), "en ligne"), | |
| 106 | + (re.compile(r"présentiel|presentiel|en salle|en classe|sur place|in[- ]person|" | |
| 107 | + r"en personne|campus", re.I), "présentiel"), | |
| 108 | +] | |
| 109 | + | |
| 110 | + | |
| 111 | +def normalize_mode(raw: str) -> str: | |
| 112 | + for rx, canon in _MODE_MAP: | |
| 113 | + if rx.search(raw or ""): | |
| 114 | + return canon | |
| 115 | + return clean_text(raw).lower() | |
| 116 | + | |
| 117 | + | |
| 118 | +# --------------------------------------------------------------------------- | |
| 119 | +# Type de formation — vocabulaire canonique Forma-Ka | |
| 120 | +# --------------------------------------------------------------------------- | |
| 121 | + | |
| 122 | +TYPES = ("Cours universitaire", "Cours collégial", "Formation continue", | |
| 123 | + "Cours en ligne", "Séminaire", "Atelier", "Webinaire", "Conférence", | |
| 124 | + "Certification", "Bootcamp", "Programme", "Formation en entreprise") | |
| 125 | + | |
| 126 | +_TYPE_MAP = [ | |
| 127 | + (re.compile(r"universitaire|university|1er cycle|premier cycle|2e cycle|" | |
| 128 | + r"cycles? sup|bachelor|baccalaur|maîtrise|maitrise|MBA", re.I), | |
| 129 | + "Cours universitaire"), | |
| 130 | + (re.compile(r"coll[ée]gial|c[ée]gep|AEC\b|DEC\b", re.I), "Cours collégial"), | |
| 131 | + (re.compile(r"bootcamp|camp d'entraînement", re.I), "Bootcamp"), | |
| 132 | + (re.compile(r"webinaire|webinar|webdiffusion", re.I), "Webinaire"), | |
| 133 | + (re.compile(r"certification|certificat professionnel|badge", re.I), "Certification"), | |
| 134 | + (re.compile(r"s[ée]minaire", re.I), "Séminaire"), | |
| 135 | + (re.compile(r"atelier|workshop", re.I), "Atelier"), | |
| 136 | + (re.compile(r"conf[ée]rence|sommet|colloque|symposium", re.I), "Conférence"), | |
| 137 | + (re.compile(r"programme|parcours|cohorte", re.I), "Programme"), | |
| 138 | + (re.compile(r"en ligne|online|à distance|a distance", re.I), "Cours en ligne"), | |
| 139 | + (re.compile(r"formation|cours|perfectionnement", re.I), "Formation continue"), | |
| 140 | +] | |
| 141 | + | |
| 142 | + | |
| 143 | +def normalize_type(raw: str) -> str: | |
| 144 | + raw = clean_text(raw) | |
| 145 | + for t in TYPES: # déjà canonique | |
| 146 | + if raw.lower() == t.lower(): | |
| 147 | + return t | |
| 148 | + for rx, canon in _TYPE_MAP: | |
| 149 | + if rx.search(raw): | |
| 150 | + return canon | |
| 151 | + return raw | |
| 152 | + | |
| 153 | + | |
| 154 | +# --------------------------------------------------------------------------- | |
| 155 | +# Langue | |
| 156 | +# --------------------------------------------------------------------------- | |
| 157 | + | |
| 158 | +def normalize_language(raw: str) -> str: | |
| 159 | + s = strip_accents((raw or "").lower()) | |
| 160 | + fr = bool(re.search(r"\bfr|francais|french", s)) | |
| 161 | + en = bool(re.search(r"\ben\b|anglais|english", s)) | |
| 162 | + if fr and en: | |
| 163 | + return "fr/en" | |
| 164 | + if en: | |
| 165 | + return "en" | |
| 166 | + if fr: | |
| 167 | + return "fr" | |
| 168 | + return clean_text(raw).lower() | |
| 169 | + | |
| 170 | + | |
| 171 | +# --------------------------------------------------------------------------- | |
| 172 | +# Dates françaises -> ISO | |
| 173 | +# --------------------------------------------------------------------------- | |
| 174 | + | |
| 175 | +_MONTHS = { | |
| 176 | + "janvier": 1, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6, | |
| 177 | + "juillet": 7, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11, | |
| 178 | + "decembre": 12, | |
| 179 | + # anglais | |
| 180 | + "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6, | |
| 181 | + "july": 7, "august": 8, "september": 9, "october": 10, "november": 11, | |
| 182 | + "december": 12, | |
| 183 | + # abréviations courantes | |
| 184 | + "janv": 1, "fevr": 2, "fev": 2, "avr": 4, "juil": 7, "sept": 9, "oct": 10, | |
| 185 | + "nov": 11, "dec": 12, "jan": 1, "feb": 2, "mar": 3, "apr": 4, "jun": 6, | |
| 186 | + "jul": 7, "aug": 8, "sep": 9, | |
| 187 | +} | |
| 188 | + | |
| 189 | +_ISO_RE = re.compile(r"\b(20\d{2})-(\d{1,2})-(\d{1,2})\b") | |
| 190 | +_FR_RE = re.compile(r"\b(1er|\d{1,2})\s+([a-zéûî]+)\.?\s+(20\d{2})", re.I) | |
| 191 | +_SLASH_RE = re.compile(r"\b(\d{1,2})/(\d{1,2})/(20\d{2})\b") | |
| 192 | + | |
| 193 | + | |
| 194 | +def parse_date_fr(text: str) -> str | None: | |
| 195 | + """« 14 octobre 2026 », « 2026-10-14 », « 14/10/2026 » -> « 2026-10-14 ».""" | |
| 196 | + if not text: | |
| 197 | + return None | |
| 198 | + m = _ISO_RE.search(text) | |
| 199 | + if m: | |
| 200 | + y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3)) | |
| 201 | + if 1 <= mo <= 12 and 1 <= d <= 31: | |
| 202 | + return f"{y:04d}-{mo:02d}-{d:02d}" | |
| 203 | + m = _FR_RE.search(strip_accents(text).lower()) | |
| 204 | + if m: | |
| 205 | + d = 1 if m.group(1) == "1er" else int(m.group(1)) | |
| 206 | + mo = _MONTHS.get(m.group(2).rstrip(".")) | |
| 207 | + if mo and 1 <= d <= 31: | |
| 208 | + return f"{int(m.group(3)):04d}-{mo:02d}-{d:02d}" | |
| 209 | + m = _SLASH_RE.search(text) | |
| 210 | + if m: # convention canadienne-française jj/mm/aaaa | |
| 211 | + d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) | |
| 212 | + if mo > 12 and d <= 12: | |
| 213 | + d, mo = mo, d | |
| 214 | + if 1 <= mo <= 12 and 1 <= d <= 31: | |
| 215 | + return f"{y:04d}-{mo:02d}-{d:02d}" | |
| 216 | + return None | |
| 217 | + | |
| 218 | + | |
| 219 | +# --------------------------------------------------------------------------- | |
| 220 | +# Extraction de détails structurés depuis les textes libres | |
| 221 | +# --------------------------------------------------------------------------- | |
| 222 | + | |
| 223 | +_UEC_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*UEC", re.I) | |
| 224 | +_CREDITS_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*cr[ée]dits?", re.I) | |
| 225 | +_LEVEL_MAP = [ | |
| 226 | + (re.compile(r"d[ée]butant|introduction|initiation|de base|niveau 1|beginner", re.I), | |
| 227 | + "débutant"), | |
| 228 | + (re.compile(r"interm[ée]diaire|niveau 2|intermediate", re.I), "intermédiaire"), | |
| 229 | + (re.compile(r"avanc[ée]|expert|niveau 3|advanced|perfectionnement avanc", re.I), | |
| 230 | + "avancé"), | |
| 231 | +] | |
| 232 | + | |
| 233 | + | |
| 234 | +def extract_details(*texts: str) -> dict: | |
| 235 | + """Détails dérivés des textes libres (jamais prioritaire sur le connecteur).""" | |
| 236 | + blob = " ".join(t for t in texts if t) | |
| 237 | + out: dict = {} | |
| 238 | + m = _UEC_RE.search(blob) | |
| 239 | + if m: | |
| 240 | + out["uec"] = float(m.group(1).replace(",", ".")) | |
| 241 | + m = _CREDITS_RE.search(blob) | |
| 242 | + if m: | |
| 243 | + out["credits"] = float(m.group(1).replace(",", ".")) | |
| 244 | + for rx, lvl in _LEVEL_MAP: | |
| 245 | + if rx.search(blob): | |
| 246 | + out["level"] = lvl | |
| 247 | + break | |
| 248 | + h = parse_duration_hours(blob) | |
| 249 | + if h is not None: | |
| 250 | + out["duration_hours"] = h | |
| 251 | + return out | |
| 252 | + | |
| 253 | + | |
| 254 | +def merge_details(explicit: dict, derived: dict) -> dict: | |
| 255 | + """Fusion : les valeurs explicites du connecteur ont toujours priorité.""" | |
| 256 | + out = dict(derived) | |
| 257 | + out.update({k: v for k, v in (explicit or {}).items() if v is not None}) | |
| 258 | + return out | |
added
formaka/schema.py
+130 −0
@@ -0,0 +1,130 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# schema.py : modèle de données standardisé (Formation) + normalisation finale | |
| 5 | +# ----------------------------------------------------------------------------- | |
| 6 | +"""Schéma standard d'une formation et normalisation des champs. | |
| 7 | + | |
| 8 | +Chaque connecteur, peu importe le site source (université, cégep, firme de | |
| 9 | +formation, plateforme…), produit des objets `Formation` conformes à ce schéma. | |
| 10 | +`finalize()` applique la couche commune (formaka/normalize.py). | |
| 11 | + | |
| 12 | +Philosophie : le PRIX EST OPTIONNEL (un cours universitaire n'affiche pas de | |
| 13 | +prix) — ce qui compte, ce sont les DÉTAILS de la formation : description, | |
| 14 | +objectifs, préalables, plan de cours, durée, crédits/UEC, mode, clientèle… | |
| 15 | +""" | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import hashlib | |
| 19 | +import json | |
| 20 | +from dataclasses import dataclass, field, asdict | |
| 21 | + | |
| 22 | +from .normalize import ( # ré-exportés pour les connecteurs | |
| 23 | + clean_text, | |
| 24 | + extract_details, | |
| 25 | + merge_details, | |
| 26 | + normalize_language, | |
| 27 | + normalize_mode, | |
| 28 | + normalize_type, | |
| 29 | + parse_date_fr, | |
| 30 | + parse_duration_hours, | |
| 31 | + parse_price, | |
| 32 | + price_is_from, | |
| 33 | + strip_accents, | |
| 34 | +) | |
| 35 | + | |
| 36 | +__all__ = [ | |
| 37 | + "Formation", "clean_text", "normalize_type", "normalize_mode", | |
| 38 | + "normalize_language", "parse_price", "parse_duration_hours", | |
| 39 | + "parse_date_fr", "strip_accents", | |
| 40 | +] | |
| 41 | + | |
| 42 | + | |
| 43 | +@dataclass | |
| 44 | +class Formation: | |
| 45 | + """Formation standardisée Forma-Ka.""" | |
| 46 | + | |
| 47 | + source: str # id de la source (voir data/sources.json) | |
| 48 | + external_id: str # identifiant chez la source | |
| 49 | + url: str # page de la formation chez la source | |
| 50 | + title: str = "" # ex. « Gestion de projet agile » | |
| 51 | + training_type: str = "" # Cours universitaire, Séminaire, Atelier… | |
| 52 | + category: str = "" # domaine : Informatique, Gestion, RH… | |
| 53 | + mode: str = "" # en ligne | présentiel | hybride | asynchrone | |
| 54 | + city: str = "" # ville (si présentiel/hybride) | |
| 55 | + language: str = "" # fr | en | fr/en | |
| 56 | + price: float | None = None # $ CAD — None = non affiché (normal !) | |
| 57 | + price_label: str = "" # texte original (« 1 295 $ + tx ») | |
| 58 | + is_free: bool | None = None # gratuit (None = inconnu) | |
| 59 | + duration: str = "" # texte original (« 2 jours », « 45 h ») | |
| 60 | + duration_hours: float | None = None | |
| 61 | + start_date: str | None = None # ISO — prochaine séance/session | |
| 62 | + schedule_label: str = "" # texte original des dates/horaires | |
| 63 | + sessions: list[str] = field(default_factory=list) # toutes les dates offertes | |
| 64 | + level: str = "" # débutant | intermédiaire | avancé | |
| 65 | + credits: str = "" # « 3 crédits », « 1,4 UEC » | |
| 66 | + credential: str = "" # attestation, certificat, diplôme, UEC… | |
| 67 | + instructor: str = "" # formateur / professeur | |
| 68 | + code: str = "" # sigle du cours (ex. « GSF-1020 ») | |
| 69 | + description: str = "" # description complète | |
| 70 | + objectives: list[str] = field(default_factory=list) # objectifs d'apprentissage | |
| 71 | + prerequisites: str = "" # préalables / conditions d'admission | |
| 72 | + audience: str = "" # clientèle visée | |
| 73 | + program: list[str] = field(default_factory=list) # plan / contenu détaillé | |
| 74 | + tags: list[str] = field(default_factory=list) | |
| 75 | + details: dict = field(default_factory=dict) # champs structurés (JSON) | |
| 76 | + images: list[str] = field(default_factory=list) # URLs absolues | |
| 77 | + | |
| 78 | + @property | |
| 79 | + def uid(self) -> str: | |
| 80 | + return f"{self.source}:{self.external_id}" | |
| 81 | + | |
| 82 | + def content_hash(self) -> str: | |
| 83 | + """Hash du contenu pour la détection de changements (pseudo-webhook).""" | |
| 84 | + payload = asdict(self) | |
| 85 | + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) | |
| 86 | + return hashlib.sha256(blob.encode("utf-8")).hexdigest() | |
| 87 | + | |
| 88 | + def finalize(self) -> "Formation": | |
| 89 | + """Applique la normalisation commune. Appelé par le pipeline d'ingestion. | |
| 90 | + | |
| 91 | + Idempotent ; ne remplace jamais une valeur explicite du connecteur. | |
| 92 | + """ | |
| 93 | + self.title = clean_text(self.title) | |
| 94 | + self.description = (self.description or "").strip() | |
| 95 | + self.training_type = normalize_type(self.training_type) | |
| 96 | + self.mode = normalize_mode(self.mode) | |
| 97 | + self.language = normalize_language(self.language) | |
| 98 | + self.objectives = [clean_text(o) for o in self.objectives if clean_text(o)] | |
| 99 | + self.program = [clean_text(p) for p in self.program if clean_text(p)] | |
| 100 | + self.tags = sorted({clean_text(t) for t in self.tags if clean_text(t)}) | |
| 101 | + | |
| 102 | + if self.price is None: | |
| 103 | + self.price = parse_price(self.price_label) | |
| 104 | + if self.is_free is None and self.price is not None: | |
| 105 | + self.is_free = self.price == 0.0 | |
| 106 | + if self.duration_hours is None: | |
| 107 | + self.duration_hours = parse_duration_hours(self.duration) | |
| 108 | + if self.start_date is None: | |
| 109 | + self.start_date = parse_date_fr(self.schedule_label) | |
| 110 | + if not self.sessions and self.start_date: | |
| 111 | + self.sessions = [self.start_date] | |
| 112 | + | |
| 113 | + # détails dérivés des textes libres (le connecteur garde priorité) | |
| 114 | + derived = extract_details(self.duration, self.description, | |
| 115 | + self.credits, self.schedule_label) | |
| 116 | + if self.price_label and price_is_from(self.price_label): | |
| 117 | + derived["price_from"] = True | |
| 118 | + self.details = merge_details(self.details, derived) | |
| 119 | + | |
| 120 | + if not self.level: | |
| 121 | + self.level = self.details.get("level", "") | |
| 122 | + if self.duration_hours is None: | |
| 123 | + dh = self.details.get("duration_hours") | |
| 124 | + self.duration_hours = float(dh) if dh is not None else None | |
| 125 | + if not self.credits: | |
| 126 | + if self.details.get("uec") is not None: | |
| 127 | + self.credits = f"{self.details['uec']:g} UEC".replace(".", ",") | |
| 128 | + elif self.details.get("credits") is not None: | |
| 129 | + self.credits = f"{self.details['credits']:g} crédits" | |
| 130 | + return self | |
added
formaka/web.py
+237 −0
@@ -0,0 +1,237 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# web.py : API FastAPI (JSON) + service du frontend React (frontend/dist) | |
| 5 | +# ----------------------------------------------------------------------------- | |
| 6 | +from __future__ import annotations | |
| 7 | + | |
| 8 | +import json | |
| 9 | +import threading | |
| 10 | +from pathlib import Path | |
| 11 | + | |
| 12 | +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query | |
| 13 | +from fastapi.middleware.cors import CORSMiddleware | |
| 14 | +from fastapi.responses import FileResponse | |
| 15 | +from fastapi.staticfiles import StaticFiles | |
| 16 | + | |
| 17 | +from . import db, ingest | |
| 18 | + | |
| 19 | +ROOT = Path(__file__).resolve().parent.parent | |
| 20 | +SOURCES_PATH = ROOT / "data" / "sources.json" | |
| 21 | +FRONTEND_DIST = ROOT / "frontend" / "dist" | |
| 22 | + | |
| 23 | +app = FastAPI(title="Forma-Ka API", version="1.0", | |
| 24 | + description="Agrégateur de formations — province de Québec") | |
| 25 | +app.add_middleware(CORSMiddleware, allow_origins=["*"], | |
| 26 | + allow_methods=["*"], allow_headers=["*"]) | |
| 27 | + | |
| 28 | +_sync_lock = threading.Lock() | |
| 29 | + | |
| 30 | +_JSON_COLS = ("sessions", "objectives", "program", "tags", "details", "images") | |
| 31 | + | |
| 32 | + | |
| 33 | +def _row_to_dict(row) -> dict: | |
| 34 | + d = dict(row) | |
| 35 | + for col in _JSON_COLS: | |
| 36 | + default = "{}" if col == "details" else "[]" | |
| 37 | + d[col] = json.loads(d.get(col) or default) | |
| 38 | + if d.get("is_free") is not None: | |
| 39 | + d["is_free"] = bool(d["is_free"]) | |
| 40 | + return d | |
| 41 | + | |
| 42 | + | |
| 43 | +def _apply_filters(sql: str, args: list, | |
| 44 | + training_type: str | None, category: str | None, | |
| 45 | + mode: str | None, city: str | None, language: str | None, | |
| 46 | + level: str | None, source: str | None, | |
| 47 | + free: int | None, price_max: float | None, | |
| 48 | + credential: str | None, starts_after: str | None, | |
| 49 | + q: str | None) -> str: | |
| 50 | + if training_type: | |
| 51 | + sql += " AND training_type=?"; args.append(training_type) | |
| 52 | + if category: | |
| 53 | + sql += " AND category LIKE ?"; args.append(f"%{category}%") | |
| 54 | + if mode: | |
| 55 | + sql += " AND mode=?"; args.append(mode) | |
| 56 | + if city: | |
| 57 | + sql += " AND city LIKE ?"; args.append(f"%{city}%") | |
| 58 | + if language: | |
| 59 | + sql += " AND language=?"; args.append(language) | |
| 60 | + if level: | |
| 61 | + sql += " AND level=?"; args.append(level) | |
| 62 | + if source: | |
| 63 | + sql += " AND source=?"; args.append(source) | |
| 64 | + if free == 1: | |
| 65 | + sql += " AND is_free=1" | |
| 66 | + elif free == 0: | |
| 67 | + sql += " AND is_free=0" | |
| 68 | + if price_max is not None: | |
| 69 | + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max) | |
| 70 | + if credential: | |
| 71 | + sql += " AND credential LIKE ?"; args.append(f"%{credential}%") | |
| 72 | + if starts_after: | |
| 73 | + sql += " AND start_date IS NOT NULL AND start_date>=?" | |
| 74 | + args.append(starts_after) | |
| 75 | + if q: | |
| 76 | + sql += (" AND (title LIKE ? OR description LIKE ? OR category LIKE ?" | |
| 77 | + " OR code LIKE ? OR tags LIKE ?)") | |
| 78 | + args += [f"%{q}%"] * 5 | |
| 79 | + return sql | |
| 80 | + | |
| 81 | + | |
| 82 | +@app.get("/api/formations") | |
| 83 | +def list_formations( | |
| 84 | + training_type: str | None = None, # Cours universitaire, Séminaire… | |
| 85 | + category: str | None = None, # domaine (LIKE) | |
| 86 | + mode: str | None = None, # en ligne | présentiel | hybride | asynchrone | |
| 87 | + city: str | None = None, | |
| 88 | + language: str | None = None, # fr | en | fr/en | |
| 89 | + level: str | None = None, # débutant | intermédiaire | avancé | |
| 90 | + source: str | None = None, | |
| 91 | + free: int | None = None, # 1 = gratuites seulement | |
| 92 | + price_max: float | None = None, | |
| 93 | + credential: str | None = None, # attestation, UEC, certificat… | |
| 94 | + starts_after: str | None = None, # ISO : prochaine séance à partir de | |
| 95 | + q: str | None = None, | |
| 96 | + active: int = 1, | |
| 97 | + sort: str = "recent", # recent | price | title | start | |
| 98 | + limit: int = Query(500, le=2000), | |
| 99 | + offset: int = 0, | |
| 100 | +): | |
| 101 | + con = db.connect() | |
| 102 | + sql = "SELECT * FROM formations WHERE 1=1" | |
| 103 | + args: list = [] | |
| 104 | + if active in (0, 1): | |
| 105 | + sql += " AND active=?"; args.append(active) | |
| 106 | + sql = _apply_filters(sql, args, training_type, category, mode, city, | |
| 107 | + language, level, source, free, price_max, | |
| 108 | + credential, starts_after, q) | |
| 109 | + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] | |
| 110 | + order = { | |
| 111 | + "price": " ORDER BY price IS NULL, price ASC", | |
| 112 | + "title": " ORDER BY title COLLATE NOCASE ASC", | |
| 113 | + "start": " ORDER BY start_date IS NULL, start_date ASC", | |
| 114 | + "recent": " ORDER BY first_seen DESC", | |
| 115 | + }.get(sort, " ORDER BY first_seen DESC") | |
| 116 | + sql += order + " LIMIT ? OFFSET ?" | |
| 117 | + args += [limit, offset] | |
| 118 | + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()] | |
| 119 | + con.close() | |
| 120 | + return {"total": total, "count": len(rows), "formations": rows} | |
| 121 | + | |
| 122 | + | |
| 123 | +@app.get("/api/formations/{uid:path}") | |
| 124 | +def get_formation(uid: str): | |
| 125 | + con = db.connect() | |
| 126 | + row = con.execute("SELECT * FROM formations WHERE uid=?", (uid,)).fetchone() | |
| 127 | + d = None | |
| 128 | + if row is not None: | |
| 129 | + d = _row_to_dict(row) | |
| 130 | + d["price_history"] = [dict(r) for r in con.execute( | |
| 131 | + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6", | |
| 132 | + (uid,)).fetchall()] | |
| 133 | + # formations similaires : même source ou même catégorie | |
| 134 | + d["similar"] = [ | |
| 135 | + {"uid": r["uid"], "title": r["title"], "training_type": r["training_type"], | |
| 136 | + "source": r["source"], "price": r["price"], "mode": r["mode"], | |
| 137 | + "duration": r["duration"]} | |
| 138 | + for r in con.execute( | |
| 139 | + "SELECT uid, title, training_type, source, price, mode, duration" | |
| 140 | + " FROM formations WHERE active=1 AND uid<>? AND" | |
| 141 | + " (category=? OR source=?) ORDER BY RANDOM() LIMIT 6", | |
| 142 | + (uid, d.get("category") or "-", d["source"])).fetchall()] | |
| 143 | + con.close() | |
| 144 | + if d is None: | |
| 145 | + raise HTTPException(404, "Formation introuvable") | |
| 146 | + return d | |
| 147 | + | |
| 148 | + | |
| 149 | +@app.get("/api/facets") | |
| 150 | +def facets(training_type: str | None = None): | |
| 151 | + """Valeurs distinctes pour construire les filtres du frontend.""" | |
| 152 | + con = db.connect() | |
| 153 | + cat_sql = "SELECT category, COUNT(*) n FROM formations WHERE active=1 AND category<>''" | |
| 154 | + cat_args: list = [] | |
| 155 | + if training_type: | |
| 156 | + cat_sql += " AND training_type=?" | |
| 157 | + cat_args.append(training_type) | |
| 158 | + out = { | |
| 159 | + "types": [dict(r) for r in con.execute( | |
| 160 | + "SELECT training_type t, COUNT(*) n FROM formations" | |
| 161 | + " WHERE active=1 AND training_type<>'' GROUP BY training_type ORDER BY n DESC")], | |
| 162 | + "categories": [dict(r) for r in con.execute( | |
| 163 | + cat_sql + " GROUP BY category ORDER BY n DESC LIMIT 60", cat_args)], | |
| 164 | + "modes": [r["mode"] for r in con.execute( | |
| 165 | + "SELECT DISTINCT mode FROM formations WHERE active=1 AND mode<>'' ORDER BY mode")], | |
| 166 | + "cities": [r["city"] for r in con.execute( | |
| 167 | + "SELECT DISTINCT city FROM formations WHERE active=1 AND city<>'' ORDER BY city")], | |
| 168 | + "languages": [r["language"] for r in con.execute( | |
| 169 | + "SELECT DISTINCT language FROM formations WHERE active=1 AND language<>'' ORDER BY language")], | |
| 170 | + "levels": [r["level"] for r in con.execute( | |
| 171 | + "SELECT DISTINCT level FROM formations WHERE active=1 AND level<>'' ORDER BY level")], | |
| 172 | + "sources": [dict(r) for r in con.execute( | |
| 173 | + "SELECT source, COUNT(*) n FROM formations WHERE active=1" | |
| 174 | + " GROUP BY source ORDER BY n DESC")], | |
| 175 | + } | |
| 176 | + con.close() | |
| 177 | + return out | |
| 178 | + | |
| 179 | + | |
| 180 | +@app.get("/api/sources") | |
| 181 | +def sources(): | |
| 182 | + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] | |
| 183 | + con = db.connect() | |
| 184 | + counts = {r["source"]: r["n"] for r in con.execute( | |
| 185 | + "SELECT source, COUNT(*) n FROM formations WHERE active=1 GROUP BY source")} | |
| 186 | + last = {r["source"]: r["ts"] for r in con.execute( | |
| 187 | + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")} | |
| 188 | + con.close() | |
| 189 | + for s in registry: | |
| 190 | + s["active_formations"] = counts.get(s["id"], 0) | |
| 191 | + s["last_sync"] = last.get(s["id"]) | |
| 192 | + return {"sources": registry} | |
| 193 | + | |
| 194 | + | |
| 195 | +@app.get("/api/stats") | |
| 196 | +def stats(): | |
| 197 | + con = db.connect() | |
| 198 | + row = con.execute( | |
| 199 | + """SELECT COUNT(*) total, | |
| 200 | + SUM(CASE WHEN is_free=1 THEN 1 ELSE 0 END) gratuites, | |
| 201 | + SUM(CASE WHEN mode='en ligne' OR mode='asynchrone' | |
| 202 | + THEN 1 ELSE 0 END) en_ligne, | |
| 203 | + SUM(CASE WHEN training_type='Cours universitaire' | |
| 204 | + THEN 1 ELSE 0 END) universitaires, | |
| 205 | + COUNT(DISTINCT source) sources, | |
| 206 | + AVG(price) avg_price, | |
| 207 | + AVG(duration_hours) avg_hours | |
| 208 | + FROM formations WHERE active=1""").fetchone() | |
| 209 | + par_type = [dict(r) for r in con.execute( | |
| 210 | + "SELECT training_type t, COUNT(*) n FROM formations WHERE active=1" | |
| 211 | + " GROUP BY training_type ORDER BY n DESC")] | |
| 212 | + log = [dict(r) for r in con.execute( | |
| 213 | + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] | |
| 214 | + con.close() | |
| 215 | + return {**dict(row), "par_type": par_type, "recent_syncs": log} | |
| 216 | + | |
| 217 | + | |
| 218 | +@app.post("/api/sync") | |
| 219 | +def trigger_sync(background: BackgroundTasks, source: str | None = None): | |
| 220 | + """Déclenche une synchronisation (équivalent d'un webhook entrant).""" | |
| 221 | + def _job(): | |
| 222 | + with _sync_lock: | |
| 223 | + ingest.run([source] if source else None) | |
| 224 | + background.add_task(_job) | |
| 225 | + return {"status": "démarré", "source": source or "toutes"} | |
| 226 | + | |
| 227 | + | |
| 228 | +# --- Frontend React (build Vite) -------------------------------------------- | |
| 229 | +if FRONTEND_DIST.exists(): | |
| 230 | + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") | |
| 231 | + | |
| 232 | + @app.get("/{full_path:path}") | |
| 233 | + def spa(full_path: str): | |
| 234 | + target = FRONTEND_DIST / full_path | |
| 235 | + if full_path and target.is_file(): | |
| 236 | + return FileResponse(target) | |
| 237 | + return FileResponse(FRONTEND_DIST / "index.html") | |
added
frontend/index.html
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<!-- --------------------------------------------------------------------------- | |
| 3 | + Forma-Ka — Agrégateur de formations (province de Québec) | |
| 4 | + Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 5 | +---------------------------------------------------------------------------- --> | |
| 6 | +<html lang="fr"> | |
| 7 | + <head> | |
| 8 | + <meta charset="UTF-8" /> | |
| 9 | + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> | |
| 10 | + <title>Forma-Ka — Toutes les formations du Québec</title> | |
| 11 | + <meta name="description" content="Forma-Ka agrège les cours en ligne, cours universitaires et collégiaux, séminaires, ateliers et certifications offerts au Québec, toujours à jour." /> | |
| 12 | + <meta name="theme-color" content="#f5f3ee" /> | |
| 13 | + <meta name="mobile-web-app-capable" content="yes" /> | |
| 14 | + <meta name="apple-mobile-web-app-capable" content="yes" /> | |
| 15 | + <meta name="apple-mobile-web-app-status-bar-style" content="default" /> | |
| 16 | + <meta name="apple-mobile-web-app-title" content="Forma-Ka" /> | |
| 17 | + <link rel="manifest" href="/manifest.webmanifest" /> | |
| 18 | + <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 19 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 20 | + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" /> | |
| 21 | + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23141814'/%3E%3Ctext x='32' y='45' font-family='Arial Black,sans-serif' font-size='32' font-weight='900' fill='%23ffd54d' text-anchor='middle'%3EFK%3C/text%3E%3C/svg%3E" /> | |
| 22 | + </head> | |
| 23 | + <body> | |
| 24 | + <div id="root"></div> | |
| 25 | + <script type="module" src="/src/main.tsx"></script> | |
| 26 | + </body> | |
| 27 | +</html> | |
added
frontend/package-lock.json
+1834 −0
@@ -0,0 +1,1834 @@ | ||
| 1 | +{ | |
| 2 | + "name": "forma-ka-frontend", | |
| 3 | + "version": "1.0.0", | |
| 4 | + "lockfileVersion": 3, | |
| 5 | + "requires": true, | |
| 6 | + "packages": { | |
| 7 | + "": { | |
| 8 | + "name": "forma-ka-frontend", | |
| 9 | + "version": "1.0.0", | |
| 10 | + "dependencies": { | |
| 11 | + "react": "^18.3.1", | |
| 12 | + "react-dom": "^18.3.1", | |
| 13 | + "react-router-dom": "^6.26.0" | |
| 14 | + }, | |
| 15 | + "devDependencies": { | |
| 16 | + "@types/react": "^18.3.3", | |
| 17 | + "@types/react-dom": "^18.3.0", | |
| 18 | + "@vitejs/plugin-react": "^4.3.1", | |
| 19 | + "typescript": "^5.5.4", | |
| 20 | + "vite": "^5.4.0" | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + "node_modules/@babel/code-frame": { | |
| 24 | + "version": "7.29.7", | |
| 25 | + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", | |
| 26 | + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", | |
| 27 | + "dev": true, | |
| 28 | + "license": "MIT", | |
| 29 | + "dependencies": { | |
| 30 | + "@babel/helper-validator-identifier": "^7.29.7", | |
| 31 | + "js-tokens": "^4.0.0", | |
| 32 | + "picocolors": "^1.1.1" | |
| 33 | + }, | |
| 34 | + "engines": { | |
| 35 | + "node": ">=6.9.0" | |
| 36 | + } | |
| 37 | + }, | |
| 38 | + "node_modules/@babel/compat-data": { | |
| 39 | + "version": "7.29.7", | |
| 40 | + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", | |
| 41 | + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", | |
| 42 | + "dev": true, | |
| 43 | + "license": "MIT", | |
| 44 | + "engines": { | |
| 45 | + "node": ">=6.9.0" | |
| 46 | + } | |
| 47 | + }, | |
| 48 | + "node_modules/@babel/core": { | |
| 49 | + "version": "7.29.7", | |
| 50 | + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", | |
| 51 | + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", | |
| 52 | + "dev": true, | |
| 53 | + "license": "MIT", | |
| 54 | + "dependencies": { | |
| 55 | + "@babel/code-frame": "^7.29.7", | |
| 56 | + "@babel/generator": "^7.29.7", | |
| 57 | + "@babel/helper-compilation-targets": "^7.29.7", | |
| 58 | + "@babel/helper-module-transforms": "^7.29.7", | |
| 59 | + "@babel/helpers": "^7.29.7", | |
| 60 | + "@babel/parser": "^7.29.7", | |
| 61 | + "@babel/template": "^7.29.7", | |
| 62 | + "@babel/traverse": "^7.29.7", | |
| 63 | + "@babel/types": "^7.29.7", | |
| 64 | + "@jridgewell/remapping": "^2.3.5", | |
| 65 | + "convert-source-map": "^2.0.0", | |
| 66 | + "debug": "^4.1.0", | |
| 67 | + "gensync": "^1.0.0-beta.2", | |
| 68 | + "json5": "^2.2.3", | |
| 69 | + "semver": "^6.3.1" | |
| 70 | + }, | |
| 71 | + "engines": { | |
| 72 | + "node": ">=6.9.0" | |
| 73 | + }, | |
| 74 | + "funding": { | |
| 75 | + "type": "opencollective", | |
| 76 | + "url": "https://opencollective.com/babel" | |
| 77 | + } | |
| 78 | + }, | |
| 79 | + "node_modules/@babel/generator": { | |
| 80 | + "version": "7.29.8", | |
| 81 | + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", | |
| 82 | + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", | |
| 83 | + "dev": true, | |
| 84 | + "license": "MIT", | |
| 85 | + "dependencies": { | |
| 86 | + "@babel/parser": "^7.29.8", | |
| 87 | + "@babel/types": "^7.29.8", | |
| 88 | + "@jridgewell/gen-mapping": "^0.3.12", | |
| 89 | + "@jridgewell/trace-mapping": "^0.3.28", | |
| 90 | + "jsesc": "^3.0.2" | |
| 91 | + }, | |
| 92 | + "engines": { | |
| 93 | + "node": ">=6.9.0" | |
| 94 | + } | |
| 95 | + }, | |
| 96 | + "node_modules/@babel/helper-compilation-targets": { | |
| 97 | + "version": "7.29.7", | |
| 98 | + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", | |
| 99 | + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", | |
| 100 | + "dev": true, | |
| 101 | + "license": "MIT", | |
| 102 | + "dependencies": { | |
| 103 | + "@babel/compat-data": "^7.29.7", | |
| 104 | + "@babel/helper-validator-option": "^7.29.7", | |
| 105 | + "browserslist": "^4.24.0", | |
| 106 | + "lru-cache": "^5.1.1", | |
| 107 | + "semver": "^6.3.1" | |
| 108 | + }, | |
| 109 | + "engines": { | |
| 110 | + "node": ">=6.9.0" | |
| 111 | + } | |
| 112 | + }, | |
| 113 | + "node_modules/@babel/helper-globals": { | |
| 114 | + "version": "7.29.7", | |
| 115 | + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", | |
| 116 | + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", | |
| 117 | + "dev": true, | |
| 118 | + "license": "MIT", | |
| 119 | + "engines": { | |
| 120 | + "node": ">=6.9.0" | |
| 121 | + } | |
| 122 | + }, | |
| 123 | + "node_modules/@babel/helper-module-imports": { | |
| 124 | + "version": "7.29.7", | |
| 125 | + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", | |
| 126 | + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", | |
| 127 | + "dev": true, | |
| 128 | + "license": "MIT", | |
| 129 | + "dependencies": { | |
| 130 | + "@babel/traverse": "^7.29.7", | |
| 131 | + "@babel/types": "^7.29.7" | |
| 132 | + }, | |
| 133 | + "engines": { | |
| 134 | + "node": ">=6.9.0" | |
| 135 | + } | |
| 136 | + }, | |
| 137 | + "node_modules/@babel/helper-module-transforms": { | |
| 138 | + "version": "7.29.7", | |
| 139 | + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", | |
| 140 | + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", | |
| 141 | + "dev": true, | |
| 142 | + "license": "MIT", | |
| 143 | + "dependencies": { | |
| 144 | + "@babel/helper-module-imports": "^7.29.7", | |
| 145 | + "@babel/helper-validator-identifier": "^7.29.7", | |
| 146 | + "@babel/traverse": "^7.29.7" | |
| 147 | + }, | |
| 148 | + "engines": { | |
| 149 | + "node": ">=6.9.0" | |
| 150 | + }, | |
| 151 | + "peerDependencies": { | |
| 152 | + "@babel/core": "^7.0.0" | |
| 153 | + } | |
| 154 | + }, | |
| 155 | + "node_modules/@babel/helper-plugin-utils": { | |
| 156 | + "version": "7.29.7", | |
| 157 | + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", | |
| 158 | + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", | |
| 159 | + "dev": true, | |
| 160 | + "license": "MIT", | |
| 161 | + "engines": { | |
| 162 | + "node": ">=6.9.0" | |
| 163 | + } | |
| 164 | + }, | |
| 165 | + "node_modules/@babel/helper-string-parser": { | |
| 166 | + "version": "7.29.7", | |
| 167 | + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", | |
| 168 | + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", | |
| 169 | + "dev": true, | |
| 170 | + "license": "MIT", | |
| 171 | + "engines": { | |
| 172 | + "node": ">=6.9.0" | |
| 173 | + } | |
| 174 | + }, | |
| 175 | + "node_modules/@babel/helper-validator-identifier": { | |
| 176 | + "version": "7.29.7", | |
| 177 | + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", | |
| 178 | + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", | |
| 179 | + "dev": true, | |
| 180 | + "license": "MIT", | |
| 181 | + "engines": { | |
| 182 | + "node": ">=6.9.0" | |
| 183 | + } | |
| 184 | + }, | |
| 185 | + "node_modules/@babel/helper-validator-option": { | |
| 186 | + "version": "7.29.7", | |
| 187 | + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", | |
| 188 | + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", | |
| 189 | + "dev": true, | |
| 190 | + "license": "MIT", | |
| 191 | + "engines": { | |
| 192 | + "node": ">=6.9.0" | |
| 193 | + } | |
| 194 | + }, | |
| 195 | + "node_modules/@babel/helpers": { | |
| 196 | + "version": "7.29.7", | |
| 197 | + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", | |
| 198 | + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", | |
| 199 | + "dev": true, | |
| 200 | + "license": "MIT", | |
| 201 | + "dependencies": { | |
| 202 | + "@babel/template": "^7.29.7", | |
| 203 | + "@babel/types": "^7.29.7" | |
| 204 | + }, | |
| 205 | + "engines": { | |
| 206 | + "node": ">=6.9.0" | |
| 207 | + } | |
| 208 | + }, | |
| 209 | + "node_modules/@babel/parser": { | |
| 210 | + "version": "7.29.8", | |
| 211 | + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", | |
| 212 | + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", | |
| 213 | + "dev": true, | |
| 214 | + "license": "MIT", | |
| 215 | + "dependencies": { | |
| 216 | + "@babel/types": "^7.29.8" | |
| 217 | + }, | |
| 218 | + "bin": { | |
| 219 | + "parser": "bin/babel-parser.js" | |
| 220 | + }, | |
| 221 | + "engines": { | |
| 222 | + "node": ">=6.0.0" | |
| 223 | + } | |
| 224 | + }, | |
| 225 | + "node_modules/@babel/plugin-transform-react-jsx-self": { | |
| 226 | + "version": "7.29.7", | |
| 227 | + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", | |
| 228 | + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", | |
| 229 | + "dev": true, | |
| 230 | + "license": "MIT", | |
| 231 | + "dependencies": { | |
| 232 | + "@babel/helper-plugin-utils": "^7.29.7" | |
| 233 | + }, | |
| 234 | + "engines": { | |
| 235 | + "node": ">=6.9.0" | |
| 236 | + }, | |
| 237 | + "peerDependencies": { | |
| 238 | + "@babel/core": "^7.0.0-0" | |
| 239 | + } | |
| 240 | + }, | |
| 241 | + "node_modules/@babel/plugin-transform-react-jsx-source": { | |
| 242 | + "version": "7.29.7", | |
| 243 | + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", | |
| 244 | + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", | |
| 245 | + "dev": true, | |
| 246 | + "license": "MIT", | |
| 247 | + "dependencies": { | |
| 248 | + "@babel/helper-plugin-utils": "^7.29.7" | |
| 249 | + }, | |
| 250 | + "engines": { | |
| 251 | + "node": ">=6.9.0" | |
| 252 | + }, | |
| 253 | + "peerDependencies": { | |
| 254 | + "@babel/core": "^7.0.0-0" | |
| 255 | + } | |
| 256 | + }, | |
| 257 | + "node_modules/@babel/template": { | |
| 258 | + "version": "7.29.7", | |
| 259 | + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", | |
| 260 | + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", | |
| 261 | + "dev": true, | |
| 262 | + "license": "MIT", | |
| 263 | + "dependencies": { | |
| 264 | + "@babel/code-frame": "^7.29.7", | |
| 265 | + "@babel/parser": "^7.29.7", | |
| 266 | + "@babel/types": "^7.29.7" | |
| 267 | + }, | |
| 268 | + "engines": { | |
| 269 | + "node": ">=6.9.0" | |
| 270 | + } | |
| 271 | + }, | |
| 272 | + "node_modules/@babel/traverse": { | |
| 273 | + "version": "7.29.8", | |
| 274 | + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", | |
| 275 | + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", | |
| 276 | + "dev": true, | |
| 277 | + "license": "MIT", | |
| 278 | + "dependencies": { | |
| 279 | + "@babel/code-frame": "^7.29.7", | |
| 280 | + "@babel/generator": "^7.29.8", | |
| 281 | + "@babel/helper-globals": "^7.29.7", | |
| 282 | + "@babel/parser": "^7.29.8", | |
| 283 | + "@babel/template": "^7.29.7", | |
| 284 | + "@babel/types": "^7.29.8", | |
| 285 | + "debug": "^4.3.1" | |
| 286 | + }, | |
| 287 | + "engines": { | |
| 288 | + "node": ">=6.9.0" | |
| 289 | + } | |
| 290 | + }, | |
| 291 | + "node_modules/@babel/types": { | |
| 292 | + "version": "7.29.8", | |
| 293 | + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", | |
| 294 | + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", | |
| 295 | + "dev": true, | |
| 296 | + "license": "MIT", | |
| 297 | + "dependencies": { | |
| 298 | + "@babel/helper-string-parser": "^7.29.7", | |
| 299 | + "@babel/helper-validator-identifier": "^7.29.7" | |
| 300 | + }, | |
| 301 | + "engines": { | |
| 302 | + "node": ">=6.9.0" | |
| 303 | + } | |
| 304 | + }, | |
| 305 | + "node_modules/@esbuild/aix-ppc64": { | |
| 306 | + "version": "0.21.5", | |
| 307 | + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", | |
| 308 | + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", | |
| 309 | + "cpu": [ | |
| 310 | + "ppc64" | |
| 311 | + ], | |
| 312 | + "dev": true, | |
| 313 | + "license": "MIT", | |
| 314 | + "optional": true, | |
| 315 | + "os": [ | |
| 316 | + "aix" | |
| 317 | + ], | |
| 318 | + "engines": { | |
| 319 | + "node": ">=12" | |
| 320 | + } | |
| 321 | + }, | |
| 322 | + "node_modules/@esbuild/android-arm": { | |
| 323 | + "version": "0.21.5", | |
| 324 | + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", | |
| 325 | + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", | |
| 326 | + "cpu": [ | |
| 327 | + "arm" | |
| 328 | + ], | |
| 329 | + "dev": true, | |
| 330 | + "license": "MIT", | |
| 331 | + "optional": true, | |
| 332 | + "os": [ | |
| 333 | + "android" | |
| 334 | + ], | |
| 335 | + "engines": { | |
| 336 | + "node": ">=12" | |
| 337 | + } | |
| 338 | + }, | |
| 339 | + "node_modules/@esbuild/android-arm64": { | |
| 340 | + "version": "0.21.5", | |
| 341 | + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", | |
| 342 | + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", | |
| 343 | + "cpu": [ | |
| 344 | + "arm64" | |
| 345 | + ], | |
| 346 | + "dev": true, | |
| 347 | + "license": "MIT", | |
| 348 | + "optional": true, | |
| 349 | + "os": [ | |
| 350 | + "android" | |
| 351 | + ], | |
| 352 | + "engines": { | |
| 353 | + "node": ">=12" | |
| 354 | + } | |
| 355 | + }, | |
| 356 | + "node_modules/@esbuild/android-x64": { | |
| 357 | + "version": "0.21.5", | |
| 358 | + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", | |
| 359 | + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", | |
| 360 | + "cpu": [ | |
| 361 | + "x64" | |
| 362 | + ], | |
| 363 | + "dev": true, | |
| 364 | + "license": "MIT", | |
| 365 | + "optional": true, | |
| 366 | + "os": [ | |
| 367 | + "android" | |
| 368 | + ], | |
| 369 | + "engines": { | |
| 370 | + "node": ">=12" | |
| 371 | + } | |
| 372 | + }, | |
| 373 | + "node_modules/@esbuild/darwin-arm64": { | |
| 374 | + "version": "0.21.5", | |
| 375 | + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", | |
| 376 | + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", | |
| 377 | + "cpu": [ | |
| 378 | + "arm64" | |
| 379 | + ], | |
| 380 | + "dev": true, | |
| 381 | + "license": "MIT", | |
| 382 | + "optional": true, | |
| 383 | + "os": [ | |
| 384 | + "darwin" | |
| 385 | + ], | |
| 386 | + "engines": { | |
| 387 | + "node": ">=12" | |
| 388 | + } | |
| 389 | + }, | |
| 390 | + "node_modules/@esbuild/darwin-x64": { | |
| 391 | + "version": "0.21.5", | |
| 392 | + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", | |
| 393 | + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", | |
| 394 | + "cpu": [ | |
| 395 | + "x64" | |
| 396 | + ], | |
| 397 | + "dev": true, | |
| 398 | + "license": "MIT", | |
| 399 | + "optional": true, | |
| 400 | + "os": [ | |
| 401 | + "darwin" | |
| 402 | + ], | |
| 403 | + "engines": { | |
| 404 | + "node": ">=12" | |
| 405 | + } | |
| 406 | + }, | |
| 407 | + "node_modules/@esbuild/freebsd-arm64": { | |
| 408 | + "version": "0.21.5", | |
| 409 | + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", | |
| 410 | + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", | |
| 411 | + "cpu": [ | |
| 412 | + "arm64" | |
| 413 | + ], | |
| 414 | + "dev": true, | |
| 415 | + "license": "MIT", | |
| 416 | + "optional": true, | |
| 417 | + "os": [ | |
| 418 | + "freebsd" | |
| 419 | + ], | |
| 420 | + "engines": { | |
| 421 | + "node": ">=12" | |
| 422 | + } | |
| 423 | + }, | |
| 424 | + "node_modules/@esbuild/freebsd-x64": { | |
| 425 | + "version": "0.21.5", | |
| 426 | + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", | |
| 427 | + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", | |
| 428 | + "cpu": [ | |
| 429 | + "x64" | |
| 430 | + ], | |
| 431 | + "dev": true, | |
| 432 | + "license": "MIT", | |
| 433 | + "optional": true, | |
| 434 | + "os": [ | |
| 435 | + "freebsd" | |
| 436 | + ], | |
| 437 | + "engines": { | |
| 438 | + "node": ">=12" | |
| 439 | + } | |
| 440 | + }, | |
| 441 | + "node_modules/@esbuild/linux-arm": { | |
| 442 | + "version": "0.21.5", | |
| 443 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", | |
| 444 | + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", | |
| 445 | + "cpu": [ | |
| 446 | + "arm" | |
| 447 | + ], | |
| 448 | + "dev": true, | |
| 449 | + "license": "MIT", | |
| 450 | + "optional": true, | |
| 451 | + "os": [ | |
| 452 | + "linux" | |
| 453 | + ], | |
| 454 | + "engines": { | |
| 455 | + "node": ">=12" | |
| 456 | + } | |
| 457 | + }, | |
| 458 | + "node_modules/@esbuild/linux-arm64": { | |
| 459 | + "version": "0.21.5", | |
| 460 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", | |
| 461 | + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", | |
| 462 | + "cpu": [ | |
| 463 | + "arm64" | |
| 464 | + ], | |
| 465 | + "dev": true, | |
| 466 | + "license": "MIT", | |
| 467 | + "optional": true, | |
| 468 | + "os": [ | |
| 469 | + "linux" | |
| 470 | + ], | |
| 471 | + "engines": { | |
| 472 | + "node": ">=12" | |
| 473 | + } | |
| 474 | + }, | |
| 475 | + "node_modules/@esbuild/linux-ia32": { | |
| 476 | + "version": "0.21.5", | |
| 477 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", | |
| 478 | + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", | |
| 479 | + "cpu": [ | |
| 480 | + "ia32" | |
| 481 | + ], | |
| 482 | + "dev": true, | |
| 483 | + "license": "MIT", | |
| 484 | + "optional": true, | |
| 485 | + "os": [ | |
| 486 | + "linux" | |
| 487 | + ], | |
| 488 | + "engines": { | |
| 489 | + "node": ">=12" | |
| 490 | + } | |
| 491 | + }, | |
| 492 | + "node_modules/@esbuild/linux-loong64": { | |
| 493 | + "version": "0.21.5", | |
| 494 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", | |
| 495 | + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", | |
| 496 | + "cpu": [ | |
| 497 | + "loong64" | |
| 498 | + ], | |
| 499 | + "dev": true, | |
| 500 | + "license": "MIT", | |
| 501 | + "optional": true, | |
| 502 | + "os": [ | |
| 503 | + "linux" | |
| 504 | + ], | |
| 505 | + "engines": { | |
| 506 | + "node": ">=12" | |
| 507 | + } | |
| 508 | + }, | |
| 509 | + "node_modules/@esbuild/linux-mips64el": { | |
| 510 | + "version": "0.21.5", | |
| 511 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", | |
| 512 | + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", | |
| 513 | + "cpu": [ | |
| 514 | + "mips64el" | |
| 515 | + ], | |
| 516 | + "dev": true, | |
| 517 | + "license": "MIT", | |
| 518 | + "optional": true, | |
| 519 | + "os": [ | |
| 520 | + "linux" | |
| 521 | + ], | |
| 522 | + "engines": { | |
| 523 | + "node": ">=12" | |
| 524 | + } | |
| 525 | + }, | |
| 526 | + "node_modules/@esbuild/linux-ppc64": { | |
| 527 | + "version": "0.21.5", | |
| 528 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", | |
| 529 | + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", | |
| 530 | + "cpu": [ | |
| 531 | + "ppc64" | |
| 532 | + ], | |
| 533 | + "dev": true, | |
| 534 | + "license": "MIT", | |
| 535 | + "optional": true, | |
| 536 | + "os": [ | |
| 537 | + "linux" | |
| 538 | + ], | |
| 539 | + "engines": { | |
| 540 | + "node": ">=12" | |
| 541 | + } | |
| 542 | + }, | |
| 543 | + "node_modules/@esbuild/linux-riscv64": { | |
| 544 | + "version": "0.21.5", | |
| 545 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", | |
| 546 | + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", | |
| 547 | + "cpu": [ | |
| 548 | + "riscv64" | |
| 549 | + ], | |
| 550 | + "dev": true, | |
| 551 | + "license": "MIT", | |
| 552 | + "optional": true, | |
| 553 | + "os": [ | |
| 554 | + "linux" | |
| 555 | + ], | |
| 556 | + "engines": { | |
| 557 | + "node": ">=12" | |
| 558 | + } | |
| 559 | + }, | |
| 560 | + "node_modules/@esbuild/linux-s390x": { | |
| 561 | + "version": "0.21.5", | |
| 562 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", | |
| 563 | + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", | |
| 564 | + "cpu": [ | |
| 565 | + "s390x" | |
| 566 | + ], | |
| 567 | + "dev": true, | |
| 568 | + "license": "MIT", | |
| 569 | + "optional": true, | |
| 570 | + "os": [ | |
| 571 | + "linux" | |
| 572 | + ], | |
| 573 | + "engines": { | |
| 574 | + "node": ">=12" | |
| 575 | + } | |
| 576 | + }, | |
| 577 | + "node_modules/@esbuild/linux-x64": { | |
| 578 | + "version": "0.21.5", | |
| 579 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", | |
| 580 | + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", | |
| 581 | + "cpu": [ | |
| 582 | + "x64" | |
| 583 | + ], | |
| 584 | + "dev": true, | |
| 585 | + "license": "MIT", | |
| 586 | + "optional": true, | |
| 587 | + "os": [ | |
| 588 | + "linux" | |
| 589 | + ], | |
| 590 | + "engines": { | |
| 591 | + "node": ">=12" | |
| 592 | + } | |
| 593 | + }, | |
| 594 | + "node_modules/@esbuild/netbsd-x64": { | |
| 595 | + "version": "0.21.5", | |
| 596 | + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", | |
| 597 | + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", | |
| 598 | + "cpu": [ | |
| 599 | + "x64" | |
| 600 | + ], | |
| 601 | + "dev": true, | |
| 602 | + "license": "MIT", | |
| 603 | + "optional": true, | |
| 604 | + "os": [ | |
| 605 | + "netbsd" | |
| 606 | + ], | |
| 607 | + "engines": { | |
| 608 | + "node": ">=12" | |
| 609 | + } | |
| 610 | + }, | |
| 611 | + "node_modules/@esbuild/openbsd-x64": { | |
| 612 | + "version": "0.21.5", | |
| 613 | + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", | |
| 614 | + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", | |
| 615 | + "cpu": [ | |
| 616 | + "x64" | |
| 617 | + ], | |
| 618 | + "dev": true, | |
| 619 | + "license": "MIT", | |
| 620 | + "optional": true, | |
| 621 | + "os": [ | |
| 622 | + "openbsd" | |
| 623 | + ], | |
| 624 | + "engines": { | |
| 625 | + "node": ">=12" | |
| 626 | + } | |
| 627 | + }, | |
| 628 | + "node_modules/@esbuild/sunos-x64": { | |
| 629 | + "version": "0.21.5", | |
| 630 | + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", | |
| 631 | + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", | |
| 632 | + "cpu": [ | |
| 633 | + "x64" | |
| 634 | + ], | |
| 635 | + "dev": true, | |
| 636 | + "license": "MIT", | |
| 637 | + "optional": true, | |
| 638 | + "os": [ | |
| 639 | + "sunos" | |
| 640 | + ], | |
| 641 | + "engines": { | |
| 642 | + "node": ">=12" | |
| 643 | + } | |
| 644 | + }, | |
| 645 | + "node_modules/@esbuild/win32-arm64": { | |
| 646 | + "version": "0.21.5", | |
| 647 | + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", | |
| 648 | + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", | |
| 649 | + "cpu": [ | |
| 650 | + "arm64" | |
| 651 | + ], | |
| 652 | + "dev": true, | |
| 653 | + "license": "MIT", | |
| 654 | + "optional": true, | |
| 655 | + "os": [ | |
| 656 | + "win32" | |
| 657 | + ], | |
| 658 | + "engines": { | |
| 659 | + "node": ">=12" | |
| 660 | + } | |
| 661 | + }, | |
| 662 | + "node_modules/@esbuild/win32-ia32": { | |
| 663 | + "version": "0.21.5", | |
| 664 | + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", | |
| 665 | + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", | |
| 666 | + "cpu": [ | |
| 667 | + "ia32" | |
| 668 | + ], | |
| 669 | + "dev": true, | |
| 670 | + "license": "MIT", | |
| 671 | + "optional": true, | |
| 672 | + "os": [ | |
| 673 | + "win32" | |
| 674 | + ], | |
| 675 | + "engines": { | |
| 676 | + "node": ">=12" | |
| 677 | + } | |
| 678 | + }, | |
| 679 | + "node_modules/@esbuild/win32-x64": { | |
| 680 | + "version": "0.21.5", | |
| 681 | + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", | |
| 682 | + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", | |
| 683 | + "cpu": [ | |
| 684 | + "x64" | |
| 685 | + ], | |
| 686 | + "dev": true, | |
| 687 | + "license": "MIT", | |
| 688 | + "optional": true, | |
| 689 | + "os": [ | |
| 690 | + "win32" | |
| 691 | + ], | |
| 692 | + "engines": { | |
| 693 | + "node": ">=12" | |
| 694 | + } | |
| 695 | + }, | |
| 696 | + "node_modules/@jridgewell/gen-mapping": { | |
| 697 | + "version": "0.3.13", | |
| 698 | + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", | |
| 699 | + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", | |
| 700 | + "dev": true, | |
| 701 | + "license": "MIT", | |
| 702 | + "dependencies": { | |
| 703 | + "@jridgewell/sourcemap-codec": "^1.5.0", | |
| 704 | + "@jridgewell/trace-mapping": "^0.3.24" | |
| 705 | + } | |
| 706 | + }, | |
| 707 | + "node_modules/@jridgewell/remapping": { | |
| 708 | + "version": "2.3.5", | |
| 709 | + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", | |
| 710 | + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", | |
| 711 | + "dev": true, | |
| 712 | + "license": "MIT", | |
| 713 | + "dependencies": { | |
| 714 | + "@jridgewell/gen-mapping": "^0.3.5", | |
| 715 | + "@jridgewell/trace-mapping": "^0.3.24" | |
| 716 | + } | |
| 717 | + }, | |
| 718 | + "node_modules/@jridgewell/resolve-uri": { | |
| 719 | + "version": "3.1.2", | |
| 720 | + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", | |
| 721 | + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", | |
| 722 | + "dev": true, | |
| 723 | + "license": "MIT", | |
| 724 | + "engines": { | |
| 725 | + "node": ">=6.0.0" | |
| 726 | + } | |
| 727 | + }, | |
| 728 | + "node_modules/@jridgewell/sourcemap-codec": { | |
| 729 | + "version": "1.5.5", | |
| 730 | + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", | |
| 731 | + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", | |
| 732 | + "dev": true, | |
| 733 | + "license": "MIT" | |
| 734 | + }, | |
| 735 | + "node_modules/@jridgewell/trace-mapping": { | |
| 736 | + "version": "0.3.31", | |
| 737 | + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", | |
| 738 | + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", | |
| 739 | + "dev": true, | |
| 740 | + "license": "MIT", | |
| 741 | + "dependencies": { | |
| 742 | + "@jridgewell/resolve-uri": "^3.1.0", | |
| 743 | + "@jridgewell/sourcemap-codec": "^1.4.14" | |
| 744 | + } | |
| 745 | + }, | |
| 746 | + "node_modules/@napi-rs/lzma-linux-x64-gnu": { | |
| 747 | + "version": "1.5.1", | |
| 748 | + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", | |
| 749 | + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", | |
| 750 | + "cpu": [ | |
| 751 | + "x64" | |
| 752 | + ], | |
| 753 | + "dev": true, | |
| 754 | + "libc": [ | |
| 755 | + "glibc" | |
| 756 | + ], | |
| 757 | + "license": "MIT", | |
| 758 | + "optional": true, | |
| 759 | + "os": [ | |
| 760 | + "linux" | |
| 761 | + ], | |
| 762 | + "engines": { | |
| 763 | + "node": "^22.20 || ^24.12 || >=25" | |
| 764 | + } | |
| 765 | + }, | |
| 766 | + "node_modules/@remix-run/router": { | |
| 767 | + "version": "1.23.3", | |
| 768 | + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", | |
| 769 | + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", | |
| 770 | + "license": "MIT", | |
| 771 | + "engines": { | |
| 772 | + "node": ">=14.0.0" | |
| 773 | + } | |
| 774 | + }, | |
| 775 | + "node_modules/@rolldown/pluginutils": { | |
| 776 | + "version": "1.0.0-beta.27", | |
| 777 | + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", | |
| 778 | + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", | |
| 779 | + "dev": true, | |
| 780 | + "license": "MIT" | |
| 781 | + }, | |
| 782 | + "node_modules/@rollup/rollup-android-arm-eabi": { | |
| 783 | + "version": "4.62.4", | |
| 784 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", | |
| 785 | + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", | |
| 786 | + "cpu": [ | |
| 787 | + "arm" | |
| 788 | + ], | |
| 789 | + "dev": true, | |
| 790 | + "license": "MIT", | |
| 791 | + "optional": true, | |
| 792 | + "os": [ | |
| 793 | + "android" | |
| 794 | + ] | |
| 795 | + }, | |
| 796 | + "node_modules/@rollup/rollup-android-arm64": { | |
| 797 | + "version": "4.62.4", | |
| 798 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", | |
| 799 | + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", | |
| 800 | + "cpu": [ | |
| 801 | + "arm64" | |
| 802 | + ], | |
| 803 | + "dev": true, | |
| 804 | + "license": "MIT", | |
| 805 | + "optional": true, | |
| 806 | + "os": [ | |
| 807 | + "android" | |
| 808 | + ] | |
| 809 | + }, | |
| 810 | + "node_modules/@rollup/rollup-darwin-arm64": { | |
| 811 | + "version": "4.62.4", | |
| 812 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", | |
| 813 | + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", | |
| 814 | + "cpu": [ | |
| 815 | + "arm64" | |
| 816 | + ], | |
| 817 | + "dev": true, | |
| 818 | + "license": "MIT", | |
| 819 | + "optional": true, | |
| 820 | + "os": [ | |
| 821 | + "darwin" | |
| 822 | + ] | |
| 823 | + }, | |
| 824 | + "node_modules/@rollup/rollup-darwin-x64": { | |
| 825 | + "version": "4.62.4", | |
| 826 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", | |
| 827 | + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", | |
| 828 | + "cpu": [ | |
| 829 | + "x64" | |
| 830 | + ], | |
| 831 | + "dev": true, | |
| 832 | + "license": "MIT", | |
| 833 | + "optional": true, | |
| 834 | + "os": [ | |
| 835 | + "darwin" | |
| 836 | + ] | |
| 837 | + }, | |
| 838 | + "node_modules/@rollup/rollup-freebsd-arm64": { | |
| 839 | + "version": "4.62.4", | |
| 840 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", | |
| 841 | + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", | |
| 842 | + "cpu": [ | |
| 843 | + "arm64" | |
| 844 | + ], | |
| 845 | + "dev": true, | |
| 846 | + "license": "MIT", | |
| 847 | + "optional": true, | |
| 848 | + "os": [ | |
| 849 | + "freebsd" | |
| 850 | + ] | |
| 851 | + }, | |
| 852 | + "node_modules/@rollup/rollup-freebsd-x64": { | |
| 853 | + "version": "4.62.4", | |
| 854 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", | |
| 855 | + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", | |
| 856 | + "cpu": [ | |
| 857 | + "x64" | |
| 858 | + ], | |
| 859 | + "dev": true, | |
| 860 | + "license": "MIT", | |
| 861 | + "optional": true, | |
| 862 | + "os": [ | |
| 863 | + "freebsd" | |
| 864 | + ] | |
| 865 | + }, | |
| 866 | + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { | |
| 867 | + "version": "4.62.4", | |
| 868 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", | |
| 869 | + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", | |
| 870 | + "cpu": [ | |
| 871 | + "arm" | |
| 872 | + ], | |
| 873 | + "dev": true, | |
| 874 | + "libc": [ | |
| 875 | + "glibc" | |
| 876 | + ], | |
| 877 | + "license": "MIT", | |
| 878 | + "optional": true, | |
| 879 | + "os": [ | |
| 880 | + "linux" | |
| 881 | + ] | |
| 882 | + }, | |
| 883 | + "node_modules/@rollup/rollup-linux-arm-musleabihf": { | |
| 884 | + "version": "4.62.4", | |
| 885 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", | |
| 886 | + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", | |
| 887 | + "cpu": [ | |
| 888 | + "arm" | |
| 889 | + ], | |
| 890 | + "dev": true, | |
| 891 | + "libc": [ | |
| 892 | + "musl" | |
| 893 | + ], | |
| 894 | + "license": "MIT", | |
| 895 | + "optional": true, | |
| 896 | + "os": [ | |
| 897 | + "linux" | |
| 898 | + ] | |
| 899 | + }, | |
| 900 | + "node_modules/@rollup/rollup-linux-arm64-gnu": { | |
| 901 | + "version": "4.62.4", | |
| 902 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", | |
| 903 | + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", | |
| 904 | + "cpu": [ | |
| 905 | + "arm64" | |
| 906 | + ], | |
| 907 | + "dev": true, | |
| 908 | + "libc": [ | |
| 909 | + "glibc" | |
| 910 | + ], | |
| 911 | + "license": "MIT", | |
| 912 | + "optional": true, | |
| 913 | + "os": [ | |
| 914 | + "linux" | |
| 915 | + ] | |
| 916 | + }, | |
| 917 | + "node_modules/@rollup/rollup-linux-arm64-musl": { | |
| 918 | + "version": "4.62.4", | |
| 919 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", | |
| 920 | + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", | |
| 921 | + "cpu": [ | |
| 922 | + "arm64" | |
| 923 | + ], | |
| 924 | + "dev": true, | |
| 925 | + "libc": [ | |
| 926 | + "musl" | |
| 927 | + ], | |
| 928 | + "license": "MIT", | |
| 929 | + "optional": true, | |
| 930 | + "os": [ | |
| 931 | + "linux" | |
| 932 | + ] | |
| 933 | + }, | |
| 934 | + "node_modules/@rollup/rollup-linux-loong64-gnu": { | |
| 935 | + "version": "4.62.4", | |
| 936 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", | |
| 937 | + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", | |
| 938 | + "cpu": [ | |
| 939 | + "loong64" | |
| 940 | + ], | |
| 941 | + "dev": true, | |
| 942 | + "libc": [ | |
| 943 | + "glibc" | |
| 944 | + ], | |
| 945 | + "license": "MIT", | |
| 946 | + "optional": true, | |
| 947 | + "os": [ | |
| 948 | + "linux" | |
| 949 | + ] | |
| 950 | + }, | |
| 951 | + "node_modules/@rollup/rollup-linux-loong64-musl": { | |
| 952 | + "version": "4.62.4", | |
| 953 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", | |
| 954 | + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", | |
| 955 | + "cpu": [ | |
| 956 | + "loong64" | |
| 957 | + ], | |
| 958 | + "dev": true, | |
| 959 | + "libc": [ | |
| 960 | + "musl" | |
| 961 | + ], | |
| 962 | + "license": "MIT", | |
| 963 | + "optional": true, | |
| 964 | + "os": [ | |
| 965 | + "linux" | |
| 966 | + ] | |
| 967 | + }, | |
| 968 | + "node_modules/@rollup/rollup-linux-ppc64-gnu": { | |
| 969 | + "version": "4.62.4", | |
| 970 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", | |
| 971 | + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", | |
| 972 | + "cpu": [ | |
| 973 | + "ppc64" | |
| 974 | + ], | |
| 975 | + "dev": true, | |
| 976 | + "libc": [ | |
| 977 | + "glibc" | |
| 978 | + ], | |
| 979 | + "license": "MIT", | |
| 980 | + "optional": true, | |
| 981 | + "os": [ | |
| 982 | + "linux" | |
| 983 | + ] | |
| 984 | + }, | |
| 985 | + "node_modules/@rollup/rollup-linux-ppc64-musl": { | |
| 986 | + "version": "4.62.4", | |
| 987 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", | |
| 988 | + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", | |
| 989 | + "cpu": [ | |
| 990 | + "ppc64" | |
| 991 | + ], | |
| 992 | + "dev": true, | |
| 993 | + "libc": [ | |
| 994 | + "musl" | |
| 995 | + ], | |
| 996 | + "license": "MIT", | |
| 997 | + "optional": true, | |
| 998 | + "os": [ | |
| 999 | + "linux" | |
| 1000 | + ] | |
| 1001 | + }, | |
| 1002 | + "node_modules/@rollup/rollup-linux-riscv64-gnu": { | |
| 1003 | + "version": "4.62.4", | |
| 1004 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", | |
| 1005 | + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", | |
| 1006 | + "cpu": [ | |
| 1007 | + "riscv64" | |
| 1008 | + ], | |
| 1009 | + "dev": true, | |
| 1010 | + "libc": [ | |
| 1011 | + "glibc" | |
| 1012 | + ], | |
| 1013 | + "license": "MIT", | |
| 1014 | + "optional": true, | |
| 1015 | + "os": [ | |
| 1016 | + "linux" | |
| 1017 | + ] | |
| 1018 | + }, | |
| 1019 | + "node_modules/@rollup/rollup-linux-riscv64-musl": { | |
| 1020 | + "version": "4.62.4", | |
| 1021 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", | |
| 1022 | + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", | |
| 1023 | + "cpu": [ | |
| 1024 | + "riscv64" | |
| 1025 | + ], | |
| 1026 | + "dev": true, | |
| 1027 | + "libc": [ | |
| 1028 | + "musl" | |
| 1029 | + ], | |
| 1030 | + "license": "MIT", | |
| 1031 | + "optional": true, | |
| 1032 | + "os": [ | |
| 1033 | + "linux" | |
| 1034 | + ] | |
| 1035 | + }, | |
| 1036 | + "node_modules/@rollup/rollup-linux-s390x-gnu": { | |
| 1037 | + "version": "4.62.4", | |
| 1038 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", | |
| 1039 | + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", | |
| 1040 | + "cpu": [ | |
| 1041 | + "s390x" | |
| 1042 | + ], | |
| 1043 | + "dev": true, | |
| 1044 | + "libc": [ | |
| 1045 | + "glibc" | |
| 1046 | + ], | |
| 1047 | + "license": "MIT", | |
| 1048 | + "optional": true, | |
| 1049 | + "os": [ | |
| 1050 | + "linux" | |
| 1051 | + ] | |
| 1052 | + }, | |
| 1053 | + "node_modules/@rollup/rollup-linux-x64-gnu": { | |
| 1054 | + "version": "4.62.4", | |
| 1055 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", | |
| 1056 | + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", | |
| 1057 | + "cpu": [ | |
| 1058 | + "x64" | |
| 1059 | + ], | |
| 1060 | + "dev": true, | |
| 1061 | + "libc": [ | |
| 1062 | + "glibc" | |
| 1063 | + ], | |
| 1064 | + "license": "MIT", | |
| 1065 | + "optional": true, | |
| 1066 | + "os": [ | |
| 1067 | + "linux" | |
| 1068 | + ] | |
| 1069 | + }, | |
| 1070 | + "node_modules/@rollup/rollup-linux-x64-musl": { | |
| 1071 | + "version": "4.62.4", | |
| 1072 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", | |
| 1073 | + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", | |
| 1074 | + "cpu": [ | |
| 1075 | + "x64" | |
| 1076 | + ], | |
| 1077 | + "dev": true, | |
| 1078 | + "libc": [ | |
| 1079 | + "musl" | |
| 1080 | + ], | |
| 1081 | + "license": "MIT", | |
| 1082 | + "optional": true, | |
| 1083 | + "os": [ | |
| 1084 | + "linux" | |
| 1085 | + ] | |
| 1086 | + }, | |
| 1087 | + "node_modules/@rollup/rollup-openbsd-x64": { | |
| 1088 | + "version": "4.62.4", | |
| 1089 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", | |
| 1090 | + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", | |
| 1091 | + "cpu": [ | |
| 1092 | + "x64" | |
| 1093 | + ], | |
| 1094 | + "dev": true, | |
| 1095 | + "license": "MIT", | |
| 1096 | + "optional": true, | |
| 1097 | + "os": [ | |
| 1098 | + "openbsd" | |
| 1099 | + ] | |
| 1100 | + }, | |
| 1101 | + "node_modules/@rollup/rollup-openharmony-arm64": { | |
| 1102 | + "version": "4.62.4", | |
| 1103 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", | |
| 1104 | + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", | |
| 1105 | + "cpu": [ | |
| 1106 | + "arm64" | |
| 1107 | + ], | |
| 1108 | + "dev": true, | |
| 1109 | + "license": "MIT", | |
| 1110 | + "optional": true, | |
| 1111 | + "os": [ | |
| 1112 | + "openharmony" | |
| 1113 | + ] | |
| 1114 | + }, | |
| 1115 | + "node_modules/@rollup/rollup-win32-arm64-msvc": { | |
| 1116 | + "version": "4.62.4", | |
| 1117 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", | |
| 1118 | + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", | |
| 1119 | + "cpu": [ | |
| 1120 | + "arm64" | |
| 1121 | + ], | |
| 1122 | + "dev": true, | |
| 1123 | + "license": "MIT", | |
| 1124 | + "optional": true, | |
| 1125 | + "os": [ | |
| 1126 | + "win32" | |
| 1127 | + ] | |
| 1128 | + }, | |
| 1129 | + "node_modules/@rollup/rollup-win32-ia32-msvc": { | |
| 1130 | + "version": "4.62.4", | |
| 1131 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", | |
| 1132 | + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", | |
| 1133 | + "cpu": [ | |
| 1134 | + "ia32" | |
| 1135 | + ], | |
| 1136 | + "dev": true, | |
| 1137 | + "license": "MIT", | |
| 1138 | + "optional": true, | |
| 1139 | + "os": [ | |
| 1140 | + "win32" | |
| 1141 | + ] | |
| 1142 | + }, | |
| 1143 | + "node_modules/@rollup/rollup-win32-x64-gnu": { | |
| 1144 | + "version": "4.62.4", | |
| 1145 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", | |
| 1146 | + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", | |
| 1147 | + "cpu": [ | |
| 1148 | + "x64" | |
| 1149 | + ], | |
| 1150 | + "dev": true, | |
| 1151 | + "license": "MIT", | |
| 1152 | + "optional": true, | |
| 1153 | + "os": [ | |
| 1154 | + "win32" | |
| 1155 | + ] | |
| 1156 | + }, | |
| 1157 | + "node_modules/@rollup/rollup-win32-x64-msvc": { | |
| 1158 | + "version": "4.62.4", | |
| 1159 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", | |
| 1160 | + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", | |
| 1161 | + "cpu": [ | |
| 1162 | + "x64" | |
| 1163 | + ], | |
| 1164 | + "dev": true, | |
| 1165 | + "license": "MIT", | |
| 1166 | + "optional": true, | |
| 1167 | + "os": [ | |
| 1168 | + "win32" | |
| 1169 | + ] | |
| 1170 | + }, | |
| 1171 | + "node_modules/@types/babel__core": { | |
| 1172 | + "version": "7.20.5", | |
| 1173 | + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", | |
| 1174 | + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", | |
| 1175 | + "dev": true, | |
| 1176 | + "license": "MIT", | |
| 1177 | + "dependencies": { | |
| 1178 | + "@babel/parser": "^7.20.7", | |
| 1179 | + "@babel/types": "^7.20.7", | |
| 1180 | + "@types/babel__generator": "*", | |
| 1181 | + "@types/babel__template": "*", | |
| 1182 | + "@types/babel__traverse": "*" | |
| 1183 | + } | |
| 1184 | + }, | |
| 1185 | + "node_modules/@types/babel__generator": { | |
| 1186 | + "version": "7.27.0", | |
| 1187 | + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", | |
| 1188 | + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", | |
| 1189 | + "dev": true, | |
| 1190 | + "license": "MIT", | |
| 1191 | + "dependencies": { | |
| 1192 | + "@babel/types": "^7.0.0" | |
| 1193 | + } | |
| 1194 | + }, | |
| 1195 | + "node_modules/@types/babel__template": { | |
| 1196 | + "version": "7.4.4", | |
| 1197 | + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", | |
| 1198 | + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", | |
| 1199 | + "dev": true, | |
| 1200 | + "license": "MIT", | |
| 1201 | + "dependencies": { | |
| 1202 | + "@babel/parser": "^7.1.0", | |
| 1203 | + "@babel/types": "^7.0.0" | |
| 1204 | + } | |
| 1205 | + }, | |
| 1206 | + "node_modules/@types/babel__traverse": { | |
| 1207 | + "version": "7.28.0", | |
| 1208 | + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", | |
| 1209 | + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", | |
| 1210 | + "dev": true, | |
| 1211 | + "license": "MIT", | |
| 1212 | + "dependencies": { | |
| 1213 | + "@babel/types": "^7.28.2" | |
| 1214 | + } | |
| 1215 | + }, | |
| 1216 | + "node_modules/@types/estree": { | |
| 1217 | + "version": "1.0.9", | |
| 1218 | + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", | |
| 1219 | + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", | |
| 1220 | + "dev": true, | |
| 1221 | + "license": "MIT" | |
| 1222 | + }, | |
| 1223 | + "node_modules/@types/prop-types": { | |
| 1224 | + "version": "15.7.15", | |
| 1225 | + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", | |
| 1226 | + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", | |
| 1227 | + "dev": true, | |
| 1228 | + "license": "MIT" | |
| 1229 | + }, | |
| 1230 | + "node_modules/@types/react": { | |
| 1231 | + "version": "18.3.31", | |
| 1232 | + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", | |
| 1233 | + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", | |
| 1234 | + "dev": true, | |
| 1235 | + "license": "MIT", | |
| 1236 | + "dependencies": { | |
| 1237 | + "@types/prop-types": "*", | |
| 1238 | + "csstype": "^3.2.2" | |
| 1239 | + } | |
| 1240 | + }, | |
| 1241 | + "node_modules/@types/react-dom": { | |
| 1242 | + "version": "18.3.7", | |
| 1243 | + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", | |
| 1244 | + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", | |
| 1245 | + "dev": true, | |
| 1246 | + "license": "MIT", | |
| 1247 | + "peerDependencies": { | |
| 1248 | + "@types/react": "^18.0.0" | |
| 1249 | + } | |
| 1250 | + }, | |
| 1251 | + "node_modules/@vitejs/plugin-react": { | |
| 1252 | + "version": "4.7.0", | |
| 1253 | + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", | |
| 1254 | + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", | |
| 1255 | + "dev": true, | |
| 1256 | + "license": "MIT", | |
| 1257 | + "dependencies": { | |
| 1258 | + "@babel/core": "^7.28.0", | |
| 1259 | + "@babel/plugin-transform-react-jsx-self": "^7.27.1", | |
| 1260 | + "@babel/plugin-transform-react-jsx-source": "^7.27.1", | |
| 1261 | + "@rolldown/pluginutils": "1.0.0-beta.27", | |
| 1262 | + "@types/babel__core": "^7.20.5", | |
| 1263 | + "react-refresh": "^0.17.0" | |
| 1264 | + }, | |
| 1265 | + "engines": { | |
| 1266 | + "node": "^14.18.0 || >=16.0.0" | |
| 1267 | + }, | |
| 1268 | + "peerDependencies": { | |
| 1269 | + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" | |
| 1270 | + } | |
| 1271 | + }, | |
| 1272 | + "node_modules/baseline-browser-mapping": { | |
| 1273 | + "version": "2.11.13", | |
| 1274 | + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", | |
| 1275 | + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", | |
| 1276 | + "dev": true, | |
| 1277 | + "license": "Apache-2.0", | |
| 1278 | + "bin": { | |
| 1279 | + "baseline-browser-mapping": "dist/cli.cjs" | |
| 1280 | + }, | |
| 1281 | + "engines": { | |
| 1282 | + "node": ">=6.0.0" | |
| 1283 | + } | |
| 1284 | + }, | |
| 1285 | + "node_modules/browserslist": { | |
| 1286 | + "version": "4.28.8", | |
| 1287 | + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", | |
| 1288 | + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", | |
| 1289 | + "dev": true, | |
| 1290 | + "funding": [ | |
| 1291 | + { | |
| 1292 | + "type": "opencollective", | |
| 1293 | + "url": "https://opencollective.com/browserslist" | |
| 1294 | + }, | |
| 1295 | + { | |
| 1296 | + "type": "tidelift", | |
| 1297 | + "url": "https://tidelift.com/funding/github/npm/browserslist" | |
| 1298 | + }, | |
| 1299 | + { | |
| 1300 | + "type": "github", | |
| 1301 | + "url": "https://github.com/sponsors/ai" | |
| 1302 | + } | |
| 1303 | + ], | |
| 1304 | + "license": "MIT", | |
| 1305 | + "dependencies": { | |
| 1306 | + "baseline-browser-mapping": "^2.11.12", | |
| 1307 | + "caniuse-lite": "^1.0.30001809", | |
| 1308 | + "electron-to-chromium": "^1.5.402", | |
| 1309 | + "node-releases": "^2.0.53", | |
| 1310 | + "update-browserslist-db": "^1.3.0" | |
| 1311 | + }, | |
| 1312 | + "bin": { | |
| 1313 | + "browserslist": "cli.js" | |
| 1314 | + }, | |
| 1315 | + "engines": { | |
| 1316 | + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" | |
| 1317 | + } | |
| 1318 | + }, | |
| 1319 | + "node_modules/caniuse-lite": { | |
| 1320 | + "version": "1.0.30001809", | |
| 1321 | + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", | |
| 1322 | + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", | |
| 1323 | + "dev": true, | |
| 1324 | + "funding": [ | |
| 1325 | + { | |
| 1326 | + "type": "opencollective", | |
| 1327 | + "url": "https://opencollective.com/browserslist" | |
| 1328 | + }, | |
| 1329 | + { | |
| 1330 | + "type": "tidelift", | |
| 1331 | + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" | |
| 1332 | + }, | |
| 1333 | + { | |
| 1334 | + "type": "github", | |
| 1335 | + "url": "https://github.com/sponsors/ai" | |
| 1336 | + } | |
| 1337 | + ], | |
| 1338 | + "license": "CC-BY-4.0" | |
| 1339 | + }, | |
| 1340 | + "node_modules/convert-source-map": { | |
| 1341 | + "version": "2.0.0", | |
| 1342 | + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", | |
| 1343 | + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", | |
| 1344 | + "dev": true, | |
| 1345 | + "license": "MIT" | |
| 1346 | + }, | |
| 1347 | + "node_modules/csstype": { | |
| 1348 | + "version": "3.2.3", | |
| 1349 | + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", | |
| 1350 | + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", | |
| 1351 | + "dev": true, | |
| 1352 | + "license": "MIT" | |
| 1353 | + }, | |
| 1354 | + "node_modules/debug": { | |
| 1355 | + "version": "4.4.3", | |
| 1356 | + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", | |
| 1357 | + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", | |
| 1358 | + "dev": true, | |
| 1359 | + "license": "MIT", | |
| 1360 | + "dependencies": { | |
| 1361 | + "ms": "^2.1.3" | |
| 1362 | + }, | |
| 1363 | + "engines": { | |
| 1364 | + "node": ">=6.0" | |
| 1365 | + }, | |
| 1366 | + "peerDependenciesMeta": { | |
| 1367 | + "supports-color": { | |
| 1368 | + "optional": true | |
| 1369 | + } | |
| 1370 | + } | |
| 1371 | + }, | |
| 1372 | + "node_modules/electron-to-chromium": { | |
| 1373 | + "version": "1.5.405", | |
| 1374 | + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", | |
| 1375 | + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", | |
| 1376 | + "dev": true, | |
| 1377 | + "license": "ISC" | |
| 1378 | + }, | |
| 1379 | + "node_modules/esbuild": { | |
| 1380 | + "version": "0.21.5", | |
| 1381 | + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", | |
| 1382 | + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", | |
| 1383 | + "dev": true, | |
| 1384 | + "hasInstallScript": true, | |
| 1385 | + "license": "MIT", | |
| 1386 | + "bin": { | |
| 1387 | + "esbuild": "bin/esbuild" | |
| 1388 | + }, | |
| 1389 | + "engines": { | |
| 1390 | + "node": ">=12" | |
| 1391 | + }, | |
| 1392 | + "optionalDependencies": { | |
| 1393 | + "@esbuild/aix-ppc64": "0.21.5", | |
| 1394 | + "@esbuild/android-arm": "0.21.5", | |
| 1395 | + "@esbuild/android-arm64": "0.21.5", | |
| 1396 | + "@esbuild/android-x64": "0.21.5", | |
| 1397 | + "@esbuild/darwin-arm64": "0.21.5", | |
| 1398 | + "@esbuild/darwin-x64": "0.21.5", | |
| 1399 | + "@esbuild/freebsd-arm64": "0.21.5", | |
| 1400 | + "@esbuild/freebsd-x64": "0.21.5", | |
| 1401 | + "@esbuild/linux-arm": "0.21.5", | |
| 1402 | + "@esbuild/linux-arm64": "0.21.5", | |
| 1403 | + "@esbuild/linux-ia32": "0.21.5", | |
| 1404 | + "@esbuild/linux-loong64": "0.21.5", | |
| 1405 | + "@esbuild/linux-mips64el": "0.21.5", | |
| 1406 | + "@esbuild/linux-ppc64": "0.21.5", | |
| 1407 | + "@esbuild/linux-riscv64": "0.21.5", | |
| 1408 | + "@esbuild/linux-s390x": "0.21.5", | |
| 1409 | + "@esbuild/linux-x64": "0.21.5", | |
| 1410 | + "@esbuild/netbsd-x64": "0.21.5", | |
| 1411 | + "@esbuild/openbsd-x64": "0.21.5", | |
| 1412 | + "@esbuild/sunos-x64": "0.21.5", | |
| 1413 | + "@esbuild/win32-arm64": "0.21.5", | |
| 1414 | + "@esbuild/win32-ia32": "0.21.5", | |
| 1415 | + "@esbuild/win32-x64": "0.21.5" | |
| 1416 | + } | |
| 1417 | + }, | |
| 1418 | + "node_modules/escalade": { | |
| 1419 | + "version": "3.2.0", | |
| 1420 | + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", | |
| 1421 | + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", | |
| 1422 | + "dev": true, | |
| 1423 | + "license": "MIT", | |
| 1424 | + "engines": { | |
| 1425 | + "node": ">=6" | |
| 1426 | + } | |
| 1427 | + }, | |
| 1428 | + "node_modules/fsevents": { | |
| 1429 | + "version": "2.3.3", | |
| 1430 | + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", | |
| 1431 | + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", | |
| 1432 | + "dev": true, | |
| 1433 | + "hasInstallScript": true, | |
| 1434 | + "license": "MIT", | |
| 1435 | + "optional": true, | |
| 1436 | + "os": [ | |
| 1437 | + "darwin" | |
| 1438 | + ], | |
| 1439 | + "engines": { | |
| 1440 | + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |
| 1441 | + } | |
| 1442 | + }, | |
| 1443 | + "node_modules/gensync": { | |
| 1444 | + "version": "1.0.0-beta.2", | |
| 1445 | + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", | |
| 1446 | + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", | |
| 1447 | + "dev": true, | |
| 1448 | + "license": "MIT", | |
| 1449 | + "engines": { | |
| 1450 | + "node": ">=6.9.0" | |
| 1451 | + } | |
| 1452 | + }, | |
| 1453 | + "node_modules/js-tokens": { | |
| 1454 | + "version": "4.0.0", | |
| 1455 | + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", | |
| 1456 | + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", | |
| 1457 | + "license": "MIT" | |
| 1458 | + }, | |
| 1459 | + "node_modules/jsesc": { | |
| 1460 | + "version": "3.1.0", | |
| 1461 | + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", | |
| 1462 | + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", | |
| 1463 | + "dev": true, | |
| 1464 | + "license": "MIT", | |
| 1465 | + "bin": { | |
| 1466 | + "jsesc": "bin/jsesc" | |
| 1467 | + }, | |
| 1468 | + "engines": { | |
| 1469 | + "node": ">=6" | |
| 1470 | + } | |
| 1471 | + }, | |
| 1472 | + "node_modules/json5": { | |
| 1473 | + "version": "2.2.3", | |
| 1474 | + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", | |
| 1475 | + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", | |
| 1476 | + "dev": true, | |
| 1477 | + "license": "MIT", | |
| 1478 | + "bin": { | |
| 1479 | + "json5": "lib/cli.js" | |
| 1480 | + }, | |
| 1481 | + "engines": { | |
| 1482 | + "node": ">=6" | |
| 1483 | + } | |
| 1484 | + }, | |
| 1485 | + "node_modules/loose-envify": { | |
| 1486 | + "version": "1.4.0", | |
| 1487 | + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", | |
| 1488 | + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", | |
| 1489 | + "license": "MIT", | |
| 1490 | + "dependencies": { | |
| 1491 | + "js-tokens": "^3.0.0 || ^4.0.0" | |
| 1492 | + }, | |
| 1493 | + "bin": { | |
| 1494 | + "loose-envify": "cli.js" | |
| 1495 | + } | |
| 1496 | + }, | |
| 1497 | + "node_modules/lru-cache": { | |
| 1498 | + "version": "5.1.1", | |
| 1499 | + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", | |
| 1500 | + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", | |
| 1501 | + "dev": true, | |
| 1502 | + "license": "ISC", | |
| 1503 | + "dependencies": { | |
| 1504 | + "yallist": "^3.0.2" | |
| 1505 | + } | |
| 1506 | + }, | |
| 1507 | + "node_modules/ms": { | |
| 1508 | + "version": "2.1.3", | |
| 1509 | + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | |
| 1510 | + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", | |
| 1511 | + "dev": true, | |
| 1512 | + "license": "MIT" | |
| 1513 | + }, | |
| 1514 | + "node_modules/nanoid": { | |
| 1515 | + "version": "3.3.18", | |
| 1516 | + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", | |
| 1517 | + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", | |
| 1518 | + "dev": true, | |
| 1519 | + "funding": [ | |
| 1520 | + { | |
| 1521 | + "type": "github", | |
| 1522 | + "url": "https://github.com/sponsors/ai" | |
| 1523 | + } | |
| 1524 | + ], | |
| 1525 | + "license": "MIT", | |
| 1526 | + "bin": { | |
| 1527 | + "nanoid": "bin/nanoid.cjs" | |
| 1528 | + }, | |
| 1529 | + "engines": { | |
| 1530 | + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" | |
| 1531 | + } | |
| 1532 | + }, | |
| 1533 | + "node_modules/node-releases": { | |
| 1534 | + "version": "2.0.53", | |
| 1535 | + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", | |
| 1536 | + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", | |
| 1537 | + "dev": true, | |
| 1538 | + "license": "MIT", | |
| 1539 | + "engines": { | |
| 1540 | + "node": ">=18" | |
| 1541 | + } | |
| 1542 | + }, | |
| 1543 | + "node_modules/picocolors": { | |
| 1544 | + "version": "1.1.1", | |
| 1545 | + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | |
| 1546 | + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", | |
| 1547 | + "dev": true, | |
| 1548 | + "license": "ISC" | |
| 1549 | + }, | |
| 1550 | + "node_modules/postcss": { | |
| 1551 | + "version": "8.5.26", | |
| 1552 | + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", | |
| 1553 | + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", | |
| 1554 | + "dev": true, | |
| 1555 | + "funding": [ | |
| 1556 | + { | |
| 1557 | + "type": "opencollective", | |
| 1558 | + "url": "https://opencollective.com/postcss/" | |
| 1559 | + }, | |
| 1560 | + { | |
| 1561 | + "type": "tidelift", | |
| 1562 | + "url": "https://tidelift.com/funding/github/npm/postcss" | |
| 1563 | + }, | |
| 1564 | + { | |
| 1565 | + "type": "github", | |
| 1566 | + "url": "https://github.com/sponsors/ai" | |
| 1567 | + } | |
| 1568 | + ], | |
| 1569 | + "license": "MIT", | |
| 1570 | + "dependencies": { | |
| 1571 | + "nanoid": "^3.3.17", | |
| 1572 | + "picocolors": "^1.1.1", | |
| 1573 | + "source-map-js": "^1.2.1" | |
| 1574 | + }, | |
| 1575 | + "engines": { | |
| 1576 | + "node": "^10 || ^12 || >=14" | |
| 1577 | + } | |
| 1578 | + }, | |
| 1579 | + "node_modules/react": { | |
| 1580 | + "version": "18.3.1", | |
| 1581 | + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", | |
| 1582 | + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", | |
| 1583 | + "license": "MIT", | |
| 1584 | + "dependencies": { | |
| 1585 | + "loose-envify": "^1.1.0" | |
| 1586 | + }, | |
| 1587 | + "engines": { | |
| 1588 | + "node": ">=0.10.0" | |
| 1589 | + } | |
| 1590 | + }, | |
| 1591 | + "node_modules/react-dom": { | |
| 1592 | + "version": "18.3.1", | |
| 1593 | + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", | |
| 1594 | + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", | |
| 1595 | + "license": "MIT", | |
| 1596 | + "dependencies": { | |
| 1597 | + "loose-envify": "^1.1.0", | |
| 1598 | + "scheduler": "^0.23.2" | |
| 1599 | + }, | |
| 1600 | + "peerDependencies": { | |
| 1601 | + "react": "^18.3.1" | |
| 1602 | + } | |
| 1603 | + }, | |
| 1604 | + "node_modules/react-refresh": { | |
| 1605 | + "version": "0.17.0", | |
| 1606 | + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", | |
| 1607 | + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", | |
| 1608 | + "dev": true, | |
| 1609 | + "license": "MIT", | |
| 1610 | + "engines": { | |
| 1611 | + "node": ">=0.10.0" | |
| 1612 | + } | |
| 1613 | + }, | |
| 1614 | + "node_modules/react-router": { | |
| 1615 | + "version": "6.30.4", | |
| 1616 | + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", | |
| 1617 | + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", | |
| 1618 | + "license": "MIT", | |
| 1619 | + "dependencies": { | |
| 1620 | + "@remix-run/router": "1.23.3" | |
| 1621 | + }, | |
| 1622 | + "engines": { | |
| 1623 | + "node": ">=14.0.0" | |
| 1624 | + }, | |
| 1625 | + "peerDependencies": { | |
| 1626 | + "react": ">=16.8" | |
| 1627 | + } | |
| 1628 | + }, | |
| 1629 | + "node_modules/react-router-dom": { | |
| 1630 | + "version": "6.30.4", | |
| 1631 | + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", | |
| 1632 | + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", | |
| 1633 | + "license": "MIT", | |
| 1634 | + "dependencies": { | |
| 1635 | + "@remix-run/router": "1.23.3", | |
| 1636 | + "react-router": "6.30.4" | |
| 1637 | + }, | |
| 1638 | + "engines": { | |
| 1639 | + "node": ">=14.0.0" | |
| 1640 | + }, | |
| 1641 | + "peerDependencies": { | |
| 1642 | + "react": ">=16.8", | |
| 1643 | + "react-dom": ">=16.8" | |
| 1644 | + } | |
| 1645 | + }, | |
| 1646 | + "node_modules/rollup": { | |
| 1647 | + "version": "4.62.4", | |
| 1648 | + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", | |
| 1649 | + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", | |
| 1650 | + "dev": true, | |
| 1651 | + "license": "MIT", | |
| 1652 | + "dependencies": { | |
| 1653 | + "@types/estree": "1.0.9" | |
| 1654 | + }, | |
| 1655 | + "bin": { | |
| 1656 | + "rollup": "dist/bin/rollup" | |
| 1657 | + }, | |
| 1658 | + "engines": { | |
| 1659 | + "node": ">=18.0.0", | |
| 1660 | + "npm": ">=8.0.0" | |
| 1661 | + }, | |
| 1662 | + "optionalDependencies": { | |
| 1663 | + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", | |
| 1664 | + "@rollup/rollup-android-arm-eabi": "4.62.4", | |
| 1665 | + "@rollup/rollup-android-arm64": "4.62.4", | |
| 1666 | + "@rollup/rollup-darwin-arm64": "4.62.4", | |
| 1667 | + "@rollup/rollup-darwin-x64": "4.62.4", | |
| 1668 | + "@rollup/rollup-freebsd-arm64": "4.62.4", | |
| 1669 | + "@rollup/rollup-freebsd-x64": "4.62.4", | |
| 1670 | + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", | |
| 1671 | + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", | |
| 1672 | + "@rollup/rollup-linux-arm64-gnu": "4.62.4", | |
| 1673 | + "@rollup/rollup-linux-arm64-musl": "4.62.4", | |
| 1674 | + "@rollup/rollup-linux-loong64-gnu": "4.62.4", | |
| 1675 | + "@rollup/rollup-linux-loong64-musl": "4.62.4", | |
| 1676 | + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", | |
| 1677 | + "@rollup/rollup-linux-ppc64-musl": "4.62.4", | |
| 1678 | + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", | |
| 1679 | + "@rollup/rollup-linux-riscv64-musl": "4.62.4", | |
| 1680 | + "@rollup/rollup-linux-s390x-gnu": "4.62.4", | |
| 1681 | + "@rollup/rollup-linux-x64-gnu": "4.62.4", | |
| 1682 | + "@rollup/rollup-linux-x64-musl": "4.62.4", | |
| 1683 | + "@rollup/rollup-openbsd-x64": "4.62.4", | |
| 1684 | + "@rollup/rollup-openharmony-arm64": "4.62.4", | |
| 1685 | + "@rollup/rollup-win32-arm64-msvc": "4.62.4", | |
| 1686 | + "@rollup/rollup-win32-ia32-msvc": "4.62.4", | |
| 1687 | + "@rollup/rollup-win32-x64-gnu": "4.62.4", | |
| 1688 | + "@rollup/rollup-win32-x64-msvc": "4.62.4", | |
| 1689 | + "fsevents": "~2.3.2" | |
| 1690 | + } | |
| 1691 | + }, | |
| 1692 | + "node_modules/scheduler": { | |
| 1693 | + "version": "0.23.2", | |
| 1694 | + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", | |
| 1695 | + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", | |
| 1696 | + "license": "MIT", | |
| 1697 | + "dependencies": { | |
| 1698 | + "loose-envify": "^1.1.0" | |
| 1699 | + } | |
| 1700 | + }, | |
| 1701 | + "node_modules/semver": { | |
| 1702 | + "version": "6.3.1", | |
| 1703 | + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", | |
| 1704 | + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", | |
| 1705 | + "dev": true, | |
| 1706 | + "license": "ISC", | |
| 1707 | + "bin": { | |
| 1708 | + "semver": "bin/semver.js" | |
| 1709 | + } | |
| 1710 | + }, | |
| 1711 | + "node_modules/source-map-js": { | |
| 1712 | + "version": "1.2.1", | |
| 1713 | + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", | |
| 1714 | + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", | |
| 1715 | + "dev": true, | |
| 1716 | + "license": "BSD-3-Clause", | |
| 1717 | + "engines": { | |
| 1718 | + "node": ">=0.10.0" | |
| 1719 | + } | |
| 1720 | + }, | |
| 1721 | + "node_modules/typescript": { | |
| 1722 | + "version": "5.9.3", | |
| 1723 | + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", | |
| 1724 | + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", | |
| 1725 | + "dev": true, | |
| 1726 | + "license": "Apache-2.0", | |
| 1727 | + "bin": { | |
| 1728 | + "tsc": "bin/tsc", | |
| 1729 | + "tsserver": "bin/tsserver" | |
| 1730 | + }, | |
| 1731 | + "engines": { | |
| 1732 | + "node": ">=14.17" | |
| 1733 | + } | |
| 1734 | + }, | |
| 1735 | + "node_modules/update-browserslist-db": { | |
| 1736 | + "version": "1.3.1", | |
| 1737 | + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", | |
| 1738 | + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", | |
| 1739 | + "dev": true, | |
| 1740 | + "funding": [ | |
| 1741 | + { | |
| 1742 | + "type": "opencollective", | |
| 1743 | + "url": "https://opencollective.com/browserslist" | |
| 1744 | + }, | |
| 1745 | + { | |
| 1746 | + "type": "tidelift", | |
| 1747 | + "url": "https://tidelift.com/funding/github/npm/browserslist" | |
| 1748 | + }, | |
| 1749 | + { | |
| 1750 | + "type": "github", | |
| 1751 | + "url": "https://github.com/sponsors/ai" | |
| 1752 | + } | |
| 1753 | + ], | |
| 1754 | + "license": "MIT", | |
| 1755 | + "dependencies": { | |
| 1756 | + "escalade": "^3.2.0", | |
| 1757 | + "picocolors": "^1.1.1" | |
| 1758 | + }, | |
| 1759 | + "bin": { | |
| 1760 | + "update-browserslist-db": "cli.js" | |
| 1761 | + }, | |
| 1762 | + "peerDependencies": { | |
| 1763 | + "browserslist": ">= 4.21.0" | |
| 1764 | + } | |
| 1765 | + }, | |
| 1766 | + "node_modules/vite": { | |
| 1767 | + "version": "5.4.21", | |
| 1768 | + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", | |
| 1769 | + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", | |
| 1770 | + "dev": true, | |
| 1771 | + "license": "MIT", | |
| 1772 | + "dependencies": { | |
| 1773 | + "esbuild": "^0.21.3", | |
| 1774 | + "postcss": "^8.4.43", | |
| 1775 | + "rollup": "^4.20.0" | |
| 1776 | + }, | |
| 1777 | + "bin": { | |
| 1778 | + "vite": "bin/vite.js" | |
| 1779 | + }, | |
| 1780 | + "engines": { | |
| 1781 | + "node": "^18.0.0 || >=20.0.0" | |
| 1782 | + }, | |
| 1783 | + "funding": { | |
| 1784 | + "url": "https://github.com/vitejs/vite?sponsor=1" | |
| 1785 | + }, | |
| 1786 | + "optionalDependencies": { | |
| 1787 | + "fsevents": "~2.3.3" | |
| 1788 | + }, | |
| 1789 | + "peerDependencies": { | |
| 1790 | + "@types/node": "^18.0.0 || >=20.0.0", | |
| 1791 | + "less": "*", | |
| 1792 | + "lightningcss": "^1.21.0", | |
| 1793 | + "sass": "*", | |
| 1794 | + "sass-embedded": "*", | |
| 1795 | + "stylus": "*", | |
| 1796 | + "sugarss": "*", | |
| 1797 | + "terser": "^5.4.0" | |
| 1798 | + }, | |
| 1799 | + "peerDependenciesMeta": { | |
| 1800 | + "@types/node": { | |
| 1801 | + "optional": true | |
| 1802 | + }, | |
| 1803 | + "less": { | |
| 1804 | + "optional": true | |
| 1805 | + }, | |
| 1806 | + "lightningcss": { | |
| 1807 | + "optional": true | |
| 1808 | + }, | |
| 1809 | + "sass": { | |
| 1810 | + "optional": true | |
| 1811 | + }, | |
| 1812 | + "sass-embedded": { | |
| 1813 | + "optional": true | |
| 1814 | + }, | |
| 1815 | + "stylus": { | |
| 1816 | + "optional": true | |
| 1817 | + }, | |
| 1818 | + "sugarss": { | |
| 1819 | + "optional": true | |
| 1820 | + }, | |
| 1821 | + "terser": { | |
| 1822 | + "optional": true | |
| 1823 | + } | |
| 1824 | + } | |
| 1825 | + }, | |
| 1826 | + "node_modules/yallist": { | |
| 1827 | + "version": "3.1.1", | |
| 1828 | + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", | |
| 1829 | + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", | |
| 1830 | + "dev": true, | |
| 1831 | + "license": "ISC" | |
| 1832 | + } | |
| 1833 | + } | |
| 1834 | +} | |
added
frontend/package.json
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +{ | |
| 2 | + "name": "forma-ka-frontend", | |
| 3 | + "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 4 | + "private": true, | |
| 5 | + "version": "1.0.0", | |
| 6 | + "type": "module", | |
| 7 | + "scripts": { | |
| 8 | + "dev": "vite", | |
| 9 | + "build": "tsc -b && vite build", | |
| 10 | + "preview": "vite preview" | |
| 11 | + }, | |
| 12 | + "dependencies": { | |
| 13 | + "react": "^18.3.1", | |
| 14 | + "react-dom": "^18.3.1", | |
| 15 | + "react-router-dom": "^6.26.0" | |
| 16 | + }, | |
| 17 | + "devDependencies": { | |
| 18 | + "@types/react": "^18.3.3", | |
| 19 | + "@types/react-dom": "^18.3.0", | |
| 20 | + "@vitejs/plugin-react": "^4.3.1", | |
| 21 | + "typescript": "^5.5.4", | |
| 22 | + "vite": "^5.4.0" | |
| 23 | + } | |
| 24 | +} | |
added
frontend/public/manifest.webmanifest
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +{ | |
| 2 | + "name": "Forma-Ka — Toutes les formations du Québec", | |
| 3 | + "short_name": "Forma-Ka", | |
| 4 | + "description": "Cours en ligne, cours universitaires, séminaires, ateliers et certifications du Québec, un seul endroit.", | |
| 5 | + "start_url": "/", | |
| 6 | + "display": "standalone", | |
| 7 | + "background_color": "#f5f3ee", | |
| 8 | + "theme_color": "#f5f3ee", | |
| 9 | + "lang": "fr", | |
| 10 | + "icons": [ | |
| 11 | + { | |
| 12 | + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Crect width='512' height='512' rx='96' fill='%23141814'/%3E%3Ctext x='256' y='356' font-family='Arial Black,sans-serif' font-size='256' font-weight='900' fill='%23ffd54d' text-anchor='middle'%3EFK%3C/text%3E%3C/svg%3E", | |
| 13 | + "sizes": "512x512", | |
| 14 | + "type": "image/svg+xml", | |
| 15 | + "purpose": "any" | |
| 16 | + } | |
| 17 | + ] | |
| 18 | +} | |
added
frontend/src/App.tsx
+184 −0
@@ -0,0 +1,184 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// App.tsx : layout global (header + ticker en direct + footer encre) et routage | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import { useEffect, useState } from "react"; | |
| 7 | +import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | |
| 8 | +import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; | |
| 9 | +import CookieConsent from "./components/CookieConsent"; | |
| 10 | +import Home from "./pages/Home"; | |
| 11 | +import FormationPage from "./pages/Formation"; | |
| 12 | +import PrivacyPage from "./pages/Privacy"; | |
| 13 | +import SourcesPage from "./pages/Sources"; | |
| 14 | +import StatsPage from "./pages/Stats"; | |
| 15 | + | |
| 16 | +function Ticker() { | |
| 17 | + const [items, setItems] = useState<string[]>([]); | |
| 18 | + | |
| 19 | + useEffect(() => { | |
| 20 | + Promise.all([fetchStats(), fetchFacets(), fetchSources()]) | |
| 21 | + .then(([stats, facets, src]) => { | |
| 22 | + registerSourceNames(src.sources); | |
| 23 | + const parts: string[] = [`${stats.total} formations actives`]; | |
| 24 | + if ((stats.gratuites ?? 0) > 0) parts.push(`${stats.gratuites} gratuites`); | |
| 25 | + if ((stats.en_ligne ?? 0) > 0) parts.push(`${stats.en_ligne} en ligne`); | |
| 26 | + for (const t of (stats.par_type ?? []).slice(0, 5)) | |
| 27 | + parts.push(`${t.t} · ${t.n}`); | |
| 28 | + for (const s of facets.sources.slice(0, 8)) | |
| 29 | + parts.push(`${sourceName(s.source)} · ${s.n}`); | |
| 30 | + parts.push("Mise à jour automatique"); | |
| 31 | + setItems(parts); | |
| 32 | + }) | |
| 33 | + .catch(() => setItems(["Forma-Ka — toutes les formations du Québec"])); | |
| 34 | + }, []); | |
| 35 | + | |
| 36 | + if (items.length === 0) return null; | |
| 37 | + // contenu doublé pour une boucle de défilement continue | |
| 38 | + return ( | |
| 39 | + <div className="ticker" aria-hidden="true"> | |
| 40 | + <div className="ticker-track"> | |
| 41 | + {[...items, ...items].map((t, i) => ( | |
| 42 | + <span key={i}>{t}</span> | |
| 43 | + ))} | |
| 44 | + </div> | |
| 45 | + </div> | |
| 46 | + ); | |
| 47 | +} | |
| 48 | + | |
| 49 | +const NAV_LINKS = [ | |
| 50 | + { to: "/", label: "Formations", icon: "🎓", end: true }, | |
| 51 | + { to: "/stats", label: "Stats", icon: "📊", end: false }, | |
| 52 | + { to: "/sources", label: "Sources", icon: "🗂", end: false }, | |
| 53 | + { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false }, | |
| 54 | +]; | |
| 55 | + | |
| 56 | +function Header() { | |
| 57 | + const [open, setOpen] = useState(false); | |
| 58 | + const location = useLocation(); | |
| 59 | + | |
| 60 | + // fermer le menu à chaque navigation + verrouiller le défilement en dessous | |
| 61 | + useEffect(() => { setOpen(false); }, [location]); | |
| 62 | + useEffect(() => { | |
| 63 | + document.body.style.overflow = open ? "hidden" : ""; | |
| 64 | + return () => { document.body.style.overflow = ""; }; | |
| 65 | + }, [open]); | |
| 66 | + | |
| 67 | + return ( | |
| 68 | + <> | |
| 69 | + <header className="header"> | |
| 70 | + <div className="container header-inner"> | |
| 71 | + <NavLink to="/" className="brand" aria-label="Forma-Ka — accueil"> | |
| 72 | + Forma<span className="ka">Ka</span> | |
| 73 | + <span className="brand-tag">Cours · séminaires · ateliers — tout le Québec</span> | |
| 74 | + </NavLink> | |
| 75 | + <nav className="nav" aria-label="Navigation principale"> | |
| 76 | + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}> | |
| 77 | + Formations | |
| 78 | + </NavLink> | |
| 79 | + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 80 | + Stats | |
| 81 | + </NavLink> | |
| 82 | + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 83 | + Sources | |
| 84 | + </NavLink> | |
| 85 | + </nav> | |
| 86 | + <button | |
| 87 | + className={`menu-btn ${open ? "open" : ""}`} | |
| 88 | + aria-expanded={open} | |
| 89 | + aria-label={open ? "Fermer le menu" : "Ouvrir le menu"} | |
| 90 | + onClick={() => setOpen(!open)} | |
| 91 | + > | |
| 92 | + <span /><span /><span /> | |
| 93 | + </button> | |
| 94 | + </div> | |
| 95 | + | |
| 96 | + {/* menu déroulant mobile */} | |
| 97 | + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile"> | |
| 98 | + {NAV_LINKS.map((l, i) => ( | |
| 99 | + <NavLink | |
| 100 | + key={l.to} | |
| 101 | + to={l.to} | |
| 102 | + end={l.end} | |
| 103 | + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }} | |
| 104 | + className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`} | |
| 105 | + onClick={() => setOpen(false)} | |
| 106 | + > | |
| 107 | + <span className="mm-ico" aria-hidden="true">{l.icon}</span> | |
| 108 | + {l.label} | |
| 109 | + <span className="mm-arrow" aria-hidden="true">→</span> | |
| 110 | + </NavLink> | |
| 111 | + ))} | |
| 112 | + <div className="mm-foot"> | |
| 113 | + Agrégateur indépendant — mis à jour automatiquement, chaque fiche | |
| 114 | + renvoie à la formation originale. | |
| 115 | + </div> | |
| 116 | + </div> | |
| 117 | + </header> | |
| 118 | + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />} | |
| 119 | + <Ticker /> | |
| 120 | + </> | |
| 121 | + ); | |
| 122 | +} | |
| 123 | + | |
| 124 | +function Footer() { | |
| 125 | + return ( | |
| 126 | + <footer className="footer"> | |
| 127 | + <div className="container"> | |
| 128 | + <div className="fbrand"> | |
| 129 | + Forma<span className="ka">Ka</span> | |
| 130 | + </div> | |
| 131 | + <div className="frow"> | |
| 132 | + <div> | |
| 133 | + <b>Agrégateur indépendant</b> de formations dans la province de Québec — | |
| 134 | + cours en ligne, cours universitaires et collégiaux, séminaires, ateliers, | |
| 135 | + certifications. Les fiches proviennent des sites publics des établissements | |
| 136 | + et sont rafraîchies automatiquement — chaque fiche renvoie vers la page | |
| 137 | + originale de la formation. | |
| 138 | + </div> | |
| 139 | + </div> | |
| 140 | + <div className="fmono"> | |
| 141 | + © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai | |
| 142 | + {" · "} | |
| 143 | + <NavLink to="/confidentialite">Confidentialité</NavLink> | |
| 144 | + {" · "} | |
| 145 | + <button | |
| 146 | + className="flink" | |
| 147 | + onClick={() => window.dispatchEvent(new Event("formaka:openConsent"))} | |
| 148 | + > | |
| 149 | + Gérer mes témoins | |
| 150 | + </button> | |
| 151 | + </div> | |
| 152 | + </div> | |
| 153 | + </footer> | |
| 154 | + ); | |
| 155 | +} | |
| 156 | + | |
| 157 | +export default function App() { | |
| 158 | + return ( | |
| 159 | + <> | |
| 160 | + <Header /> | |
| 161 | + <main> | |
| 162 | + <Routes> | |
| 163 | + <Route path="/" element={<Home />} /> | |
| 164 | + <Route path="/formation/:uid" element={<FormationPage />} /> | |
| 165 | + <Route path="/stats" element={<StatsPage />} /> | |
| 166 | + <Route path="/sources" element={<SourcesPage />} /> | |
| 167 | + <Route path="/confidentialite" element={<PrivacyPage />} /> | |
| 168 | + <Route | |
| 169 | + path="*" | |
| 170 | + element={ | |
| 171 | + <div className="notice container"> | |
| 172 | + <div className="big">🧭</div> | |
| 173 | + <h2>Page introuvable</h2> | |
| 174 | + <p>Le lien demandé n'existe pas.</p> | |
| 175 | + </div> | |
| 176 | + } | |
| 177 | + /> | |
| 178 | + </Routes> | |
| 179 | + </main> | |
| 180 | + <Footer /> | |
| 181 | + <CookieConsent /> | |
| 182 | + </> | |
| 183 | + ); | |
| 184 | +} | |
added
frontend/src/api.ts
+178 −0
@@ -0,0 +1,178 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// api.ts : types + client API robuste (timeout, erreurs typées) | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | + | |
| 7 | +export interface FormationDetails { | |
| 8 | + uec?: number; | |
| 9 | + credits?: number; | |
| 10 | + level?: string; | |
| 11 | + duration_hours?: number; | |
| 12 | + price_from?: boolean; | |
| 13 | + modes_offerts?: string[]; | |
| 14 | + [k: string]: unknown; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export interface Formation { | |
| 18 | + uid: string; | |
| 19 | + source: string; | |
| 20 | + external_id: string; | |
| 21 | + url: string; | |
| 22 | + title: string; | |
| 23 | + training_type: string; // Cours universitaire, Séminaire, Atelier… | |
| 24 | + category: string; // domaine : Informatique, Gestion, RH… | |
| 25 | + mode: string; // en ligne | présentiel | hybride | asynchrone | |
| 26 | + city: string; | |
| 27 | + language: string; // fr | en | fr/en | |
| 28 | + price: number | null; // null = non affiché (normal pour l'universitaire) | |
| 29 | + price_label: string; | |
| 30 | + is_free: boolean | null; | |
| 31 | + duration: string; | |
| 32 | + duration_hours: number | null; | |
| 33 | + start_date: string | null; // ISO | |
| 34 | + schedule_label: string; | |
| 35 | + sessions: string[]; // toutes les dates offertes (ISO) | |
| 36 | + level: string; | |
| 37 | + credits: string; // « 3 crédits », « 1,4 UEC » | |
| 38 | + credential: string; | |
| 39 | + instructor: string; | |
| 40 | + code: string; // sigle (ex. « GSF-1020 ») | |
| 41 | + description: string; | |
| 42 | + objectives: string[]; | |
| 43 | + prerequisites: string; | |
| 44 | + audience: string; | |
| 45 | + program: string[]; // plan / contenu | |
| 46 | + tags: string[]; | |
| 47 | + details: FormationDetails; | |
| 48 | + images: string[]; | |
| 49 | + price_history?: { ts: number; price: number | null }[]; | |
| 50 | + similar?: { | |
| 51 | + uid: string; title: string; training_type: string; source: string; | |
| 52 | + price: number | null; mode: string; duration: string; | |
| 53 | + }[]; | |
| 54 | + first_seen?: number; | |
| 55 | + last_seen: number; | |
| 56 | + updated_at: number; | |
| 57 | + active: number; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export interface Facets { | |
| 61 | + types: { t: string; n: number }[]; | |
| 62 | + categories: { category: string; n: number }[]; | |
| 63 | + modes: string[]; | |
| 64 | + cities: string[]; | |
| 65 | + languages: string[]; | |
| 66 | + levels: string[]; | |
| 67 | + sources: { source: string; n: number }[]; | |
| 68 | +} | |
| 69 | + | |
| 70 | +export interface Source { | |
| 71 | + id: string; | |
| 72 | + name: string; | |
| 73 | + url: string; | |
| 74 | + listing_url: string; | |
| 75 | + type_offre: string; | |
| 76 | + connector: string | null; | |
| 77 | + status: string; | |
| 78 | + region: string; | |
| 79 | + active_formations: number; | |
| 80 | + last_sync: number | null; | |
| 81 | +} | |
| 82 | + | |
| 83 | +export interface Stats { | |
| 84 | + total: number; | |
| 85 | + gratuites: number; | |
| 86 | + en_ligne: number; | |
| 87 | + universitaires: number; | |
| 88 | + sources: number; | |
| 89 | + avg_price: number | null; | |
| 90 | + avg_hours: number | null; | |
| 91 | + par_type: { t: string; n: number }[]; | |
| 92 | + recent_syncs: { | |
| 93 | + source: string; ts: number; found: number; added: number; | |
| 94 | + updated: number; removed: number; ok: number; message: string; | |
| 95 | + }[]; | |
| 96 | +} | |
| 97 | + | |
| 98 | +const SOURCE_NAMES: Record<string, string> = {}; | |
| 99 | + | |
| 100 | +export function registerSourceNames(sources: Source[]) { | |
| 101 | + for (const s of sources) SOURCE_NAMES[s.id] = s.name; | |
| 102 | +} | |
| 103 | +export function sourceName(id: string): string { | |
| 104 | + return SOURCE_NAMES[id] ?? id; | |
| 105 | +} | |
| 106 | + | |
| 107 | +async function get<T>(path: string): Promise<T> { | |
| 108 | + const ctrl = new AbortController(); | |
| 109 | + const timer = setTimeout(() => ctrl.abort(), 20000); | |
| 110 | + try { | |
| 111 | + const res = await fetch(path, { signal: ctrl.signal }); | |
| 112 | + if (!res.ok) throw new Error(`API ${res.status} — ${path}`); | |
| 113 | + return (await res.json()) as T; | |
| 114 | + } finally { | |
| 115 | + clearTimeout(timer); | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +export interface FormationFilters { | |
| 120 | + training_type?: string; | |
| 121 | + category?: string; | |
| 122 | + mode?: string; | |
| 123 | + city?: string; | |
| 124 | + language?: string; | |
| 125 | + level?: string; | |
| 126 | + source?: string; | |
| 127 | + free?: string; // "1" = gratuites | |
| 128 | + price_max?: string; | |
| 129 | + credential?: string; | |
| 130 | + starts_after?: string; // ISO | |
| 131 | + q?: string; | |
| 132 | + sort?: string; // recent | price | title | start | |
| 133 | + limit?: string; // taille de page | |
| 134 | + offset?: string; // décalage (pagination) | |
| 135 | +} | |
| 136 | + | |
| 137 | +export function fetchFormations(f: FormationFilters) { | |
| 138 | + const params = new URLSearchParams(); | |
| 139 | + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); | |
| 140 | + return get<{ total: number; formations: Formation[] }>(`/api/formations?${params}`); | |
| 141 | +} | |
| 142 | + | |
| 143 | +export const fetchFormation = (uid: string) => | |
| 144 | + get<Formation>(`/api/formations/${encodeURIComponent(uid)}`); | |
| 145 | +export const fetchFacets = (trainingType?: string) => | |
| 146 | + get<Facets>(`/api/facets${trainingType ? `?training_type=${encodeURIComponent(trainingType)}` : ""}`); | |
| 147 | +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); | |
| 148 | +export const fetchStats = () => get<Stats>("/api/stats"); | |
| 149 | + | |
| 150 | +/** null -> « Prix non affiché » (fréquent : cours universitaires) */ | |
| 151 | +export const fmtPrice = (p: number | null, label?: string) => { | |
| 152 | + if (p === 0) return "Gratuit"; | |
| 153 | + if (p != null) | |
| 154 | + return p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"; | |
| 155 | + return label || "Prix non affiché"; | |
| 156 | +}; | |
| 157 | + | |
| 158 | +/** "2026-12-01" -> « 1ᵉʳ décembre 2026 » */ | |
| 159 | +export function fmtDate(iso: string | null): string | null { | |
| 160 | + if (!iso) return null; | |
| 161 | + const [y, m, d] = iso.split("-").map(Number); | |
| 162 | + if (!y || !m || !d) return null; | |
| 163 | + const txt = new Date(y, m - 1, d).toLocaleDateString("fr-CA", { | |
| 164 | + day: "numeric", month: "long", year: "numeric", | |
| 165 | + }); | |
| 166 | + return txt.replace(/^1 /, "1ᵉʳ "); | |
| 167 | +} | |
| 168 | + | |
| 169 | +/** 14 -> « 14 h », 3.5 -> « 3,5 h » */ | |
| 170 | +export const fmtHours = (h: number | null): string | null => | |
| 171 | + h == null ? null : `${h.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} h`; | |
| 172 | + | |
| 173 | +export const MODE_ICONS: Record<string, string> = { | |
| 174 | + "en ligne": "💻", | |
| 175 | + "présentiel": "🏛", | |
| 176 | + "hybride": "🔀", | |
| 177 | + "asynchrone": "🕓", | |
| 178 | +}; | |
added
frontend/src/components/CookieConsent.tsx
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/CookieConsent.tsx : bandeau de consentement (témoins/stockage) | |
| 5 | +// Honnête par design : Forma-Ka n'utilise AUCUN témoin tiers ni traceur. | |
| 6 | +// Le sélecteur couvre le stockage local (préférences) et une éventuelle | |
| 7 | +// mesure d'audience anonyme future. Choix mémorisé en localStorage, | |
| 8 | +// ré-ouvrable via l'événement « formaka:openConsent » (lien du pied de page). | |
| 9 | +// ----------------------------------------------------------------------------- | |
| 10 | +import { useEffect, useState } from "react"; | |
| 11 | +import { Link } from "react-router-dom"; | |
| 12 | + | |
| 13 | +const KEY = "formaka-consent-v1"; | |
| 14 | + | |
| 15 | +export interface Consent { | |
| 16 | + essential: true; // toujours actif (fonctionnement du site) | |
| 17 | + preferences: boolean; // mémoriser filtres et vue liste/carte | |
| 18 | + statistics: boolean; // mesure d'audience anonyme (si activée un jour) | |
| 19 | + ts: number; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function getConsent(): Consent | null { | |
| 23 | + try { | |
| 24 | + const raw = localStorage.getItem(KEY); | |
| 25 | + return raw ? (JSON.parse(raw) as Consent) : null; | |
| 26 | + } catch { | |
| 27 | + return null; | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +function save(preferences: boolean, statistics: boolean): Consent { | |
| 32 | + const c: Consent = { essential: true, preferences, statistics, ts: Date.now() }; | |
| 33 | + try { localStorage.setItem(KEY, JSON.stringify(c)); } catch { /* stockage bloqué */ } | |
| 34 | + return c; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export default function CookieConsent() { | |
| 38 | + const [visible, setVisible] = useState(false); | |
| 39 | + const [custom, setCustom] = useState(false); | |
| 40 | + const [prefs, setPrefs] = useState(true); | |
| 41 | + const [stats, setStats] = useState(false); | |
| 42 | + | |
| 43 | + useEffect(() => { | |
| 44 | + if (getConsent() === null) setVisible(true); | |
| 45 | + const open = () => { setVisible(true); setCustom(true); }; | |
| 46 | + window.addEventListener("formaka:openConsent", open); | |
| 47 | + return () => window.removeEventListener("formaka:openConsent", open); | |
| 48 | + }, []); | |
| 49 | + | |
| 50 | + if (!visible) return null; | |
| 51 | + | |
| 52 | + const close = (p: boolean, s: boolean) => { save(p, s); setVisible(false); setCustom(false); }; | |
| 53 | + | |
| 54 | + return ( | |
| 55 | + <div className="cookie-banner" role="dialog" aria-modal="false" aria-label="Gestion des témoins"> | |
| 56 | + <div className="cookie-inner"> | |
| 57 | + <div className="cookie-text"> | |
| 58 | + <b>🍪 Vos témoins, vos choix.</b>{" "} | |
| 59 | + Forma-Ka n'utilise aucun traceur publicitaire ni témoin tiers. Le site | |
| 60 | + mémorise localement vos préférences (filtres, vue carte) et votre choix | |
| 61 | + de consentement. <Link to="/confidentialite">Politique de confidentialité</Link> | |
| 62 | + </div> | |
| 63 | + | |
| 64 | + {custom && ( | |
| 65 | + <div className="cookie-options"> | |
| 66 | + <label className="cookie-opt"> | |
| 67 | + <input type="checkbox" checked disabled /> | |
| 68 | + <span><b>Essentiels</b> — fonctionnement du site (toujours actifs)</span> | |
| 69 | + </label> | |
| 70 | + <label className="cookie-opt"> | |
| 71 | + <input type="checkbox" checked={prefs} onChange={(e) => setPrefs(e.target.checked)} /> | |
| 72 | + <span><b>Préférences</b> — retenir vos filtres et votre vue liste/carte</span> | |
| 73 | + </label> | |
| 74 | + <label className="cookie-opt"> | |
| 75 | + <input type="checkbox" checked={stats} onChange={(e) => setStats(e.target.checked)} /> | |
| 76 | + <span><b>Statistiques</b> — mesure d'audience anonyme, sans profilage</span> | |
| 77 | + </label> | |
| 78 | + </div> | |
| 79 | + )} | |
| 80 | + | |
| 81 | + <div className="cookie-actions"> | |
| 82 | + {!custom && ( | |
| 83 | + <button className="btn btn-ghost" onClick={() => setCustom(true)}> | |
| 84 | + Personnaliser | |
| 85 | + </button> | |
| 86 | + )} | |
| 87 | + <button className="btn btn-ghost" onClick={() => close(false, false)}> | |
| 88 | + Essentiels seulement | |
| 89 | + </button> | |
| 90 | + {custom ? ( | |
| 91 | + <button className="btn btn-primary" onClick={() => close(prefs, stats)}> | |
| 92 | + Enregistrer mes choix | |
| 93 | + </button> | |
| 94 | + ) : ( | |
| 95 | + <button className="btn btn-primary" onClick={() => close(true, true)}> | |
| 96 | + Tout accepter | |
| 97 | + </button> | |
| 98 | + )} | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + </div> | |
| 102 | + ); | |
| 103 | +} | |
added
frontend/src/components/FormationCard.tsx
+74 −0
@@ -0,0 +1,74 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/FormationCard.tsx : carte de formation (grille de résultats) | |
| 5 | +// Pas d'image imposée (rare dans le domaine) : monogramme de la source + | |
| 6 | +// type de formation en badge, détails clés (mode, durée, ville, date). | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | +import { Link } from "react-router-dom"; | |
| 9 | +import { Formation, MODE_ICONS, fmtDate, fmtHours, fmtPrice, sourceName } from "../api"; | |
| 10 | + | |
| 11 | +/** Couleur stable dérivée du domaine (pour le bandeau du monogramme). */ | |
| 12 | +const HUES = [14, 36, 88, 152, 196, 226, 262, 318]; | |
| 13 | +function hue(s: string): number { | |
| 14 | + let h = 0; | |
| 15 | + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; | |
| 16 | + return HUES[Math.abs(h) % HUES.length]; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export default function FormationCard({ f }: { f: Formation }) { | |
| 20 | + const img = f.images && f.images.length > 0 ? f.images[0] : null; | |
| 21 | + const start = fmtDate(f.start_date); | |
| 22 | + const dur = f.duration || fmtHours(f.duration_hours); | |
| 23 | + const h = hue(f.category || f.source); | |
| 24 | + return ( | |
| 25 | + <Link to={`/formation/${encodeURIComponent(f.uid)}`} className="card"> | |
| 26 | + <div className="card-img" style={{ aspectRatio: "5 / 2" }}> | |
| 27 | + {img ? ( | |
| 28 | + <img src={img} alt={f.title} loading="lazy" /> | |
| 29 | + ) : ( | |
| 30 | + <div | |
| 31 | + className="noimg" | |
| 32 | + style={{ | |
| 33 | + background: `linear-gradient(135deg, hsl(${h} 42% 90%), hsl(${h} 38% 78%))`, | |
| 34 | + color: `hsl(${h} 45% 26%)`, | |
| 35 | + fontFamily: "var(--font-display)", | |
| 36 | + fontSize: "1rem", fontWeight: 700, padding: "0 16px", | |
| 37 | + textAlign: "center", lineHeight: 1.25, | |
| 38 | + }} | |
| 39 | + > | |
| 40 | + {f.category || sourceName(f.source)} | |
| 41 | + </div> | |
| 42 | + )} | |
| 43 | + {f.training_type && <span className="badge type">{f.training_type}</span>} | |
| 44 | + {f.is_free && <span className="badge right">Gratuit</span>} | |
| 45 | + </div> | |
| 46 | + <div className="card-body"> | |
| 47 | + <div className="card-price"> | |
| 48 | + {f.price != null && f.price > 0 ? ( | |
| 49 | + <>{fmtPrice(f.price, f.price_label)}</> | |
| 50 | + ) : f.is_free ? ( | |
| 51 | + <>Gratuit</> | |
| 52 | + ) : ( | |
| 53 | + <span style={{ color: "var(--ink-3)", fontSize: ".85em" }}> | |
| 54 | + {f.credits || f.price_label || "Prix non affiché"} | |
| 55 | + </span> | |
| 56 | + )} | |
| 57 | + {f.code && <small style={{ marginLeft: 8 }}>{f.code}</small>} | |
| 58 | + </div> | |
| 59 | + <div className="card-title">{f.title}</div> | |
| 60 | + <div className="card-meta"> | |
| 61 | + {f.mode && <span>{MODE_ICONS[f.mode] ?? ""} {f.mode}</span>} | |
| 62 | + {f.mode && dur && <span className="sep" />} | |
| 63 | + {dur && <span>{dur}</span>} | |
| 64 | + {(f.mode || dur) && f.city && <span className="sep" />} | |
| 65 | + {f.city && <span>{f.city}</span>} | |
| 66 | + </div> | |
| 67 | + <div className="card-foot"> | |
| 68 | + <span className="source-tag">{sourceName(f.source)}</span> | |
| 69 | + {start && <span className="avail">Débute le {start}</span>} | |
| 70 | + </div> | |
| 71 | + </div> | |
| 72 | + </Link> | |
| 73 | + ); | |
| 74 | +} | |
added
frontend/src/main.tsx
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// main.tsx : point d'entrée React | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import React from "react"; | |
| 7 | +import ReactDOM from "react-dom/client"; | |
| 8 | +import { BrowserRouter } from "react-router-dom"; | |
| 9 | +import App from "./App"; | |
| 10 | +import "./styles.css"; | |
| 11 | + | |
| 12 | +ReactDOM.createRoot(document.getElementById("root")!).render( | |
| 13 | + <React.StrictMode> | |
| 14 | + <BrowserRouter> | |
| 15 | + <App /> | |
| 16 | + </BrowserRouter> | |
| 17 | + </React.StrictMode> | |
| 18 | +); | |
added
frontend/src/pages/Formation.tsx
+267 −0
@@ -0,0 +1,267 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Formation.tsx : fiche d'une formation — l'accent est mis sur les | |
| 5 | +// DÉTAILS (le prix est souvent absent, p. ex. cours universitaires) : | |
| 6 | +// description → objectifs → plan de cours → préalables → clientèle → | |
| 7 | +// infos pratiques. Colonne droite : synthèse, dates offertes, CTA source, | |
| 8 | +// formations similaires. CTA sticky en bas d'écran (mobile). | |
| 9 | +// ----------------------------------------------------------------------------- | |
| 10 | +import { useEffect, useState } from "react"; | |
| 11 | +import { Link, useParams } from "react-router-dom"; | |
| 12 | +import { | |
| 13 | + Formation, MODE_ICONS, fetchFormation, fetchSources, fmtDate, fmtHours, | |
| 14 | + fmtPrice, registerSourceNames, sourceName, | |
| 15 | +} from "../api"; | |
| 16 | + | |
| 17 | +const NBSP = " "; | |
| 18 | + | |
| 19 | +export default function FormationPage() { | |
| 20 | + const { uid } = useParams<{ uid: string }>(); | |
| 21 | + const [f, setF] = useState<Formation | null>(null); | |
| 22 | + const [error, setError] = useState<string | null>(null); | |
| 23 | + // re-rendu quand le registre des noms de sources est chargé | |
| 24 | + const [, setSourcesReady] = useState(false); | |
| 25 | + | |
| 26 | + useEffect(() => { | |
| 27 | + fetchSources() | |
| 28 | + .then((r) => { registerSourceNames(r.sources); setSourcesReady(true); }) | |
| 29 | + .catch(() => {}); | |
| 30 | + if (!uid) return; | |
| 31 | + fetchFormation(uid).then(setF).catch((e) => setError(String(e))); | |
| 32 | + window.scrollTo(0, 0); | |
| 33 | + }, [uid]); | |
| 34 | + | |
| 35 | + if (error) | |
| 36 | + return ( | |
| 37 | + <div className="notice container"> | |
| 38 | + <div className="big">⚠️</div> | |
| 39 | + <h2>Formation introuvable</h2> | |
| 40 | + <p>{error}</p> | |
| 41 | + <Link className="btn btn-primary" to="/">Retour aux formations</Link> | |
| 42 | + </div> | |
| 43 | + ); | |
| 44 | + | |
| 45 | + if (!f) | |
| 46 | + return ( | |
| 47 | + <div className="container detail"> | |
| 48 | + <div className="fiche" aria-busy="true"> | |
| 49 | + <div className="skel"><div className="sk-img" /></div> | |
| 50 | + <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div> | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + ); | |
| 54 | + | |
| 55 | + const updated = f.updated_at | |
| 56 | + ? new Date(f.updated_at * 1000).toLocaleDateString("fr-CA", { | |
| 57 | + day: "numeric", month: "long", year: "numeric" }) : null; | |
| 58 | + | |
| 59 | + // chips clés — tout ce qui décrit la formation d'un coup d'œil | |
| 60 | + const chips: string[] = []; | |
| 61 | + if (f.training_type) chips.push(f.training_type); | |
| 62 | + if (f.mode) chips.push(`${MODE_ICONS[f.mode] ?? ""} ${f.mode}`.trim()); | |
| 63 | + const dur = f.duration || fmtHours(f.duration_hours); | |
| 64 | + if (dur) chips.push(dur); | |
| 65 | + if (f.credits) chips.push(f.credits); | |
| 66 | + if (f.level) chips.push(`Niveau ${f.level}`); | |
| 67 | + if (f.language) chips.push(f.language === "fr" ? "Français" : f.language === "en" ? "Anglais" : f.language); | |
| 68 | + if (f.city) chips.push(f.city); | |
| 69 | + if (f.code) chips.push(f.code); | |
| 70 | + | |
| 71 | + const sessions = (f.sessions ?? []).filter(Boolean); | |
| 72 | + const img = f.images && f.images.length > 0 ? f.images[0] : null; | |
| 73 | + | |
| 74 | + return ( | |
| 75 | + <div className="container detail"> | |
| 76 | + <nav className="crumbs" aria-label="Fil d'Ariane"> | |
| 77 | + <Link to="/">Formations</Link> › | |
| 78 | + {f.category && <span>{f.category}</span>} › | |
| 79 | + <span>{f.title}</span> | |
| 80 | + </nav> | |
| 81 | + | |
| 82 | + <div className="fiche"> | |
| 83 | + {/* ------- colonne gauche (desktop) : les DÉTAILS de la formation ---- */} | |
| 84 | + <div className="f-col"> | |
| 85 | + {img && ( | |
| 86 | + <section className="f-bloc f-galerie" aria-label="Visuel"> | |
| 87 | + <div className="carousel"> | |
| 88 | + <div className="carousel-track"> | |
| 89 | + <img src={img} alt={f.title} loading="eager" /> | |
| 90 | + </div> | |
| 91 | + </div> | |
| 92 | + </section> | |
| 93 | + )} | |
| 94 | + | |
| 95 | + <section className="f-bloc f-desc" id="description"> | |
| 96 | + <h2>Description</h2> | |
| 97 | + {f.description | |
| 98 | + ? f.description.split(/\n{2,}/).map((p, i) => ( | |
| 99 | + <p key={i} style={{ color: "var(--ink-2)" }}>{p}</p> | |
| 100 | + )) | |
| 101 | + : <p className="fine">La source ne fournit pas de description pour cette formation.</p>} | |
| 102 | + </section> | |
| 103 | + | |
| 104 | + {f.objectives.length > 0 && ( | |
| 105 | + <section className="f-bloc" id="objectifs"> | |
| 106 | + <h2>Objectifs d'apprentissage</h2> | |
| 107 | + <div className="amenity-row"> | |
| 108 | + {f.objectives.map((o) => ( | |
| 109 | + <span className="amenity confirmed" key={o}>✓ {o}</span> | |
| 110 | + ))} | |
| 111 | + </div> | |
| 112 | + </section> | |
| 113 | + )} | |
| 114 | + | |
| 115 | + {f.program.length > 0 && ( | |
| 116 | + <section className="f-bloc" id="plan"> | |
| 117 | + <h2>Plan de la formation</h2> | |
| 118 | + <ol style={{ color: "var(--ink-2)", lineHeight: 1.7, paddingLeft: 22, margin: 0 }}> | |
| 119 | + {f.program.map((p, i) => ( | |
| 120 | + <li key={i}>{p}</li> | |
| 121 | + ))} | |
| 122 | + </ol> | |
| 123 | + </section> | |
| 124 | + )} | |
| 125 | + | |
| 126 | + {(f.prerequisites || f.audience) && ( | |
| 127 | + <section className="f-bloc" id="admission"> | |
| 128 | + <h2>Admission</h2> | |
| 129 | + <div className="kv"> | |
| 130 | + {f.prerequisites && ( | |
| 131 | + <div className="cell"><div className="k">Préalables</div><div className="v">{f.prerequisites}</div></div> | |
| 132 | + )} | |
| 133 | + {f.audience && ( | |
| 134 | + <div className="cell"><div className="k">Clientèle visée</div><div className="v">{f.audience}</div></div> | |
| 135 | + )} | |
| 136 | + </div> | |
| 137 | + </section> | |
| 138 | + )} | |
| 139 | + | |
| 140 | + <section className="f-bloc f-pratique" id="pratique"> | |
| 141 | + <h2>Infos pratiques</h2> | |
| 142 | + <div className="kv"> | |
| 143 | + <div className="cell"><div className="k">Établissement</div><div className="v">{sourceName(f.source)}</div></div> | |
| 144 | + {f.code && ( | |
| 145 | + <div className="cell"><div className="k">Sigle</div><div className="v">{f.code}</div></div> | |
| 146 | + )} | |
| 147 | + {f.credential && ( | |
| 148 | + <div className="cell"><div className="k">Reconnaissance</div><div className="v">{f.credential}</div></div> | |
| 149 | + )} | |
| 150 | + {f.credits && ( | |
| 151 | + <div className="cell"><div className="k">Crédits / UEC</div><div className="v">{f.credits}</div></div> | |
| 152 | + )} | |
| 153 | + {dur && ( | |
| 154 | + <div className="cell"><div className="k">Durée</div><div className="v">{dur}</div></div> | |
| 155 | + )} | |
| 156 | + {f.instructor && ( | |
| 157 | + <div className="cell"><div className="k">Formateur·trice</div><div className="v">{f.instructor}</div></div> | |
| 158 | + )} | |
| 159 | + {f.price_label && ( | |
| 160 | + <div className="cell"><div className="k">Prix affiché</div><div className="v">{f.price_label}</div></div> | |
| 161 | + )} | |
| 162 | + {f.schedule_label && ( | |
| 163 | + <div className="cell"><div className="k">Horaire affiché</div><div className="v">{f.schedule_label}</div></div> | |
| 164 | + )} | |
| 165 | + {updated && ( | |
| 166 | + <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div> | |
| 167 | + )} | |
| 168 | + </div> | |
| 169 | + </section> | |
| 170 | + </div> | |
| 171 | + | |
| 172 | + {/* ------- colonne droite (desktop) : synthèse, dates, similaires ---- */} | |
| 173 | + <div className="f-col"> | |
| 174 | + <section className="f-bloc f-hero"> | |
| 175 | + <div className="price"> | |
| 176 | + {f.price === 0 || f.is_free ? ( | |
| 177 | + "Gratuit" | |
| 178 | + ) : f.price != null ? ( | |
| 179 | + <>{fmtPrice(f.price, f.price_label)}</> | |
| 180 | + ) : ( | |
| 181 | + <span style={{ fontSize: ".72em" }}>Prix non affiché par la source</span> | |
| 182 | + )} | |
| 183 | + </div> | |
| 184 | + <h1>{f.title}</h1> | |
| 185 | + <div className="loc"> | |
| 186 | + {[f.category, f.city].filter(Boolean).join(" · ")} | |
| 187 | + </div> | |
| 188 | + <div className="chips-scroll" role="list" aria-label="Caractéristiques clés"> | |
| 189 | + {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)} | |
| 190 | + </div> | |
| 191 | + <nav className="ancres" aria-label="Sections de la fiche"> | |
| 192 | + <a href="#description">Description</a> | |
| 193 | + {f.objectives.length > 0 && <a href="#objectifs">Objectifs</a>} | |
| 194 | + {f.program.length > 0 && <a href="#plan">Plan</a>} | |
| 195 | + <a href="#pratique">Infos pratiques</a> | |
| 196 | + </nav> | |
| 197 | + <a className="cta cta-desktop" href={f.url} | |
| 198 | + target="_blank" rel="noopener noreferrer"> | |
| 199 | + Voir chez {sourceName(f.source)} ↗ | |
| 200 | + </a> | |
| 201 | + </section> | |
| 202 | + | |
| 203 | + {sessions.length > 0 && ( | |
| 204 | + <section className="f-bloc" id="dates"> | |
| 205 | + <h2>Dates offertes</h2> | |
| 206 | + <div className="amenity-row"> | |
| 207 | + {sessions.map((s) => ( | |
| 208 | + <span className="amenity confirmed" key={s}>📅 {fmtDate(s) ?? s}</span> | |
| 209 | + ))} | |
| 210 | + </div> | |
| 211 | + </section> | |
| 212 | + )} | |
| 213 | + | |
| 214 | + {f.tags.length > 0 && ( | |
| 215 | + <section className="f-bloc"> | |
| 216 | + <h2>Thèmes</h2> | |
| 217 | + <div className="amenity-row"> | |
| 218 | + {f.tags.map((t) => ( | |
| 219 | + <span className="amenity unconfirmed" key={t}>{t}</span> | |
| 220 | + ))} | |
| 221 | + </div> | |
| 222 | + </section> | |
| 223 | + )} | |
| 224 | + | |
| 225 | + {(f.similar ?? []).length > 0 && ( | |
| 226 | + <section className="f-bloc"> | |
| 227 | + <h2>Formations similaires</h2> | |
| 228 | + <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 10 }}> | |
| 229 | + {(f.similar ?? []).map((s) => ( | |
| 230 | + <li key={s.uid}> | |
| 231 | + <Link to={`/formation/${encodeURIComponent(s.uid)}`} | |
| 232 | + style={{ color: "var(--ink)", fontWeight: 600 }}> | |
| 233 | + {s.title} | |
| 234 | + </Link> | |
| 235 | + <div className="fine"> | |
| 236 | + {[s.training_type, s.mode, s.duration, | |
| 237 | + s.price != null ? fmtPrice(s.price) : "", | |
| 238 | + sourceName(s.source)].filter(Boolean).join(" · ")} | |
| 239 | + </div> | |
| 240 | + </li> | |
| 241 | + ))} | |
| 242 | + </ul> | |
| 243 | + </section> | |
| 244 | + )} | |
| 245 | + </div> | |
| 246 | + </div> | |
| 247 | + | |
| 248 | + <div className="fine f-foot"> | |
| 249 | + {updated && <>Dernière synchronisation : {updated}. </>} | |
| 250 | + Les prix, dates et détails sont ceux affichés par la source — chaque fiche | |
| 251 | + renvoie à la page originale de la formation. | |
| 252 | + </div> | |
| 253 | + | |
| 254 | + {/* CTA sticky mobile — toujours visible */} | |
| 255 | + <div className="cta-sticky"> | |
| 256 | + <span className="cta-sticky-prix"> | |
| 257 | + {f.price === 0 || f.is_free ? "Gratuit" | |
| 258 | + : f.price != null ? fmtPrice(f.price, f.price_label) | |
| 259 | + : (f.credits || "Détails")} | |
| 260 | + </span> | |
| 261 | + <a className="cta" href={f.url} target="_blank" rel="noopener noreferrer"> | |
| 262 | + Voir chez {sourceName(f.source)} ↗ | |
| 263 | + </a> | |
| 264 | + </div> | |
| 265 | + </div> | |
| 266 | + ); | |
| 267 | +} | |
added
frontend/src/pages/Home.tsx
+455 −0
@@ -0,0 +1,455 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille | |
| 5 | +// Filtres : recherche, type, domaine, mode, ville, prix, gratuit, langue, | |
| 6 | +// niveau, source, tri — avec pastilles de filtres actifs. | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | +import { useEffect, useMemo, useState } from "react"; | |
| 9 | +import { useSearchParams } from "react-router-dom"; | |
| 10 | +import { | |
| 11 | + Facets, Formation, FormationFilters, Stats, | |
| 12 | + fetchFacets, fetchFormations, fetchSources, fetchStats, | |
| 13 | + registerSourceNames, sourceName, | |
| 14 | +} from "../api"; | |
| 15 | +import FormationCard from "../components/FormationCard"; | |
| 16 | + | |
| 17 | +const PRICE_STEPS = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000]; | |
| 18 | +const PAGE_SIZE = 12; | |
| 19 | + | |
| 20 | +/** Numéros de pages à afficher : 1 … autour de la courante … dernière. */ | |
| 21 | +function pageNumbers(current: number, total: number): (number | "…")[] { | |
| 22 | + if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); | |
| 23 | + const around = [current - 1, current, current + 1] | |
| 24 | + .filter((p) => p > 1 && p < total); | |
| 25 | + const out: (number | "…")[] = [1]; | |
| 26 | + if (around[0] > 2) out.push("…"); | |
| 27 | + out.push(...around); | |
| 28 | + if (around[around.length - 1] < total - 1) out.push("…"); | |
| 29 | + out.push(total); | |
| 30 | + return out; | |
| 31 | +} | |
| 32 | + | |
| 33 | +const SORTS: { key: string; label: string }[] = [ | |
| 34 | + { key: "recent", label: "Nouveautés" }, | |
| 35 | + { key: "start", label: "Prochaines dates" }, | |
| 36 | + { key: "price", label: "Prix croissant" }, | |
| 37 | + { key: "title", label: "A → Z" }, | |
| 38 | +]; | |
| 39 | + | |
| 40 | +export default function Home() { | |
| 41 | + const [formations, setFormations] = useState<Formation[] | null>(null); | |
| 42 | + const [total, setTotal] = useState(0); | |
| 43 | + const [facets, setFacets] = useState<Facets | null>(null); | |
| 44 | + const [stats, setStats] = useState<Stats | null>(null); | |
| 45 | + const [error, setError] = useState<string | null>(null); | |
| 46 | + | |
| 47 | + // filtres (pré-remplis depuis l'URL, ex. /?training_type=Séminaire) | |
| 48 | + const [params] = useSearchParams(); | |
| 49 | + const [q, setQ] = useState(params.get("q") ?? ""); | |
| 50 | + const [type, setType] = useState(params.get("training_type") ?? ""); | |
| 51 | + const [category, setCategory] = useState(params.get("category") ?? ""); | |
| 52 | + const [mode, setMode] = useState(params.get("mode") ?? ""); | |
| 53 | + const [city, setCity] = useState(params.get("city") ?? ""); | |
| 54 | + const [language, setLanguage] = useState(params.get("language") ?? ""); | |
| 55 | + const [level, setLevel] = useState(params.get("level") ?? ""); | |
| 56 | + const [source, setSource] = useState(params.get("source") ?? ""); | |
| 57 | + const [free, setFree] = useState(params.get("free") ?? ""); | |
| 58 | + const [priceMax, setPriceMax] = useState(params.get("price_max") ?? ""); | |
| 59 | + const [sort, setSort] = useState(params.get("sort") ?? "recent"); | |
| 60 | + const [page, setPage] = useState(1); | |
| 61 | + // feuille de filtres mobile (bottom sheet) + panneau avancé desktop | |
| 62 | + const [sheetOpen, setSheetOpen] = useState(false); | |
| 63 | + const [advOpen, setAdvOpen] = useState(false); | |
| 64 | + const activeFilters = [q, type, category, mode, city, language, level, | |
| 65 | + source, free, priceMax].filter(Boolean).length; | |
| 66 | + const advCount = [language, level, source, priceMax].filter(Boolean).length; | |
| 67 | + | |
| 68 | + const filters: FormationFilters = useMemo(() => ({ | |
| 69 | + q, training_type: type, category, mode, city, language, level, | |
| 70 | + source, free, price_max: priceMax, sort, | |
| 71 | + }), [q, type, category, mode, city, language, level, source, free, priceMax, sort]); | |
| 72 | + | |
| 73 | + // tout changement de filtre/tri ramène à la première page | |
| 74 | + useEffect(() => { setPage(1); }, [filters]); | |
| 75 | + | |
| 76 | + // re-rendu quand le registre des noms de sources est chargé | |
| 77 | + const [, setSourcesReady] = useState(false); | |
| 78 | + useEffect(() => { | |
| 79 | + fetchSources() | |
| 80 | + .then((r) => { registerSourceNames(r.sources); setSourcesReady(true); }) | |
| 81 | + .catch(() => {}); | |
| 82 | + fetchFacets().then(setFacets).catch(() => {}); | |
| 83 | + fetchStats().then(setStats).catch(() => {}); | |
| 84 | + }, []); | |
| 85 | + | |
| 86 | + useEffect(() => { | |
| 87 | + let cancelled = false; | |
| 88 | + setFormations(null); | |
| 89 | + setError(null); | |
| 90 | + fetchFormations({ | |
| 91 | + ...filters, | |
| 92 | + limit: String(PAGE_SIZE), | |
| 93 | + offset: String((page - 1) * PAGE_SIZE), | |
| 94 | + }) | |
| 95 | + .then((r) => { | |
| 96 | + if (!cancelled) { | |
| 97 | + setFormations(r.formations); | |
| 98 | + setTotal(r.total); | |
| 99 | + } | |
| 100 | + }) | |
| 101 | + .catch((e) => !cancelled && setError(String(e))); | |
| 102 | + return () => { | |
| 103 | + cancelled = true; | |
| 104 | + }; | |
| 105 | + }, [filters, page]); | |
| 106 | + | |
| 107 | + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); | |
| 108 | + const goto = (p: number) => { | |
| 109 | + setPage(Math.min(Math.max(1, p), totalPages)); | |
| 110 | + // remonter en haut des résultats, pas de la page | |
| 111 | + document.querySelector(".results-head")?.scrollIntoView({ behavior: "smooth" }); | |
| 112 | + }; | |
| 113 | + | |
| 114 | + const resetAll = () => { | |
| 115 | + setQ(""); setType(""); setCategory(""); setMode(""); setCity(""); | |
| 116 | + setLanguage(""); setLevel(""); setSource(""); setFree(""); setPriceMax(""); | |
| 117 | + }; | |
| 118 | + | |
| 119 | + // pastilles « filtres actifs » — libellé + action de retrait | |
| 120 | + const pills: { label: string; clear: () => void }[] = []; | |
| 121 | + if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") }); | |
| 122 | + if (type) pills.push({ label: type, clear: () => setType("") }); | |
| 123 | + if (category) pills.push({ label: category, clear: () => setCategory("") }); | |
| 124 | + if (mode) pills.push({ label: mode, clear: () => setMode("") }); | |
| 125 | + if (city) pills.push({ label: city, clear: () => setCity("") }); | |
| 126 | + if (free) pills.push({ label: "Gratuites", clear: () => setFree("") }); | |
| 127 | + if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") }); | |
| 128 | + if (language) pills.push({ label: language === "fr" ? "Français" : language === "en" ? "Anglais" : language, clear: () => setLanguage("") }); | |
| 129 | + if (level) pills.push({ label: level, clear: () => setLevel("") }); | |
| 130 | + if (source) pills.push({ label: sourceName(source), clear: () => setSource("") }); | |
| 131 | + | |
| 132 | + const types = facets?.types ?? []; | |
| 133 | + | |
| 134 | + return ( | |
| 135 | + <div className="container"> | |
| 136 | + <section className="hero"> | |
| 137 | + <span className="kicker">Agrégateur — toutes les formations du Québec</span> | |
| 138 | + <h1> | |
| 139 | + Toutes les <span className="outline">formations</span>,<br /> | |
| 140 | + <span className="hl">un seul</span> endroit. | |
| 141 | + </h1> | |
| 142 | + <p className="lede"> | |
| 143 | + Forma-Ka rassemble les cours en ligne, cours universitaires et collégiaux, | |
| 144 | + séminaires, ateliers et certifications offerts au Québec — mis à jour | |
| 145 | + automatiquement, avec tous les détails et un lien direct vers la formation | |
| 146 | + originale. | |
| 147 | + </p> | |
| 148 | + <div className="stat-row"> | |
| 149 | + <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span> | |
| 150 | + {stats && ( | |
| 151 | + <> | |
| 152 | + <span className="stat-chip"><b>{stats.total}</b> formations actives</span> | |
| 153 | + {(stats.gratuites ?? 0) > 0 && ( | |
| 154 | + <span className="stat-chip"><b>{stats.gratuites}</b> gratuites</span> | |
| 155 | + )} | |
| 156 | + {(stats.en_ligne ?? 0) > 0 && ( | |
| 157 | + <span className="stat-chip"><b>{stats.en_ligne}</b> en ligne</span> | |
| 158 | + )} | |
| 159 | + <span className="stat-chip"><b>{stats.sources}</b> établissements</span> | |
| 160 | + </> | |
| 161 | + )} | |
| 162 | + </div> | |
| 163 | + </section> | |
| 164 | + | |
| 165 | + {sheetOpen && ( | |
| 166 | + <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" /> | |
| 167 | + )} | |
| 168 | + <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres"> | |
| 169 | + <div className="sheet-head"> | |
| 170 | + <span>Filtres</span> | |
| 171 | + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres"> | |
| 172 | + ✕ | |
| 173 | + </button> | |
| 174 | + </div> | |
| 175 | + | |
| 176 | + {/* — rangée principale : recherche, domaine, mode, ville, + filtres — */} | |
| 177 | + <div className="f-primary"> | |
| 178 | + <div className="f-search"> | |
| 179 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true"> | |
| 180 | + <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /> | |
| 181 | + </svg> | |
| 182 | + <input | |
| 183 | + id="f-q" placeholder="Sujet, sigle, compétence…" value={q} | |
| 184 | + onChange={(e) => setQ(e.target.value)} | |
| 185 | + aria-label="Recherche" | |
| 186 | + /> | |
| 187 | + {q && ( | |
| 188 | + <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button> | |
| 189 | + )} | |
| 190 | + </div> | |
| 191 | + <label className="f-ctl"> | |
| 192 | + <span>Domaine</span> | |
| 193 | + <select value={category} onChange={(e) => setCategory(e.target.value)}> | |
| 194 | + <option value="">Tous</option> | |
| 195 | + {(facets?.categories ?? []).map((c) => ( | |
| 196 | + <option key={c.category} value={c.category}>{c.category} ({c.n})</option> | |
| 197 | + ))} | |
| 198 | + </select> | |
| 199 | + </label> | |
| 200 | + <label className="f-ctl"> | |
| 201 | + <span>Mode</span> | |
| 202 | + <select value={mode} onChange={(e) => setMode(e.target.value)}> | |
| 203 | + <option value="">Tous</option> | |
| 204 | + {(facets?.modes ?? ["en ligne", "présentiel", "hybride", "asynchrone"]).map((m) => ( | |
| 205 | + <option key={m} value={m}>{m}</option> | |
| 206 | + ))} | |
| 207 | + </select> | |
| 208 | + </label> | |
| 209 | + <label className="f-ctl"> | |
| 210 | + <span>Ville</span> | |
| 211 | + <select value={city} onChange={(e) => setCity(e.target.value)}> | |
| 212 | + <option value="">Toutes</option> | |
| 213 | + {(facets?.cities ?? []).map((c) => ( | |
| 214 | + <option key={c} value={c}>{c}</option> | |
| 215 | + ))} | |
| 216 | + </select> | |
| 217 | + </label> | |
| 218 | + <button | |
| 219 | + className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} | |
| 220 | + onClick={() => setAdvOpen(!advOpen)} | |
| 221 | + aria-expanded={advOpen} | |
| 222 | + > | |
| 223 | + Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"} | |
| 224 | + </button> | |
| 225 | + </div> | |
| 226 | + | |
| 227 | + {/* — panneau avancé : segments & sélecteurs — */} | |
| 228 | + {(advOpen || sheetOpen) && ( | |
| 229 | + <div className="f-adv"> | |
| 230 | + <div className="f-group"> | |
| 231 | + <label>Prix</label> | |
| 232 | + <div className="seg" role="group"> | |
| 233 | + <button className={free === "" && !priceMax ? "on" : ""} | |
| 234 | + onClick={() => { setFree(""); setPriceMax(""); }}> | |
| 235 | + Peu importe | |
| 236 | + </button> | |
| 237 | + <button className={free === "1" ? "on" : ""} | |
| 238 | + onClick={() => { setFree(free === "1" ? "" : "1"); setPriceMax(""); }}> | |
| 239 | + Gratuites | |
| 240 | + </button> | |
| 241 | + </div> | |
| 242 | + </div> | |
| 243 | + <div className="f-group"> | |
| 244 | + <label>Prix maximum</label> | |
| 245 | + <select className="f-native" value={priceMax} | |
| 246 | + onChange={(e) => { setPriceMax(e.target.value); setFree(""); }}> | |
| 247 | + <option value="">Aucun</option> | |
| 248 | + {PRICE_STEPS.map((p) => ( | |
| 249 | + <option key={p} value={p}>≤ {p.toLocaleString("fr-CA")} $</option> | |
| 250 | + ))} | |
| 251 | + </select> | |
| 252 | + </div> | |
| 253 | + <div className="f-group"> | |
| 254 | + <label>Langue</label> | |
| 255 | + <div className="seg" role="group"> | |
| 256 | + {[["", "Peu importe"], ["fr", "Français"], ["en", "Anglais"]].map(([v, l]) => ( | |
| 257 | + <button key={v} className={language === v ? "on" : ""} | |
| 258 | + onClick={() => setLanguage(v)}> | |
| 259 | + {l} | |
| 260 | + </button> | |
| 261 | + ))} | |
| 262 | + </div> | |
| 263 | + </div> | |
| 264 | + <div className="f-group"> | |
| 265 | + <label>Niveau</label> | |
| 266 | + <div className="seg" role="group"> | |
| 267 | + <button className={level === "" ? "on" : ""} onClick={() => setLevel("")}> | |
| 268 | + Peu importe | |
| 269 | + </button> | |
| 270 | + {(facets?.levels ?? []).map((l) => ( | |
| 271 | + <button key={l} className={level === l ? "on" : ""} | |
| 272 | + onClick={() => setLevel(l)}> | |
| 273 | + {l} | |
| 274 | + </button> | |
| 275 | + ))} | |
| 276 | + </div> | |
| 277 | + </div> | |
| 278 | + <div className="f-group"> | |
| 279 | + <label>Établissement</label> | |
| 280 | + <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}> | |
| 281 | + <option value="">Tous</option> | |
| 282 | + {(facets?.sources ?? []).map((s) => ( | |
| 283 | + <option key={s.source} value={s.source}> | |
| 284 | + {sourceName(s.source)} ({s.n}) | |
| 285 | + </option> | |
| 286 | + ))} | |
| 287 | + </select> | |
| 288 | + </div> | |
| 289 | + <div className="f-group f-group-end"> | |
| 290 | + <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}> | |
| 291 | + Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""} | |
| 292 | + </button> | |
| 293 | + </div> | |
| 294 | + </div> | |
| 295 | + )} | |
| 296 | + | |
| 297 | + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}> | |
| 298 | + Voir les résultats {formations ? `(${total})` : ""} | |
| 299 | + </button> | |
| 300 | + </section> | |
| 301 | + | |
| 302 | + <div className="chips" role="group" aria-label="Filtres rapides — type de formation"> | |
| 303 | + {types.slice(0, 8).map(({ t, n }) => ( | |
| 304 | + <button | |
| 305 | + key={t} | |
| 306 | + className={`chip ${type === t ? "on" : ""}`} | |
| 307 | + onClick={() => setType(type === t ? "" : t)} | |
| 308 | + title={`${n} formations`} | |
| 309 | + > | |
| 310 | + {t} | |
| 311 | + </button> | |
| 312 | + ))} | |
| 313 | + <span className="chip-sep" aria-hidden="true" /> | |
| 314 | + <button | |
| 315 | + className={`chip ${free === "1" ? "on" : ""}`} | |
| 316 | + onClick={() => setFree(free === "1" ? "" : "1")} | |
| 317 | + > | |
| 318 | + 🆓 Gratuites | |
| 319 | + </button> | |
| 320 | + <button | |
| 321 | + className={`chip ${mode === "en ligne" ? "on" : ""}`} | |
| 322 | + onClick={() => setMode(mode === "en ligne" ? "" : "en ligne")} | |
| 323 | + > | |
| 324 | + 💻 En ligne | |
| 325 | + </button> | |
| 326 | + </div> | |
| 327 | + | |
| 328 | + {pills.length > 0 && ( | |
| 329 | + <div className="pills" aria-label="Filtres actifs"> | |
| 330 | + {pills.map((p) => ( | |
| 331 | + <button key={p.label} className="pill" onClick={p.clear} | |
| 332 | + aria-label={`Retirer le filtre ${p.label}`}> | |
| 333 | + {p.label} <span className="pill-x">✕</span> | |
| 334 | + </button> | |
| 335 | + ))} | |
| 336 | + <button className="pill pill-clear" onClick={resetAll}> | |
| 337 | + Tout effacer | |
| 338 | + </button> | |
| 339 | + </div> | |
| 340 | + )} | |
| 341 | + | |
| 342 | + <div className="results-head"> | |
| 343 | + <h2>Formations offertes</h2> | |
| 344 | + <div className="results-tools"> | |
| 345 | + {formations && <span>{total} résultat{total > 1 ? "s" : ""}</span>} | |
| 346 | + <div className="view-toggle" role="tablist" aria-label="Tri"> | |
| 347 | + {SORTS.map((s) => ( | |
| 348 | + <button | |
| 349 | + key={s.key} | |
| 350 | + role="tab" aria-selected={sort === s.key} | |
| 351 | + className={sort === s.key ? "on" : ""} | |
| 352 | + onClick={() => setSort(s.key)} | |
| 353 | + > | |
| 354 | + {s.label} | |
| 355 | + </button> | |
| 356 | + ))} | |
| 357 | + </div> | |
| 358 | + </div> | |
| 359 | + </div> | |
| 360 | + | |
| 361 | + {error && ( | |
| 362 | + <div className="notice"> | |
| 363 | + <div className="big">⚠️</div> | |
| 364 | + <h2>Impossible de charger les formations</h2> | |
| 365 | + <p>{error}</p> | |
| 366 | + <button className="btn btn-primary" onClick={() => window.location.reload()}> | |
| 367 | + Réessayer | |
| 368 | + </button> | |
| 369 | + </div> | |
| 370 | + )} | |
| 371 | + | |
| 372 | + {!error && formations === null && ( | |
| 373 | + <div className="grid" aria-busy="true"> | |
| 374 | + {Array.from({ length: 8 }).map((_, i) => ( | |
| 375 | + <div className="skel" key={i}> | |
| 376 | + <div className="sk-img" /> | |
| 377 | + <div className="sk-line" /> | |
| 378 | + <div className="sk-line short" /> | |
| 379 | + </div> | |
| 380 | + ))} | |
| 381 | + </div> | |
| 382 | + )} | |
| 383 | + | |
| 384 | + {!error && formations !== null && formations.length === 0 && ( | |
| 385 | + <div className="notice"> | |
| 386 | + <div className="big">🔍</div> | |
| 387 | + <h2>Aucune formation ne correspond</h2> | |
| 388 | + <p> | |
| 389 | + Essayez d'élargir vos critères | |
| 390 | + {activeFilters > 0 && ( | |
| 391 | + <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></> | |
| 392 | + )}. | |
| 393 | + </p> | |
| 394 | + </div> | |
| 395 | + )} | |
| 396 | + | |
| 397 | + {!error && formations !== null && formations.length > 0 && ( | |
| 398 | + <> | |
| 399 | + <div className="grid"> | |
| 400 | + {formations.map((f) => ( | |
| 401 | + <FormationCard key={f.uid} f={f} /> | |
| 402 | + ))} | |
| 403 | + </div> | |
| 404 | + | |
| 405 | + {totalPages > 1 && ( | |
| 406 | + <nav className="pagination" aria-label="Pagination des résultats"> | |
| 407 | + <button | |
| 408 | + className="page-btn page-prev" | |
| 409 | + onClick={() => goto(page - 1)} | |
| 410 | + disabled={page <= 1} | |
| 411 | + aria-label="Page précédente" | |
| 412 | + > | |
| 413 | + ← Précédent | |
| 414 | + </button> | |
| 415 | + {pageNumbers(page, totalPages).map((p, i) => | |
| 416 | + p === "…" ? ( | |
| 417 | + <span key={`e${i}`} className="page-ellipsis" aria-hidden="true">…</span> | |
| 418 | + ) : ( | |
| 419 | + <button | |
| 420 | + key={p} | |
| 421 | + className={`page-btn ${p === page ? "on" : ""}`} | |
| 422 | + onClick={() => goto(p)} | |
| 423 | + aria-current={p === page ? "page" : undefined} | |
| 424 | + > | |
| 425 | + {p} | |
| 426 | + </button> | |
| 427 | + ) | |
| 428 | + )} | |
| 429 | + <button | |
| 430 | + className="page-btn page-next" | |
| 431 | + onClick={() => goto(page + 1)} | |
| 432 | + disabled={page >= totalPages} | |
| 433 | + aria-label="Page suivante" | |
| 434 | + > | |
| 435 | + Suivant → | |
| 436 | + </button> | |
| 437 | + </nav> | |
| 438 | + )} | |
| 439 | + <div className="fine" style={{ textAlign: "center", marginTop: 8 }}> | |
| 440 | + Page {page} de {totalPages} — {total.toLocaleString("fr-CA")} formations | |
| 441 | + </div> | |
| 442 | + </> | |
| 443 | + )} | |
| 444 | + | |
| 445 | + {/* Bouton flottant mobile — ouvre la feuille de filtres */} | |
| 446 | + <button | |
| 447 | + className="fab" | |
| 448 | + onClick={() => setSheetOpen(true)} | |
| 449 | + aria-label="Ouvrir les filtres" | |
| 450 | + > | |
| 451 | + ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""} | |
| 452 | + </button> | |
| 453 | + </div> | |
| 454 | + ); | |
| 455 | +} | |
added
frontend/src/pages/Privacy.tsx
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Privacy.tsx : politique de confidentialité et de témoins (cookies) | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | + | |
| 7 | +export default function PrivacyPage() { | |
| 8 | + const reopen = () => window.dispatchEvent(new Event("formaka:openConsent")); | |
| 9 | + | |
| 10 | + return ( | |
| 11 | + <div className="container legal"> | |
| 12 | + <span className="kicker">Confidentialité & témoins</span> | |
| 13 | + <h1>Votre vie privée, simplement.</h1> | |
| 14 | + | |
| 15 | + <section> | |
| 16 | + <h3>Ce que Forma-Ka collecte</h3> | |
| 17 | + <p> | |
| 18 | + Rien qui vous identifie. Forma-Ka n'a pas de compte utilisateur, pas de | |
| 19 | + formulaire d'inscription, pas de pixel publicitaire et ne vend aucune | |
| 20 | + donnée. Le site consulte des fiches publiques de gestionnaires | |
| 21 | + immobiliers et vous les présente — c'est tout. | |
| 22 | + </p> | |
| 23 | + </section> | |
| 24 | + | |
| 25 | + <section> | |
| 26 | + <h3>Témoins et stockage local</h3> | |
| 27 | + <p> | |
| 28 | + Forma-Ka n'utilise <b>aucun témoin (cookie) tiers</b>. Le seul stockage | |
| 29 | + se fait dans votre navigateur (« localStorage ») : | |
| 30 | + </p> | |
| 31 | + <ul> | |
| 32 | + <li><b>Essentiels</b> — votre choix de consentement lui-même.</li> | |
| 33 | + <li><b>Préférences</b> (si acceptées) — vos filtres de recherche et votre vue liste/carte.</li> | |
| 34 | + <li><b>Statistiques</b> (si acceptées) — une éventuelle mesure d'audience anonyme, | |
| 35 | + sans identifiant ni profilage. Aucune n'est active à ce jour.</li> | |
| 36 | + </ul> | |
| 37 | + <p> | |
| 38 | + <button className="btn btn-ghost" onClick={reopen}>Modifier mes choix de témoins</button> | |
| 39 | + </p> | |
| 40 | + </section> | |
| 41 | + | |
| 42 | + <section> | |
| 43 | + <h3>Services externes</h3> | |
| 44 | + <ul> | |
| 45 | + <li><b>Photos des fiches</b> — chargées depuis les serveurs des gestionnaires | |
| 46 | + immobiliers ; leur politique s'applique à ces requêtes.</li> | |
| 47 | + <li><b>Carte</b> — tuiles vectorielles d'OpenFreeMap (données © OpenStreetMap), | |
| 48 | + chargées uniquement si vous ouvrez la vue carte.</li> | |
| 49 | + <li><b>Commodités de proximité</b> — calculées côté serveur à partir | |
| 50 | + d'OpenStreetMap ; votre navigateur ne contacte pas ces services.</li> | |
| 51 | + </ul> | |
| 52 | + </section> | |
| 53 | + | |
| 54 | + <section> | |
| 55 | + <h3>Vos fiches, vos sources</h3> | |
| 56 | + <p> | |
| 57 | + Chaque fiche renvoie vers l'fiche originale du gestionnaire. Pour | |
| 58 | + faire retirer un contenu ou pour toute question :{" "} | |
| 59 | + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>. | |
| 60 | + </p> | |
| 61 | + </section> | |
| 62 | + | |
| 63 | + <div className="fine">Dernière mise à jour : août 2026.</div> | |
| 64 | + </div> | |
| 65 | + ); | |
| 66 | +} | |
added
frontend/src/pages/Sources.tsx
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Sources.tsx : registre des établissements de formation agrégés | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import { useEffect, useState } from "react"; | |
| 7 | +import { Source, fetchSources } from "../api"; | |
| 8 | + | |
| 9 | +export default function SourcesPage() { | |
| 10 | + const [sources, setSources] = useState<Source[] | null>(null); | |
| 11 | + const [error, setError] = useState<string | null>(null); | |
| 12 | + | |
| 13 | + useEffect(() => { | |
| 14 | + fetchSources() | |
| 15 | + .then((r) => setSources(r.sources)) | |
| 16 | + .catch((e) => setError(String(e))); | |
| 17 | + }, []); | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <div className="container sources"> | |
| 21 | + <span className="kicker">Registre — établissements de formation</span> | |
| 22 | + <h1>Sources agrégées</h1> | |
| 23 | + <p className="sub"> | |
| 24 | + Universités, cégeps, firmes de formation et organisateurs d'événements du | |
| 25 | + Québec recensés par Forma-Ka. Chaque source « active » est synchronisée | |
| 26 | + périodiquement par un connecteur dédié; les autres sont en attente de | |
| 27 | + connecteur. | |
| 28 | + </p> | |
| 29 | + | |
| 30 | + {error && <div className="notice">⚠️ {error}</div>} | |
| 31 | + {!sources && !error && <div className="notice">Chargement…</div>} | |
| 32 | + | |
| 33 | + {sources && ( | |
| 34 | + <div className="src-wrap"> | |
| 35 | + <table className="src-table"> | |
| 36 | + <thead> | |
| 37 | + <tr> | |
| 38 | + <th>Établissement</th> | |
| 39 | + <th>Type d'offre</th> | |
| 40 | + <th>Statut</th> | |
| 41 | + <th style={{ textAlign: "right" }}>Formations actives</th> | |
| 42 | + <th>Dernière synchro</th> | |
| 43 | + </tr> | |
| 44 | + </thead> | |
| 45 | + <tbody> | |
| 46 | + {sources.map((s) => ( | |
| 47 | + <tr key={s.id}> | |
| 48 | + <td> | |
| 49 | + <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a> | |
| 50 | + </td> | |
| 51 | + <td style={{ color: "var(--ink-2)", maxWidth: 380 }}>{s.type_offre}</td> | |
| 52 | + <td> | |
| 53 | + {s.connector ? ( | |
| 54 | + <span className="pill ok">connecté</span> | |
| 55 | + ) : ( | |
| 56 | + <span className="pill todo">{s.status}</span> | |
| 57 | + )} | |
| 58 | + </td> | |
| 59 | + <td style={{ textAlign: "right" }}> | |
| 60 | + <span className="count-pill">{s.active_formations || "—"}</span> | |
| 61 | + </td> | |
| 62 | + <td style={{ color: "var(--ink-3)" }}> | |
| 63 | + {s.last_sync | |
| 64 | + ? new Date(s.last_sync * 1000).toLocaleString("fr-CA") | |
| 65 | + : "—"} | |
| 66 | + </td> | |
| 67 | + </tr> | |
| 68 | + ))} | |
| 69 | + </tbody> | |
| 70 | + </table> | |
| 71 | + </div> | |
| 72 | + )} | |
| 73 | + </div> | |
| 74 | + ); | |
| 75 | +} | |
added
frontend/src/pages/Stats.tsx
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Stats.tsx : portrait de l'offre de formation — totaux, répartition par | |
| 5 | +// type (barres), santé des synchronisations récentes. | |
| 6 | +// ----------------------------------------------------------------------------- | |
| 7 | +import { useEffect, useState } from "react"; | |
| 8 | +import { Link } from "react-router-dom"; | |
| 9 | +import { Stats, fetchSources, fetchStats, registerSourceNames, sourceName } from "../api"; | |
| 10 | + | |
| 11 | +export default function StatsPage() { | |
| 12 | + const [stats, setStats] = useState<Stats | null>(null); | |
| 13 | + const [error, setError] = useState<string | null>(null); | |
| 14 | + | |
| 15 | + useEffect(() => { | |
| 16 | + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); | |
| 17 | + fetchStats().then(setStats).catch((e) => setError(String(e))); | |
| 18 | + }, []); | |
| 19 | + | |
| 20 | + if (error) | |
| 21 | + return ( | |
| 22 | + <div className="notice container"> | |
| 23 | + <div className="big">⚠️</div> | |
| 24 | + <h2>Impossible de charger les statistiques</h2> | |
| 25 | + <p>{error}</p> | |
| 26 | + </div> | |
| 27 | + ); | |
| 28 | + | |
| 29 | + if (!stats) | |
| 30 | + return ( | |
| 31 | + <div className="container"> | |
| 32 | + <div className="notice">Chargement…</div> | |
| 33 | + </div> | |
| 34 | + ); | |
| 35 | + | |
| 36 | + const maxType = Math.max(1, ...stats.par_type.map((t) => t.n)); | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <div className="container sources"> | |
| 40 | + <span className="kicker">Portrait — l'offre de formation au Québec</span> | |
| 41 | + <h1>Statistiques</h1> | |
| 42 | + <p className="sub"> | |
| 43 | + Portrait en direct des formations actives agrégées par Forma-Ka, tous | |
| 44 | + établissements confondus. | |
| 45 | + </p> | |
| 46 | + | |
| 47 | + <div className="stat-row" style={{ marginBottom: 28 }}> | |
| 48 | + <span className="stat-chip"><b>{stats.total}</b> formations actives</span> | |
| 49 | + <span className="stat-chip"><b>{stats.sources}</b> établissements</span> | |
| 50 | + {(stats.gratuites ?? 0) > 0 && ( | |
| 51 | + <span className="stat-chip"><b>{stats.gratuites}</b> gratuites</span> | |
| 52 | + )} | |
| 53 | + {(stats.en_ligne ?? 0) > 0 && ( | |
| 54 | + <span className="stat-chip"><b>{stats.en_ligne}</b> en ligne</span> | |
| 55 | + )} | |
| 56 | + {stats.avg_price != null && ( | |
| 57 | + <span className="stat-chip"> | |
| 58 | + prix moyen (affiché) <b>{Math.round(stats.avg_price).toLocaleString("fr-CA")} $</b> | |
| 59 | + </span> | |
| 60 | + )} | |
| 61 | + {stats.avg_hours != null && ( | |
| 62 | + <span className="stat-chip"> | |
| 63 | + durée moyenne <b>{Math.round(stats.avg_hours)} h</b> | |
| 64 | + </span> | |
| 65 | + )} | |
| 66 | + </div> | |
| 67 | + | |
| 68 | + <h2 style={{ fontFamily: "var(--font-display)" }}>Par type de formation</h2> | |
| 69 | + <div style={{ display: "grid", gap: 8, marginBottom: 36 }}> | |
| 70 | + {stats.par_type.map((t) => ( | |
| 71 | + <Link | |
| 72 | + key={t.t || "(sans type)"} | |
| 73 | + to={`/?training_type=${encodeURIComponent(t.t)}`} | |
| 74 | + style={{ | |
| 75 | + display: "grid", gridTemplateColumns: "180px 1fr 56px", | |
| 76 | + alignItems: "center", gap: 12, color: "var(--ink)", | |
| 77 | + }} | |
| 78 | + > | |
| 79 | + <span style={{ fontWeight: 600, fontSize: ".92rem" }}>{t.t || "Autre"}</span> | |
| 80 | + <span style={{ background: "var(--surface-2)", border: "1px solid var(--line)", borderRadius: 6, overflow: "hidden", height: 18 }}> | |
| 81 | + <span style={{ | |
| 82 | + display: "block", height: "100%", | |
| 83 | + width: `${Math.max(2, Math.round((t.n / maxType) * 100))}%`, | |
| 84 | + background: "var(--lime)", borderRight: "1px solid var(--ink)", | |
| 85 | + }} /> | |
| 86 | + </span> | |
| 87 | + <span style={{ fontFamily: "var(--font-mono)", fontSize: ".85rem", textAlign: "right" }}> | |
| 88 | + {t.n} | |
| 89 | + </span> | |
| 90 | + </Link> | |
| 91 | + ))} | |
| 92 | + </div> | |
| 93 | + | |
| 94 | + <h2 style={{ fontFamily: "var(--font-display)" }}>Synchronisations récentes</h2> | |
| 95 | + <div className="src-wrap"> | |
| 96 | + <table className="src-table"> | |
| 97 | + <thead> | |
| 98 | + <tr> | |
| 99 | + <th>Source</th> | |
| 100 | + <th>Quand</th> | |
| 101 | + <th style={{ textAlign: "right" }}>Trouvées</th> | |
| 102 | + <th style={{ textAlign: "right" }}>+ / ~ / −</th> | |
| 103 | + <th>État</th> | |
| 104 | + </tr> | |
| 105 | + </thead> | |
| 106 | + <tbody> | |
| 107 | + {stats.recent_syncs.map((s, i) => ( | |
| 108 | + <tr key={i}> | |
| 109 | + <td>{sourceName(s.source)}</td> | |
| 110 | + <td style={{ color: "var(--ink-3)" }}> | |
| 111 | + {new Date(s.ts * 1000).toLocaleString("fr-CA")} | |
| 112 | + </td> | |
| 113 | + <td style={{ textAlign: "right" }}>{s.found}</td> | |
| 114 | + <td style={{ textAlign: "right", fontFamily: "var(--font-mono)", fontSize: ".85rem" }}> | |
| 115 | + {s.added} / {s.updated} / {s.removed} | |
| 116 | + </td> | |
| 117 | + <td> | |
| 118 | + {s.ok ? ( | |
| 119 | + s.message === "ok" | |
| 120 | + ? <span className="pill ok">ok</span> | |
| 121 | + : <span className="pill todo" title={s.message}>alerte</span> | |
| 122 | + ) : ( | |
| 123 | + <span className="pill todo" title={s.message}>échec</span> | |
| 124 | + )} | |
| 125 | + </td> | |
| 126 | + </tr> | |
| 127 | + ))} | |
| 128 | + </tbody> | |
| 129 | + </table> | |
| 130 | + </div> | |
| 131 | + </div> | |
| 132 | + ); | |
| 133 | +} | |
added
frontend/src/styles.css
+1101 −0
@@ -0,0 +1,1101 @@ | ||
| 1 | +/* ----------------------------------------------------------------------------- | |
| 2 | + Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | + Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + styles.css : système de design « éditorial sharp » — thème clair | |
| 5 | + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono | |
| 6 | + · Signature : bordures encre + ombres décalées (néo-brutalisme raffiné) | |
| 7 | + · Accent surligneur ambré #ffd54d sur encre profonde | |
| 8 | + · 100 % adaptatif mobile (PWA installable, safe-areas iOS) | |
| 9 | +----------------------------------------------------------------------------- */ | |
| 10 | +:root { | |
| 11 | + --paper: #f5f3ee; | |
| 12 | + --surface: #ffffff; | |
| 13 | + --surface-2: #faf9f5; | |
| 14 | + --ink: #141814; | |
| 15 | + --ink-2: #4d5551; | |
| 16 | + --ink-3: #8b928c; | |
| 17 | + --line: rgba(20, 24, 20, 0.14); | |
| 18 | + --line-strong: rgba(20, 24, 20, 0.85); | |
| 19 | + --green: #1d3f66; | |
| 20 | + --green-deep: #122a4a; | |
| 21 | + --lime: #ffd54d; | |
| 22 | + --lime-soft: #fdf2cf; | |
| 23 | + --amber: #e8a33d; | |
| 24 | + --amber-soft: #fdf3e2; | |
| 25 | + --danger: #b3423a; | |
| 26 | + --r-card: 10px; | |
| 27 | + --r-ctl: 6px; | |
| 28 | + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 29 | + --shadow-off: 6px 6px 0 var(--ink); | |
| 30 | + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 31 | + --font-display: "Space Grotesk", system-ui, sans-serif; | |
| 32 | + --font-body: "Inter", system-ui, sans-serif; | |
| 33 | + --font-mono: "JetBrains Mono", ui-monospace, monospace; | |
| 34 | +} | |
| 35 | + | |
| 36 | +* { box-sizing: border-box; } | |
| 37 | +html { scroll-behavior: smooth; } | |
| 38 | +body { | |
| 39 | + margin: 0; | |
| 40 | + background: var(--paper); | |
| 41 | + color: var(--ink); | |
| 42 | + font-family: var(--font-body); | |
| 43 | + font-size: 15px; | |
| 44 | + line-height: 1.55; | |
| 45 | + -webkit-font-smoothing: antialiased; | |
| 46 | + padding-bottom: env(safe-area-inset-bottom); | |
| 47 | +} | |
| 48 | +/* grain subtil — texture signature */ | |
| 49 | +body::before { | |
| 50 | + content: ""; | |
| 51 | + position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35; | |
| 52 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| 53 | +} | |
| 54 | +#root { position: relative; z-index: 1; } | |
| 55 | + | |
| 56 | +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0; } | |
| 57 | +a { color: inherit; text-decoration: none; } | |
| 58 | +button { font-family: inherit; } | |
| 59 | +img { display: block; } | |
| 60 | +::selection { background: var(--lime); color: var(--ink); } | |
| 61 | + | |
| 62 | +.mono { font-family: var(--font-mono); } | |
| 63 | +.kicker { | |
| 64 | + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; | |
| 65 | + text-transform: uppercase; letter-spacing: 0.14em; color: var(--green); | |
| 66 | + display: inline-flex; align-items: center; gap: 8px; | |
| 67 | +} | |
| 68 | +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); } | |
| 69 | + | |
| 70 | +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; } | |
| 71 | +@media (max-width: 640px) { .container { padding: 0 16px; } } | |
| 72 | + | |
| 73 | +/* ================= Header ================= */ | |
| 74 | +.header { | |
| 75 | + position: sticky; top: 0; z-index: 50; | |
| 76 | + background: rgba(245, 243, 238, 0.88); | |
| 77 | + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); | |
| 78 | + border-bottom: 2px solid var(--ink); | |
| 79 | +} | |
| 80 | +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; } | |
| 81 | +.brand { | |
| 82 | + font-family: var(--font-display); font-weight: 700; font-size: 26px; | |
| 83 | + letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; | |
| 84 | +} | |
| 85 | +.brand .ka { | |
| 86 | + background: var(--ink); color: var(--lime); padding: 2px 7px 4px; | |
| 87 | + border-radius: 6px; margin-left: 3px; transform: rotate(-2deg); | |
| 88 | + transition: transform 0.2s ease; | |
| 89 | +} | |
| 90 | +.brand:hover .ka { transform: rotate(0deg); } | |
| 91 | +.brand-tag { | |
| 92 | + font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); | |
| 93 | + letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px; | |
| 94 | +} | |
| 95 | +@media (max-width: 760px) { .brand-tag { display: none; } } | |
| 96 | +.nav { margin-left: auto; display: flex; gap: 4px; } | |
| 97 | +.nav a { | |
| 98 | + padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px; | |
| 99 | + color: var(--ink-2); border: 1.5px solid transparent; | |
| 100 | + transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center; | |
| 101 | +} | |
| 102 | +.nav a:hover { border-color: var(--ink); color: var(--ink); } | |
| 103 | +.nav a.active { background: var(--ink); color: var(--lime); } | |
| 104 | + | |
| 105 | +/* ================= Ticker ================= */ | |
| 106 | +.ticker { | |
| 107 | + background: var(--ink); color: var(--lime); overflow: hidden; | |
| 108 | + font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; | |
| 109 | + text-transform: uppercase; padding: 7px 0; white-space: nowrap; | |
| 110 | + border-bottom: 1px solid rgba(217, 242, 107, 0.25); | |
| 111 | +} | |
| 112 | +.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; } | |
| 113 | +.ticker span { padding: 0 26px; position: relative; } | |
| 114 | +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; } | |
| 115 | +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } } | |
| 116 | +@media (prefers-reduced-motion: reduce) { | |
| 117 | + .ticker-track { animation: none; } | |
| 118 | + * { transition-duration: 0.01ms !important; } | |
| 119 | +} | |
| 120 | + | |
| 121 | +/* ================= Hero ================= */ | |
| 122 | +.hero { padding: 58px 0 22px; } | |
| 123 | +@media (max-width: 640px) { .hero { padding: 34px 0 14px; } } | |
| 124 | +.hero h1 { | |
| 125 | + font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98; | |
| 126 | + text-transform: uppercase; letter-spacing: -0.035em; max-width: 900px; margin-top: 14px; | |
| 127 | +} | |
| 128 | +.hero h1 .outline { | |
| 129 | + color: transparent; -webkit-text-stroke: 2px var(--ink); | |
| 130 | +} | |
| 131 | +.hero h1 .hl { | |
| 132 | + background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block; | |
| 133 | + transform: rotate(-1deg); | |
| 134 | +} | |
| 135 | +.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 640px; margin: 20px 0 0; } | |
| 136 | +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; } | |
| 137 | +.stat-chip { | |
| 138 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; | |
| 139 | + padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; | |
| 140 | + color: var(--ink-2); display: flex; gap: 8px; align-items: center; | |
| 141 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); | |
| 142 | +} | |
| 143 | +.stat-chip b { color: var(--ink); font-weight: 700; } | |
| 144 | +.stat-chip .pulse { | |
| 145 | + width: 8px; height: 8px; border-radius: 50%; background: var(--green); | |
| 146 | + box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite; | |
| 147 | +} | |
| 148 | +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(217, 242, 107, 0.4); } } | |
| 149 | + | |
| 150 | +/* ================= Filter bar ================= */ | |
| 151 | +.filterbar { | |
| 152 | + background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card); | |
| 153 | + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 30px 0 6px; | |
| 154 | + display: flex; flex-direction: column; gap: 0; min-width: 0; | |
| 155 | +} | |
| 156 | + | |
| 157 | +/* — rangée principale — */ | |
| 158 | +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; } | |
| 159 | +.f-search { | |
| 160 | + flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 9px; | |
| 161 | + border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2); | |
| 162 | + padding: 0 13px; min-height: 52px; color: var(--ink-2); | |
| 163 | + transition: box-shadow 0.15s ease; | |
| 164 | +} | |
| 165 | +.f-search:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); } | |
| 166 | +.f-search input { | |
| 167 | + border: none; background: none; outline: none; flex: 1; min-width: 0; | |
| 168 | + font-size: 15px; color: var(--ink); font-family: inherit; | |
| 169 | +} | |
| 170 | +.f-clear { | |
| 171 | + border: none; background: var(--line); color: var(--ink-2); border-radius: 50%; | |
| 172 | + width: 20px; height: 20px; font-size: 10px; cursor: pointer; flex: none; | |
| 173 | + display: grid; place-items: center; | |
| 174 | +} | |
| 175 | +.f-ctl { | |
| 176 | + flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; | |
| 177 | + gap: 2px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); | |
| 178 | + padding: 7px 12px 6px; min-height: 52px; cursor: pointer; | |
| 179 | + transition: box-shadow 0.15s ease; | |
| 180 | +} | |
| 181 | +.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--lime); } | |
| 182 | +.f-ctl > span { | |
| 183 | + font-family: var(--font-mono); font-size: 9px; font-weight: 700; | |
| 184 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); | |
| 185 | +} | |
| 186 | +.f-ctl select { | |
| 187 | + border: none; background: transparent; outline: none; font-family: var(--font-display); | |
| 188 | + font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer; | |
| 189 | + appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px; | |
| 190 | + text-overflow: ellipsis; | |
| 191 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E"); | |
| 192 | + background-repeat: no-repeat; background-position: right center; | |
| 193 | +} | |
| 194 | +.range-pair { display: flex; align-items: center; gap: 4px; } | |
| 195 | +.range-pair select { max-width: 86px; } | |
| 196 | +.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; } | |
| 197 | +.f-more { | |
| 198 | + flex: none; align-self: stretch; border: 1.5px solid var(--ink); border-radius: 9px; | |
| 199 | + background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer; | |
| 200 | + font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px; | |
| 201 | + transition: all 0.13s ease; | |
| 202 | +} | |
| 203 | +.f-more:hover { background: var(--lime-soft); } | |
| 204 | +.f-more.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.22); } | |
| 205 | + | |
| 206 | +/* — panneau avancé — */ | |
| 207 | +.f-adv { | |
| 208 | + display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px; | |
| 209 | + border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0; | |
| 210 | + animation: adv-in 0.18s ease; | |
| 211 | +} | |
| 212 | +@keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } | |
| 213 | +.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; } | |
| 214 | +.f-group > label { | |
| 215 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 216 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); | |
| 217 | +} | |
| 218 | +.f-group-end { justify-content: flex-end; } | |
| 219 | +.f-group .btn:disabled { opacity: 0.4; cursor: default; } | |
| 220 | +.f-native { | |
| 221 | + border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl); | |
| 222 | + padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none; | |
| 223 | + font-family: inherit; min-height: 42px; width: 100%; min-width: 0; | |
| 224 | + appearance: none; -webkit-appearance: none; | |
| 225 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E"); | |
| 226 | + background-repeat: no-repeat; background-position: right 12px center; | |
| 227 | +} | |
| 228 | +.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); } | |
| 229 | + | |
| 230 | +/* segments (pilules soudées) */ | |
| 231 | +.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; } | |
| 232 | +.seg button { | |
| 233 | + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2); | |
| 234 | + padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px; | |
| 235 | + cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 38px; | |
| 236 | + transition: all 0.12s ease; | |
| 237 | +} | |
| 238 | +.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; } | |
| 239 | +.seg button:last-child { border-radius: 0 8px 8px 0; } | |
| 240 | +.seg button:hover { background: var(--lime-soft); color: var(--ink); } | |
| 241 | +.seg button.on { background: var(--ink); color: var(--lime); position: relative; z-index: 1; } | |
| 242 | + | |
| 243 | +/* pastilles de filtres actifs */ | |
| 244 | +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; } | |
| 245 | +.pill { | |
| 246 | + display: inline-flex; align-items: center; gap: 7px; | |
| 247 | + border: 1.5px solid var(--ink); background: var(--lime-soft); color: var(--ink); | |
| 248 | + border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; | |
| 249 | + font-family: var(--font-mono); cursor: pointer; transition: all 0.13s ease; | |
| 250 | +} | |
| 251 | +.pill:hover { background: var(--lime); } | |
| 252 | +.pill-x { font-size: 10px; opacity: 0.65; } | |
| 253 | +.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); } | |
| 254 | +.pill-clear:hover { background: var(--danger); color: #fff; } | |
| 255 | +.chip-sep { width: 1.5px; align-self: stretch; background: var(--line); margin: 4px 4px; flex: none; } | |
| 256 | +.link-btn { | |
| 257 | + background: none; border: none; padding: 0; color: var(--green); font: inherit; | |
| 258 | + text-decoration: underline; cursor: pointer; | |
| 259 | +} | |
| 260 | +.field { display: flex; flex-direction: column; gap: 5px; } | |
| 261 | +.field label { | |
| 262 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 263 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); padding-left: 2px; | |
| 264 | +} | |
| 265 | +.field input, .field select { | |
| 266 | + border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl); | |
| 267 | + padding: 11px 12px; font-size: 15px; color: var(--ink); outline: none; font-family: inherit; | |
| 268 | + transition: border-color 0.15s ease, box-shadow 0.15s ease; min-height: 44px; | |
| 269 | + appearance: none; -webkit-appearance: none; | |
| 270 | +} | |
| 271 | +.field select { | |
| 272 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E"); | |
| 273 | + background-repeat: no-repeat; background-position: right 12px center; padding-right: 30px; | |
| 274 | +} | |
| 275 | +.field input:focus, .field select:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); } | |
| 276 | +.btn { | |
| 277 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px; | |
| 278 | + font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px; | |
| 279 | + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease; | |
| 280 | + font-family: var(--font-display); letter-spacing: 0.01em; | |
| 281 | +} | |
| 282 | +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; } | |
| 283 | +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); } | |
| 284 | +.btn-primary:hover { background: var(--green-deep); } | |
| 285 | +.btn-ghost { background: transparent; color: var(--ink); } | |
| 286 | +.btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.2); } | |
| 287 | + | |
| 288 | +/* ================= Chips ================= */ | |
| 289 | +.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; } | |
| 290 | +.chips::-webkit-scrollbar { display: none; } | |
| 291 | +.chip { | |
| 292 | + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); | |
| 293 | + border-radius: 999px; padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; | |
| 294 | + font-family: var(--font-display); white-space: nowrap; min-height: 40px; | |
| 295 | + transition: all 0.13s ease; | |
| 296 | +} | |
| 297 | +.chip:hover { background: var(--lime-soft); transform: translateY(-1px); } | |
| 298 | +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.2); } | |
| 299 | + | |
| 300 | +/* ================= Results ================= */ | |
| 301 | +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; } | |
| 302 | +.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; } | |
| 303 | +.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; } | |
| 304 | +.grid { | |
| 305 | + display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); | |
| 306 | + gap: 22px; padding-bottom: 70px; | |
| 307 | +} | |
| 308 | +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 48px; } } | |
| 309 | + | |
| 310 | +.card { | |
| 311 | + background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-card); | |
| 312 | + overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat); | |
| 313 | + transition: transform 0.16s ease, box-shadow 0.16s ease; | |
| 314 | +} | |
| 315 | +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); } | |
| 316 | +.card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; } | |
| 317 | +.card-img { position: relative; aspect-ratio: 16/10.5; background: repeating-linear-gradient(45deg, #eceae3 0 12px, #f3f1ea 12px 24px); overflow: hidden; } | |
| 318 | +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; } | |
| 319 | +.card:hover .card-img img { transform: scale(1.05); } | |
| 320 | +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; } | |
| 321 | +.badge { | |
| 322 | + position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px; | |
| 323 | + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; | |
| 324 | + background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink); | |
| 325 | +} | |
| 326 | +.badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); } | |
| 327 | +.badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; } | |
| 328 | +.card-body { padding: 16px 18px 16px; display: flex; flex-direction: column; gap: 6px; flex: 1; } | |
| 329 | +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; } | |
| 330 | +.card-price small { font-family: var(--font-mono); font-weight: 500; color: var(--ink-3); font-size: 11px; letter-spacing: 0.05em; } | |
| 331 | +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); } | |
| 332 | +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; font-family: var(--font-mono); } | |
| 333 | +.card-meta .sep { width: 4px; height: 4px; background: var(--lime); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); } | |
| 334 | +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1.5px dashed var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; } | |
| 335 | +.source-tag { | |
| 336 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; | |
| 337 | + letter-spacing: 0.08em; color: var(--green-deep); background: var(--lime-soft); | |
| 338 | + border: 1px solid var(--green); border-radius: 4px; padding: 3px 8px; | |
| 339 | + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; | |
| 340 | +} | |
| 341 | +.avail { font-size: 11.5px; color: var(--ink-2); font-weight: 500; text-align: right; } | |
| 342 | + | |
| 343 | +/* ================= Skeletons ================= */ | |
| 344 | +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } } | |
| 345 | +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); } | |
| 346 | +.skel .sk-img, .skel .sk-line { | |
| 347 | + background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%); | |
| 348 | + background-size: 800px 100%; animation: shimmer 1.4s infinite linear; | |
| 349 | +} | |
| 350 | +.skel .sk-img { aspect-ratio: 16/10.5; } | |
| 351 | +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; } | |
| 352 | +.skel .sk-line.short { width: 45%; } | |
| 353 | + | |
| 354 | +/* ================= Empty / error ================= */ | |
| 355 | +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); } | |
| 356 | +.notice .big { font-size: 44px; margin-bottom: 10px; } | |
| 357 | +.notice h2 { text-transform: uppercase; } | |
| 358 | + | |
| 359 | +/* ================= Detail page ================= */ | |
| 360 | +.detail { padding: 30px 0 90px; } | |
| 361 | +.crumbs { | |
| 362 | + font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; | |
| 363 | + color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; | |
| 364 | +} | |
| 365 | +.crumbs a { border-bottom: 1.5px solid transparent; } | |
| 366 | +.crumbs a:hover { color: var(--green); border-color: var(--green); } | |
| 367 | +.detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; } | |
| 368 | +@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } } | |
| 369 | +/* les enfants de grille ont min-width:auto par défaut : un contenu large | |
| 370 | + (grille du quartier) déborderait de l'écran mobile sans ceci */ | |
| 371 | +.detail-grid > * { min-width: 0; } | |
| 372 | + | |
| 373 | +.gallery { display: flex; flex-direction: column; gap: 10px; } | |
| 374 | +.gallery-main { | |
| 375 | + border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--ink); | |
| 376 | + aspect-ratio: 16/10; background: #eceae3; cursor: zoom-in; box-shadow: var(--shadow-off-soft); | |
| 377 | +} | |
| 378 | +.gallery-main img { width: 100%; height: 100%; object-fit: cover; } | |
| 379 | +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; } | |
| 380 | +.thumbs button { | |
| 381 | + border: 2px solid var(--line); border-radius: 8px; overflow: hidden; padding: 0; cursor: pointer; | |
| 382 | + aspect-ratio: 4/3; background: #eceae3; transition: border-color 0.12s ease, transform 0.12s ease; | |
| 383 | +} | |
| 384 | +.thumbs button:hover { transform: translateY(-2px); } | |
| 385 | +.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); } | |
| 386 | +.thumbs img { width: 100%; height: 100%; object-fit: cover; } | |
| 387 | + | |
| 388 | +.panel { | |
| 389 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 390 | + box-shadow: var(--shadow-off-soft); padding: 26px; position: sticky; top: 120px; | |
| 391 | +} | |
| 392 | +@media (max-width: 900px) { .panel { position: static; } } | |
| 393 | +.panel .price { font-family: var(--font-display); font-size: 34px; font-weight: 700; letter-spacing: -0.03em; } | |
| 394 | +.panel .price small { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); font-weight: 500; letter-spacing: 0.05em; } | |
| 395 | +.panel h1 { font-size: 22px; margin: 8px 0 2px; } | |
| 396 | +.panel .loc { color: var(--ink-2); font-size: 14px; } | |
| 397 | +.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; } | |
| 398 | +@media (max-width: 380px) { .kv { grid-template-columns: 1fr; } } | |
| 399 | +.kv .cell { background: var(--surface-2); border: 1.5px solid var(--line); border-radius: var(--r-ctl); padding: 10px 13px; } | |
| 400 | +.kv .cell .k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; } | |
| 401 | +.kv .cell .v { font-weight: 700; font-size: 14.5px; margin-top: 2px; font-family: var(--font-display); } | |
| 402 | +.klabel { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; margin-bottom: 8px; } | |
| 403 | +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 20px; } | |
| 404 | +.amenity { | |
| 405 | + background: var(--lime-soft); color: var(--green-deep); font-size: 12px; font-weight: 600; | |
| 406 | + border: 1px solid var(--green); border-radius: 999px; padding: 5px 12px; | |
| 407 | +} | |
| 408 | +.cta { | |
| 409 | + display: block; text-align: center; background: var(--ink); color: var(--lime); | |
| 410 | + font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); | |
| 411 | + padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; | |
| 412 | + box-shadow: 4px 4px 0 rgba(20,24,20,0.25); transition: all 0.14s ease; | |
| 413 | +} | |
| 414 | +.cta:hover { background: var(--lime); color: var(--ink); } | |
| 415 | +.cta:active { transform: translate(2px, 2px); box-shadow: none; } | |
| 416 | +.panel .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; } | |
| 417 | + | |
| 418 | +/* ================= Lightbox ================= */ | |
| 419 | +.lightbox { | |
| 420 | + position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: 100; | |
| 421 | + display: flex; align-items: center; justify-content: center; cursor: zoom-out; | |
| 422 | + padding: max(16px, env(safe-area-inset-top)) 16px; | |
| 423 | +} | |
| 424 | +.lightbox img { max-width: 94vw; max-height: 90vh; border-radius: 6px; border: 2px solid var(--lime); } | |
| 425 | + | |
| 426 | +/* ================= Sources page ================= */ | |
| 427 | +.sources { padding: 44px 0 90px; } | |
| 428 | +.sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; } | |
| 429 | +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 700px; } | |
| 430 | +.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); } | |
| 431 | +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 720px; } | |
| 432 | +.src-table th { | |
| 433 | + text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; | |
| 434 | + letter-spacing: 0.12em; color: var(--ink-3); padding: 14px 18px; | |
| 435 | + border-bottom: 1.5px solid var(--ink); background: var(--surface-2); | |
| 436 | +} | |
| 437 | +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; } | |
| 438 | +.src-table tr:last-child td { border-bottom: none; } | |
| 439 | +.src-table tr:hover td { background: var(--surface-2); } | |
| 440 | +.src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); } | |
| 441 | +.pill { display: inline-block; border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; } | |
| 442 | +.pill.ok { background: var(--lime); color: var(--ink); border: 1px solid var(--ink); } | |
| 443 | +.pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); } | |
| 444 | +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; } | |
| 445 | + | |
| 446 | +/* ================= Page Statistiques ================= */ | |
| 447 | +.stats-page { padding: 44px 0 90px; } | |
| 448 | +.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; } | |
| 449 | +.stats-page .sub { color: var(--ink-2); max-width: 640px; margin-bottom: 30px; } | |
| 450 | + | |
| 451 | +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; } | |
| 452 | +.tile { | |
| 453 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 454 | + padding: 18px 20px; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); | |
| 455 | +} | |
| 456 | +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.03em; line-height: 1.05; } | |
| 457 | +.tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; } | |
| 458 | +.hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); } | |
| 459 | +.hero-tile .tile-k { color: rgba(217, 242, 107, 0.7); } | |
| 460 | + | |
| 461 | +.viz-card { | |
| 462 | + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 463 | + box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px; | |
| 464 | +} | |
| 465 | +.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; } | |
| 466 | +.viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; } | |
| 467 | +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; } | |
| 468 | +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } } | |
| 469 | + | |
| 470 | +/* Histogramme (mono-série, encre verte) */ | |
| 471 | +.histo { display: flex; align-items: stretch; gap: 2px; height: 240px; } | |
| 472 | +.histo-col { flex: 1; display: flex; flex-direction: column; min-width: 0; cursor: default; } | |
| 473 | +.histo-bar-zone { flex: 1; display: flex; align-items: flex-end; border-bottom: 1.5px solid var(--ink); } | |
| 474 | +.histo-bar { | |
| 475 | + width: 100%; background: var(--green); border-radius: 4px 4px 0 0; | |
| 476 | + min-height: 2px; transition: background 0.12s ease; | |
| 477 | +} | |
| 478 | +.histo-col:hover .histo-bar { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); } | |
| 479 | +.histo-x { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3); text-align: left; height: 18px; padding-top: 5px; overflow: visible; white-space: nowrap; } | |
| 480 | + | |
| 481 | +/* Barres horizontales (mono-série) */ | |
| 482 | +.hbars { display: flex; flex-direction: column; gap: 7px; } | |
| 483 | +.hbar-row { | |
| 484 | + display: grid; grid-template-columns: minmax(96px, 170px) 1fr auto; | |
| 485 | + gap: 12px; align-items: center; min-height: 26px; cursor: default; | |
| 486 | +} | |
| 487 | +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 488 | +.hbar-label a { border-bottom: 1.5px solid var(--lime); } | |
| 489 | +.hbar-label a:hover { color: var(--green-deep); } | |
| 490 | +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; } | |
| 491 | +.hbar-fill { | |
| 492 | + display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0; | |
| 493 | + min-width: 2px; transition: background 0.12s ease; | |
| 494 | +} | |
| 495 | +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); } | |
| 496 | +.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; } | |
| 497 | +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); } | |
| 498 | +@media (max-width: 640px) { | |
| 499 | + .hbar-row { grid-template-columns: minmax(80px, 110px) 1fr auto; gap: 8px; } | |
| 500 | + .hbar-value em { display: none; } | |
| 501 | + .viz-card { padding: 20px 16px 14px; } | |
| 502 | + .histo { height: 170px; } | |
| 503 | +} | |
| 504 | + | |
| 505 | +/* Infobulle */ | |
| 506 | +.viz-tip { | |
| 507 | + position: fixed; z-index: 120; pointer-events: none; max-width: 180px; | |
| 508 | + background: var(--ink); color: var(--paper); border-radius: 8px; | |
| 509 | + border: 1px solid var(--lime); padding: 9px 12px; font-size: 12px; line-height: 1.5; | |
| 510 | + box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35); | |
| 511 | +} | |
| 512 | +.viz-tip-title { font-family: var(--font-display); font-weight: 700; color: var(--lime); margin-bottom: 2px; } | |
| 513 | + | |
| 514 | +/* Vue tableau repliable (accessibilité / relief) */ | |
| 515 | +.viz-table { margin-top: 16px; border-top: 1.5px dashed var(--line); padding-top: 10px; } | |
| 516 | +.viz-table summary { | |
| 517 | + cursor: pointer; font-family: var(--font-mono); font-size: 11px; font-weight: 700; | |
| 518 | + text-transform: uppercase; letter-spacing: 0.08em; color: var(--green-deep); | |
| 519 | +} | |
| 520 | +.viz-table table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13px; } | |
| 521 | +.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); } | |
| 522 | +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); } | |
| 523 | + | |
| 524 | +.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; } | |
| 525 | + | |
| 526 | +/* ================= Mobile : feuille de filtres + FAB ================= */ | |
| 527 | +.sheet-head { display: none; } | |
| 528 | +.sheet-apply { display: none; } | |
| 529 | +.sheet-backdrop { display: none; } | |
| 530 | +.fab { display: none; } | |
| 531 | + | |
| 532 | +@media (max-width: 640px) { | |
| 533 | + /* En-tête compact */ | |
| 534 | + .header-inner { height: 56px; } | |
| 535 | + .brand { font-size: 21px; } | |
| 536 | + .nav a { padding: 8px 13px; font-size: 13.5px; } | |
| 537 | + .ticker { font-size: 10.5px; padding: 6px 0; } | |
| 538 | + | |
| 539 | + /* Héro resserré + stats en rangée défilante */ | |
| 540 | + .hero h1 { font-size: clamp(30px, 9.4vw, 44px); } | |
| 541 | + .hero p.lede { font-size: 15px; } | |
| 542 | + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; } | |
| 543 | + .stat-row::-webkit-scrollbar { display: none; } | |
| 544 | + .stat-chip { flex: 0 0 auto; white-space: nowrap; } | |
| 545 | + | |
| 546 | + /* La barre de filtres devient une feuille coulissante (bottom sheet) */ | |
| 547 | + .filterbar { display: none; } | |
| 548 | + .filterbar.open .f-primary { flex-direction: column; } | |
| 549 | + .filterbar.open .f-ctl select { max-width: none; width: 100%; } | |
| 550 | + .filterbar.open .f-more { display: none; } | |
| 551 | + .filterbar.open .f-adv { margin-top: 4px; } | |
| 552 | + .filterbar.open { | |
| 553 | + display: flex; flex-direction: column; gap: 12px; | |
| 554 | + position: fixed; left: 0; right: 0; bottom: 0; z-index: 95; | |
| 555 | + margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0; | |
| 556 | + max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; | |
| 557 | + padding: 16px 18px calc(18px + env(safe-area-inset-bottom)); | |
| 558 | + box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35); | |
| 559 | + animation: sheet-up 0.22s ease; | |
| 560 | + } | |
| 561 | + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } } | |
| 562 | + .filterbar.open .sheet-head { | |
| 563 | + display: flex; justify-content: space-between; align-items: center; | |
| 564 | + font-family: var(--font-display); font-weight: 700; font-size: 17px; | |
| 565 | + text-transform: uppercase; letter-spacing: -0.01em; | |
| 566 | + position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px; | |
| 567 | + border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1; | |
| 568 | + } | |
| 569 | + .sheet-close { | |
| 570 | + border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%; | |
| 571 | + width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; | |
| 572 | + } | |
| 573 | + .filterbar.open .sheet-apply { display: block; width: 100%; } | |
| 574 | + .sheet-backdrop { | |
| 575 | + display: block; position: fixed; inset: 0; z-index: 90; | |
| 576 | + background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px); | |
| 577 | + } | |
| 578 | + | |
| 579 | + /* Bouton flottant */ | |
| 580 | + .fab { | |
| 581 | + display: flex; align-items: center; gap: 6px; | |
| 582 | + position: fixed; left: 50%; transform: translateX(-50%); | |
| 583 | + bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; | |
| 584 | + background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); | |
| 585 | + border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); | |
| 586 | + font-weight: 700; font-size: 15px; cursor: pointer; | |
| 587 | + box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(20, 24, 20, 0.25); | |
| 588 | + } | |
| 589 | + .fab:active { transform: translateX(-50%) scale(0.97); } | |
| 590 | + | |
| 591 | + /* Fiche logement : vignettes en bande défilante + panneau non collant */ | |
| 592 | + .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; padding-bottom: 4px; } | |
| 593 | + .thumbs::-webkit-scrollbar { display: none; } | |
| 594 | + .thumbs button { flex: 0 0 96px; } | |
| 595 | + .detail { padding-top: 20px; } | |
| 596 | + .panel { padding: 20px; } | |
| 597 | + .panel .price { font-size: 28px; } | |
| 598 | + .results-head { margin-top: 20px; } | |
| 599 | + .chips { margin-right: -16px; padding-right: 16px; } | |
| 600 | + .notice { padding: 48px 16px; } | |
| 601 | +} | |
| 602 | + | |
| 603 | +/* Anti-zoom iOS : les champs doivent faire >= 16px */ | |
| 604 | +@media (max-width: 900px) { | |
| 605 | + .field input, .field select { font-size: 16px; } | |
| 606 | +} | |
| 607 | + | |
| 608 | +/* ================= Footer ================= */ | |
| 609 | +.footer { background: var(--ink); color: rgba(245, 243, 238, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; } | |
| 610 | +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 34px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 12px; } | |
| 611 | +.footer .fbrand .ka { color: var(--lime); } | |
| 612 | +.footer b { color: var(--paper); } | |
| 613 | +.footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 720px; } | |
| 614 | +.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(245,243,238,0.45); margin-top: 18px; } | |
| 615 | + | |
| 616 | +/* --- Carte interactive (mission 4) --------------------------------------- */ | |
| 617 | +.results-tools { display: flex; align-items: center; gap: 14px; } | |
| 618 | +.view-toggle { | |
| 619 | + display: inline-flex; border: 1.5px solid var(--line-strong); | |
| 620 | + border-radius: var(--r-ctl); overflow: hidden; background: var(--surface); | |
| 621 | + box-shadow: var(--shadow-flat); | |
| 622 | +} | |
| 623 | +.view-toggle button { | |
| 624 | + border: 0; background: transparent; padding: 7px 14px; cursor: pointer; | |
| 625 | + font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); | |
| 626 | +} | |
| 627 | +.view-toggle button + button { border-left: 1.5px solid var(--line-strong); } | |
| 628 | +.view-toggle button.on { background: var(--ink); color: var(--lime); } | |
| 629 | + | |
| 630 | +.map-split { | |
| 631 | + display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px; | |
| 632 | + height: calc(100dvh - 170px); min-height: 420px; | |
| 633 | +} | |
| 634 | +.map-list { | |
| 635 | + overflow-y: auto; display: flex; flex-direction: column; gap: 14px; | |
| 636 | + padding-right: 4px; scrollbar-width: thin; | |
| 637 | +} | |
| 638 | +.map-list .card { margin: 0; flex: 0 0 auto; } | |
| 639 | +.mapview { | |
| 640 | + width: 100%; height: 100%; border: 1.5px solid var(--line-strong); | |
| 641 | + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); | |
| 642 | + overflow: hidden; background: var(--surface-2); | |
| 643 | +} | |
| 644 | +.map-loading { | |
| 645 | + display: flex; align-items: center; justify-content: center; | |
| 646 | + color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; | |
| 647 | +} | |
| 648 | + | |
| 649 | +/* mobile d'abord : carte plein écran, liste masquée (bascule via l'onglet) */ | |
| 650 | +@media (max-width: 780px) { | |
| 651 | + .map-split { grid-template-columns: 1fr; height: calc(100dvh - 210px); } | |
| 652 | + .map-list { display: none; } | |
| 653 | + .results-tools { width: 100%; justify-content: space-between; } | |
| 654 | +} | |
| 655 | + | |
| 656 | +/* popup MapLibre au style Forma-Ka */ | |
| 657 | +.maplibregl-popup-content { | |
| 658 | + padding: 0; border-radius: var(--r-card); overflow: hidden; | |
| 659 | + border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off); | |
| 660 | + font-family: var(--font-body); | |
| 661 | +} | |
| 662 | +.maplibregl-popup-close-button { | |
| 663 | + font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; | |
| 664 | + text-shadow: 0 0 4px rgba(20, 24, 20, 0.8); | |
| 665 | +} | |
| 666 | +.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; } | |
| 667 | +.mv-noimg { display: flex; align-items: center; justify-content: center; | |
| 668 | + font-size: 34px; background: var(--surface-2); } | |
| 669 | +.mv-pop-body { padding: 10px 12px 12px; } | |
| 670 | +.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; } | |
| 671 | +.mv-pop-price small { font-weight: 400; color: var(--ink-3); font-size: 12px; } | |
| 672 | +.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px; | |
| 673 | + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 674 | +.mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; } | |
| 675 | +.mv-pop-src { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); | |
| 676 | + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; } | |
| 677 | +.mv-pop-cta { | |
| 678 | + display: block; margin-top: 10px; padding: 8px 10px; text-align: center; | |
| 679 | + background: var(--ink); color: var(--lime); border-radius: var(--r-ctl); | |
| 680 | + font-weight: 600; font-size: 13px; | |
| 681 | +} | |
| 682 | +.mv-pop-cta:hover { background: var(--green-deep); } | |
| 683 | + | |
| 684 | +/* --- Commodités de proximité (fiche) -------------------------------------- */ | |
| 685 | +.poi-list { | |
| 686 | + list-style: none; margin: 0; padding: 0; | |
| 687 | + display: flex; flex-direction: column; gap: 2px; | |
| 688 | +} | |
| 689 | +.poi-list li { | |
| 690 | + display: flex; align-items: center; gap: 9px; | |
| 691 | + padding: 5px 2px; border-bottom: 1px dashed var(--line); | |
| 692 | + font-size: 13px; | |
| 693 | +} | |
| 694 | +.poi-list li:last-child { border-bottom: 0; } | |
| 695 | +.poi-ico { width: 20px; text-align: center; flex: 0 0 auto; } | |
| 696 | +.poi-name { flex: 1; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 697 | +.poi-dist { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); font-weight: 600; flex: 0 0 auto; } | |
| 698 | + | |
| 699 | +/* --- Menu déroulant mobile ------------------------------------------------- */ | |
| 700 | +.menu-btn { | |
| 701 | + display: none; position: relative; z-index: 60; | |
| 702 | + width: 44px; height: 44px; margin-left: auto; | |
| 703 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 704 | + background: var(--surface); cursor: pointer; padding: 0; | |
| 705 | + flex-direction: column; align-items: center; justify-content: center; gap: 5px; | |
| 706 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15); | |
| 707 | + transition: box-shadow 0.15s ease, transform 0.15s ease; | |
| 708 | +} | |
| 709 | +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(20,24,20,0.15); } | |
| 710 | +.menu-btn span { | |
| 711 | + display: block; width: 18px; height: 2px; background: var(--ink); | |
| 712 | + border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; | |
| 713 | +} | |
| 714 | +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); } | |
| 715 | +.menu-btn.open span:nth-child(2) { opacity: 0; } | |
| 716 | +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } | |
| 717 | + | |
| 718 | +.mobile-menu { | |
| 719 | + display: none; position: absolute; left: 0; right: 0; top: 100%; | |
| 720 | + background: var(--paper); border-bottom: 2px solid var(--ink); | |
| 721 | + box-shadow: 0 18px 30px rgba(20, 24, 20, 0.18); | |
| 722 | + padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); | |
| 723 | + max-height: 0; overflow: hidden; opacity: 0; | |
| 724 | + transition: max-height 0.3s ease, opacity 0.22s ease; | |
| 725 | +} | |
| 726 | +.mobile-menu.open { max-height: 480px; opacity: 1; } | |
| 727 | +.mm-link { | |
| 728 | + display: flex; align-items: center; gap: 12px; | |
| 729 | + padding: 15px 10px; min-height: 52px; | |
| 730 | + font-family: var(--font-display); font-weight: 700; font-size: 19px; | |
| 731 | + letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line); | |
| 732 | + opacity: 0; transform: translateY(-8px); | |
| 733 | + transition: opacity 0.25s ease, transform 0.25s ease; | |
| 734 | +} | |
| 735 | +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); } | |
| 736 | +.mm-link.active { color: var(--green); } | |
| 737 | +.mm-link.active .mm-arrow { opacity: 1; } | |
| 738 | +.mm-ico { width: 26px; text-align: center; font-size: 17px; } | |
| 739 | +.mm-arrow { margin-left: auto; opacity: 0.25; transition: opacity 0.15s ease; } | |
| 740 | +.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); } | |
| 741 | +.mm-foot { | |
| 742 | + padding: 12px 10px 4px; font-family: var(--font-mono); | |
| 743 | + font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; | |
| 744 | +} | |
| 745 | +.mm-backdrop { | |
| 746 | + position: fixed; inset: 0; z-index: 40; | |
| 747 | + background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px); | |
| 748 | +} | |
| 749 | +@media (max-width: 760px) { | |
| 750 | + .menu-btn { display: flex; } | |
| 751 | + .nav { display: none; } | |
| 752 | + .mobile-menu { display: block; } | |
| 753 | +} | |
| 754 | + | |
| 755 | +/* --- Bandeau de consentement (témoins) ------------------------------------- */ | |
| 756 | +.cookie-banner { | |
| 757 | + position: fixed; left: 12px; right: 12px; | |
| 758 | + bottom: calc(12px + env(safe-area-inset-bottom)); z-index: 80; | |
| 759 | + animation: cookie-in 0.35s ease; | |
| 760 | +} | |
| 761 | +@keyframes cookie-in { from { transform: translateY(24px); opacity: 0; } } | |
| 762 | +.cookie-inner { | |
| 763 | + max-width: 720px; margin: 0 auto; background: var(--surface); | |
| 764 | + border: 2px solid var(--ink); border-radius: var(--r-card); | |
| 765 | + box-shadow: var(--shadow-off); padding: 16px 18px; | |
| 766 | +} | |
| 767 | +.cookie-text { font-size: 13.5px; color: var(--ink-2); line-height: 1.5; } | |
| 768 | +.cookie-text b { color: var(--ink); } | |
| 769 | +.cookie-text a { text-decoration: underline; text-underline-offset: 2px; } | |
| 770 | +.cookie-options { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } | |
| 771 | +.cookie-opt { | |
| 772 | + display: flex; align-items: flex-start; gap: 10px; font-size: 13px; | |
| 773 | + color: var(--ink-2); cursor: pointer; | |
| 774 | +} | |
| 775 | +.cookie-opt input { margin-top: 2px; accent-color: var(--green); width: 16px; height: 16px; } | |
| 776 | +.cookie-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; justify-content: flex-end; } | |
| 777 | +@media (max-width: 640px) { | |
| 778 | + .cookie-actions { justify-content: stretch; } | |
| 779 | + .cookie-actions .btn { flex: 1; text-align: center; justify-content: center; } | |
| 780 | +} | |
| 781 | + | |
| 782 | +/* --- Page confidentialité + lien pied de page ------------------------------- */ | |
| 783 | +.legal { padding: 48px 0 70px; max-width: 760px; } | |
| 784 | +.legal h1 { font-size: clamp(30px, 5vw, 46px); margin: 12px 0 8px; letter-spacing: -0.03em; } | |
| 785 | +.legal section { margin-top: 26px; } | |
| 786 | +.legal h3 { font-size: 18px; margin-bottom: 8px; } | |
| 787 | +.legal p, .legal li { color: var(--ink-2); font-size: 14.5px; } | |
| 788 | +.legal ul { padding-left: 20px; display: flex; flex-direction: column; gap: 6px; } | |
| 789 | +.flink { | |
| 790 | + border: 0; background: none; padding: 0; cursor: pointer; | |
| 791 | + color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 2px; | |
| 792 | +} | |
| 793 | + | |
| 794 | +/* --- Section « Le quartier » (fiche) --------------------------------------- */ | |
| 795 | +.quartier { margin-top: 34px; } | |
| 796 | +.quartier h2 { font-size: 24px; letter-spacing: -0.02em; } | |
| 797 | +.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; } | |
| 798 | +.q-grid { | |
| 799 | + display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; | |
| 800 | +} | |
| 801 | +@media (max-width: 640px) { .q-grid { grid-template-columns: repeat(2, 1fr); } } | |
| 802 | +.q-cell { | |
| 803 | + background: var(--surface); border: 1.5px solid var(--line-strong); | |
| 804 | + border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat); | |
| 805 | +} | |
| 806 | +.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; } | |
| 807 | +.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); | |
| 808 | + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; } | |
| 809 | +.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; } | |
| 810 | +.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; } | |
| 811 | +.q-bar-label { flex: 0 0 150px; color: var(--ink-2); } | |
| 812 | +@media (max-width: 640px) { .q-bar-label { flex-basis: 120px; font-size: 12px; } } | |
| 813 | +.q-bar-track { | |
| 814 | + flex: 1; height: 10px; background: var(--surface-2); | |
| 815 | + border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden; | |
| 816 | +} | |
| 817 | +.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; } | |
| 818 | +.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono); | |
| 819 | + font-size: 11.5px; font-weight: 700; } | |
| 820 | +.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } | |
| 821 | +.q-badge { | |
| 822 | + display: inline-flex; align-items: center; gap: 6px; | |
| 823 | + border: 1.5px solid var(--line-strong); border-radius: 999px; | |
| 824 | + padding: 7px 13px; font-size: 12.5px; background: var(--surface); | |
| 825 | +} | |
| 826 | +.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); } | |
| 827 | +.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; } | |
| 828 | +.quartier .fine { margin-top: 10px; } | |
| 829 | + | |
| 830 | +/* --- Fiche v2 (mobile-first) ------------------------------------------------ */ | |
| 831 | +/* mobile : flux unique ordonné ; desktop : 2 colonnes (logement | synthèse) */ | |
| 832 | +.fiche { display: flex; flex-direction: column; gap: 22px; } | |
| 833 | +.f-col { display: contents; } | |
| 834 | +.f-galerie { order: 1; } .f-hero { order: 2; } .f-desc { order: 3; } | |
| 835 | +.f-incl { order: 4; } .f-pratique { order: 5; } .f-quartier { order: 6; } | |
| 836 | +.f-poi { order: 7; } | |
| 837 | +@media (min-width: 900px) { | |
| 838 | + .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; } | |
| 839 | + .f-col { display: flex; flex-direction: column; gap: 26px; min-width: 0; } | |
| 840 | +} | |
| 841 | +.f-bloc { min-width: 0; } | |
| 842 | +.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 10px; } | |
| 843 | +.f-bloc:empty { display: none; } | |
| 844 | + | |
| 845 | +/* galerie à balayage natif */ | |
| 846 | +.carousel { position: relative; border-radius: var(--r-card); overflow: hidden; | |
| 847 | + border: 1.5px solid var(--line-strong); background: var(--surface-2); } | |
| 848 | +.carousel-track { | |
| 849 | + display: flex; overflow-x: auto; scroll-snap-type: x mandatory; | |
| 850 | + -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 16/11; | |
| 851 | +} | |
| 852 | +.carousel-track::-webkit-scrollbar { display: none; } | |
| 853 | +.carousel-track img { | |
| 854 | + flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center; | |
| 855 | + cursor: zoom-in; | |
| 856 | +} | |
| 857 | +.carousel-empty { display: flex; align-items: center; justify-content: center; | |
| 858 | + aspect-ratio: 16/11; font-size: 48px; } | |
| 859 | +.carousel-count { | |
| 860 | + position: absolute; right: 12px; bottom: 12px; z-index: 2; | |
| 861 | + background: rgba(20, 24, 20, 0.82); color: var(--lime); | |
| 862 | + font-family: var(--font-mono); font-size: 12px; font-weight: 700; | |
| 863 | + padding: 4px 10px; border-radius: 999px; | |
| 864 | +} | |
| 865 | +.carousel-nav { | |
| 866 | + position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; | |
| 867 | + width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink); | |
| 868 | + background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer; | |
| 869 | + display: flex; align-items: center; justify-content: center; line-height: 1; | |
| 870 | +} | |
| 871 | +.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; } | |
| 872 | +@media (max-width: 640px) { .carousel-nav { display: none; } } /* balayage natif */ | |
| 873 | + | |
| 874 | +/* bandeau prix + badge marché */ | |
| 875 | +.f-hero .price { font-size: clamp(34px, 8vw, 44px); } | |
| 876 | +.deal-badge { | |
| 877 | + display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 4px; | |
| 878 | + border-radius: 999px; padding: 7px 14px; font-size: 13px; font-weight: 600; | |
| 879 | + border: 1.5px solid var(--line-strong); | |
| 880 | +} | |
| 881 | +.deal-good { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); } | |
| 882 | +.deal-ok { background: var(--surface); color: var(--ink-2); } | |
| 883 | +.deal-high { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; } | |
| 884 | +.f-hero h1 { font-size: clamp(20px, 4.5vw, 26px); margin-top: 8px; } | |
| 885 | + | |
| 886 | +/* chips clés à défilement horizontal */ | |
| 887 | +.chips-scroll { | |
| 888 | + display: flex; gap: 8px; overflow-x: auto; padding: 12px 0 6px; | |
| 889 | + scrollbar-width: none; -webkit-overflow-scrolling: touch; | |
| 890 | +} | |
| 891 | +.chips-scroll::-webkit-scrollbar { display: none; } | |
| 892 | +.chip-key { | |
| 893 | + flex: 0 0 auto; background: var(--ink); color: var(--lime); | |
| 894 | + border-radius: 999px; padding: 8px 14px; font-family: var(--font-mono); | |
| 895 | + font-size: 12px; font-weight: 700; white-space: nowrap; min-height: 34px; | |
| 896 | + display: inline-flex; align-items: center; | |
| 897 | +} | |
| 898 | + | |
| 899 | +/* ancres de navigation rapide */ | |
| 900 | +.ancres { | |
| 901 | + display: flex; gap: 4px; overflow-x: auto; padding: 6px 0 2px; | |
| 902 | + scrollbar-width: none; border-bottom: 1.5px dashed var(--line); margin-bottom: 8px; | |
| 903 | +} | |
| 904 | +.ancres::-webkit-scrollbar { display: none; } | |
| 905 | +.ancres a { | |
| 906 | + flex: 0 0 auto; padding: 9px 12px; font-size: 13.5px; font-weight: 600; | |
| 907 | + color: var(--ink-2); border-radius: var(--r-ctl); min-height: 44px; | |
| 908 | + display: inline-flex; align-items: center; | |
| 909 | +} | |
| 910 | +.ancres a:hover { background: var(--lime-soft); color: var(--ink); } | |
| 911 | +html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */ | |
| 912 | + | |
| 913 | +/* description restructurée */ | |
| 914 | +.enbref { | |
| 915 | + background: var(--lime-soft); border: 1.5px solid var(--green); | |
| 916 | + border-radius: var(--r-card); padding: 12px 15px; color: var(--green-deep); | |
| 917 | + font-size: 15px; margin: 0 0 14px; | |
| 918 | +} | |
| 919 | +.desc-section { margin-bottom: 12px; } | |
| 920 | +.desc-section h4 { font-size: 15px; margin-bottom: 3px; } | |
| 921 | +.desc-section p { color: var(--ink-2); font-size: 14.5px; margin: 0; white-space: pre-line; } | |
| 922 | +.texte-original { margin-top: 12px; } | |
| 923 | +.texte-original summary { | |
| 924 | + cursor: pointer; font-family: var(--font-mono); font-size: 12px; | |
| 925 | + color: var(--ink-3); min-height: 44px; display: flex; align-items: center; | |
| 926 | +} | |
| 927 | +.texte-original p { color: var(--ink-3); font-size: 13px; white-space: pre-line; } | |
| 928 | + | |
| 929 | +/* inclusions : confirmées vs mentionnées */ | |
| 930 | +.amenity.confirmed { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); font-weight: 600; } | |
| 931 | +.amenity.unconfirmed { background: transparent; border-style: dashed; color: var(--ink-3); } | |
| 932 | + | |
| 933 | +/* détails pratiques : historique de prix */ | |
| 934 | +.prix-histo { | |
| 935 | + margin-top: 12px; padding: 10px 14px; border-radius: var(--r-card); | |
| 936 | + border: 1.5px solid var(--line-strong); font-size: 13.5px; background: var(--surface); | |
| 937 | +} | |
| 938 | +.prix-histo.down { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); } | |
| 939 | + | |
| 940 | +/* quartier compacté sur mobile */ | |
| 941 | +@media (max-width: 640px) { | |
| 942 | + .q-cell { padding: 9px 11px; } | |
| 943 | + .q-val { font-size: 16px; } | |
| 944 | + .q-bar { gap: 8px; font-size: 12px; } | |
| 945 | + .q-bar-track { height: 8px; } | |
| 946 | +} | |
| 947 | +.q-badge-sub { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-left: 4px; } | |
| 948 | + | |
| 949 | +/* POI groupés repliables */ | |
| 950 | +.poi-groupe { border: 1.5px solid var(--line); border-radius: var(--r-card); | |
| 951 | + margin-bottom: 8px; background: var(--surface); overflow: hidden; } | |
| 952 | +.poi-groupe summary { | |
| 953 | + display: flex; justify-content: space-between; align-items: center; gap: 10px; | |
| 954 | + padding: 12px 14px; cursor: pointer; font-weight: 600; font-size: 14px; | |
| 955 | + min-height: 48px; list-style: none; | |
| 956 | +} | |
| 957 | +.poi-groupe summary::-webkit-details-marker { display: none; } | |
| 958 | +.poi-groupe summary::after { content: "▾"; color: var(--ink-3); transition: transform 0.15s ease; } | |
| 959 | +.poi-groupe[open] summary::after { transform: rotate(180deg); } | |
| 960 | +.poi-resume { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); font-weight: 400; } | |
| 961 | +.poi-groupe .poi-list { padding: 0 14px 10px; } | |
| 962 | + | |
| 963 | +/* CTA sticky mobile */ | |
| 964 | +.cta-sticky { | |
| 965 | + position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; | |
| 966 | + display: flex; align-items: center; gap: 12px; | |
| 967 | + background: rgba(245, 243, 238, 0.94); backdrop-filter: blur(12px); | |
| 968 | + border-top: 2px solid var(--ink); | |
| 969 | + padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); | |
| 970 | +} | |
| 971 | +.cta-sticky .cta { flex: 1; margin: 0; text-align: center; } | |
| 972 | +.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; } | |
| 973 | +.cta-sticky-prix small { font-family: var(--font-mono); font-weight: 500; font-size: 10px; color: var(--ink-3); } | |
| 974 | +@media (min-width: 900px) { .cta-sticky { display: none; } } | |
| 975 | +@media (max-width: 899px) { | |
| 976 | + .cta-desktop { display: none; } | |
| 977 | + .detail { padding-bottom: 84px; } /* place pour le CTA sticky */ | |
| 978 | +} | |
| 979 | +.f-foot { margin-top: 26px; } | |
| 980 | + | |
| 981 | +/* --- Passerelle vers l'annonce originale ------------------------------------ */ | |
| 982 | +.passerelle { | |
| 983 | + min-height: calc(100dvh - 120px); display: flex; align-items: center; | |
| 984 | + justify-content: center; padding: 24px 16px; | |
| 985 | +} | |
| 986 | +.pass-card { | |
| 987 | + width: 100%; max-width: 520px; background: var(--surface); | |
| 988 | + border: 2px solid var(--ink); border-radius: var(--r-card); | |
| 989 | + box-shadow: var(--shadow-off); padding: 30px 28px; text-align: center; | |
| 990 | +} | |
| 991 | +.pass-brands { | |
| 992 | + display: flex; align-items: center; justify-content: center; gap: 14px; | |
| 993 | + margin-bottom: 22px; flex-wrap: wrap; | |
| 994 | +} | |
| 995 | +.pass-brand { | |
| 996 | + font-family: var(--font-display); font-weight: 700; font-size: 24px; | |
| 997 | + letter-spacing: -0.04em; display: inline-flex; align-items: center; | |
| 998 | +} | |
| 999 | +.pass-brand .ka { | |
| 1000 | + background: var(--ink); color: var(--lime); padding: 2px 7px 4px; | |
| 1001 | + border-radius: 6px; margin-left: 3px; transform: rotate(-2deg); | |
| 1002 | +} | |
| 1003 | +.pass-trajet { display: inline-flex; align-items: center; gap: 5px; } | |
| 1004 | +.pass-dot { | |
| 1005 | + width: 6px; height: 6px; border-radius: 50%; background: var(--green); | |
| 1006 | + opacity: 0.25; animation: pass-dot 1.2s ease infinite; | |
| 1007 | +} | |
| 1008 | +.pass-dot:nth-child(2) { animation-delay: 0.2s; } | |
| 1009 | +.pass-dot:nth-child(3) { animation-delay: 0.4s; } | |
| 1010 | +@keyframes pass-dot { 40% { opacity: 1; transform: translateX(2px); } } | |
| 1011 | +.pass-fleche { color: var(--green); font-size: 18px; } | |
| 1012 | +.pass-gestionnaire { | |
| 1013 | + font-family: var(--font-display); font-weight: 700; font-size: 19px; | |
| 1014 | + display: inline-flex; align-items: center; gap: 8px; | |
| 1015 | + background: var(--lime-soft); border: 1.5px solid var(--green); | |
| 1016 | + border-radius: 999px; padding: 6px 16px; color: var(--green-deep); | |
| 1017 | +} | |
| 1018 | +.pass-card h1 { font-size: 20px; letter-spacing: -0.02em; } | |
| 1019 | +.pass-texte { color: var(--ink-2); font-size: 14.5px; margin: 10px 0 20px; } | |
| 1020 | +.pass-progress { | |
| 1021 | + height: 10px; border: 1.5px solid var(--ink); border-radius: 999px; | |
| 1022 | + overflow: hidden; background: var(--surface-2); margin-bottom: 20px; | |
| 1023 | +} | |
| 1024 | +.pass-progress-fill { | |
| 1025 | + display: block; height: 100%; width: 0; background: var(--lime); | |
| 1026 | + border-right: 2px solid var(--ink); | |
| 1027 | +} | |
| 1028 | +.pass-progress-fill.go { animation: pass-fill 2.2s linear forwards; } | |
| 1029 | +@keyframes pass-fill { to { width: 100%; } } | |
| 1030 | +@media (prefers-reduced-motion: reduce) { | |
| 1031 | + .pass-progress-fill.go { animation: none; width: 100%; } | |
| 1032 | + .pass-dot { animation: none; opacity: 0.7; } | |
| 1033 | +} | |
| 1034 | +.pass-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 14px; } | |
| 1035 | + | |
| 1036 | +/* --- boutons PDF -------------------------------------------------------- */ | |
| 1037 | +.btn-pdf { display: block; text-align: center; margin-top: 10px; width: 100%; } | |
| 1038 | + | |
| 1039 | +/* --- Onglets de la page Stats -------------------------------------------- */ | |
| 1040 | +.onglets { | |
| 1041 | + display: flex; gap: 6px; overflow-x: auto; margin: 26px 0 22px; | |
| 1042 | + padding-bottom: 4px; scrollbar-width: none; | |
| 1043 | + border-bottom: 2px solid var(--ink); | |
| 1044 | +} | |
| 1045 | +.onglets::-webkit-scrollbar { display: none; } | |
| 1046 | +.onglet { | |
| 1047 | + flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; | |
| 1048 | + border: 1.5px solid var(--ink); border-bottom: 0; | |
| 1049 | + border-radius: var(--r-ctl) var(--r-ctl) 0 0; | |
| 1050 | + background: var(--surface); color: var(--ink-2); cursor: pointer; | |
| 1051 | + padding: 10px 16px; font-weight: 600; font-size: 13.5px; min-height: 44px; | |
| 1052 | + transition: background 0.12s ease, color 0.12s ease; | |
| 1053 | +} | |
| 1054 | +.onglet:hover { background: var(--lime-soft); color: var(--ink); } | |
| 1055 | +.onglet.on { background: var(--ink); color: var(--lime); } | |
| 1056 | + | |
| 1057 | +/* baisses de prix */ | |
| 1058 | +.baisses { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } | |
| 1059 | +.baisses li { | |
| 1060 | + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; | |
| 1061 | + padding: 9px 4px; border-bottom: 1px dashed var(--line); font-size: 13.5px; | |
| 1062 | +} | |
| 1063 | +.baisses li a { font-weight: 600; text-decoration: underline; text-underline-offset: 2px; } | |
| 1064 | +.baisse-ville { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; } | |
| 1065 | +.baisse-prix { margin-left: auto; } | |
| 1066 | +.baisse-prix s { color: var(--ink-3); } | |
| 1067 | +.baisse-pct { | |
| 1068 | + font-style: normal; font-family: var(--font-mono); font-weight: 700; | |
| 1069 | + background: var(--lime-soft); border: 1px solid var(--green); | |
| 1070 | + color: var(--green-deep); border-radius: 999px; padding: 2px 8px; | |
| 1071 | + font-size: 11px; margin-left: 8px; | |
| 1072 | +} | |
| 1073 | +.alertes { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--ink-2); } | |
| 1074 | + | |
| 1075 | +/* --- pagination (12 formations / page) ------------------------------------ */ | |
| 1076 | +.pagination { | |
| 1077 | + display: flex; flex-wrap: wrap; justify-content: center; align-items: center; | |
| 1078 | + gap: 8px; margin: 34px 0 6px; | |
| 1079 | +} | |
| 1080 | +.page-btn { | |
| 1081 | + font-family: var(--font-mono); font-size: 0.85rem; font-weight: 500; | |
| 1082 | + min-width: 38px; padding: 9px 12px; cursor: pointer; | |
| 1083 | + background: var(--surface); color: var(--ink); | |
| 1084 | + border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); | |
| 1085 | + transition: box-shadow 0.12s ease, transform 0.12s ease, background 0.12s ease; | |
| 1086 | +} | |
| 1087 | +.page-btn:hover:not(:disabled):not(.on) { | |
| 1088 | + background: var(--lime-soft); | |
| 1089 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.22); transform: translate(-1px, -1px); | |
| 1090 | +} | |
| 1091 | +.page-btn.on { | |
| 1092 | + background: var(--ink); color: var(--lime); | |
| 1093 | + box-shadow: 3px 3px 0 var(--lime); cursor: default; | |
| 1094 | +} | |
| 1095 | +.page-btn:disabled { opacity: 0.35; cursor: not-allowed; } | |
| 1096 | +.page-prev, .page-next { font-family: var(--font-body); font-weight: 600; } | |
| 1097 | +.page-ellipsis { color: var(--ink-3); padding: 0 2px; user-select: none; } | |
| 1098 | +@media (max-width: 560px) { | |
| 1099 | + .page-btn { min-width: 34px; padding: 8px 10px; } | |
| 1100 | + .page-prev, .page-next { flex-basis: 40%; } | |
| 1101 | +} | |
added
frontend/src/vite-env.d.ts
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +/// <reference types="vite/client" /> | |
added
frontend/tsconfig.json
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2020", | |
| 4 | + "useDefineForClassFields": true, | |
| 5 | + "lib": ["ES2020", "DOM", "DOM.Iterable"], | |
| 6 | + "module": "ESNext", | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "moduleResolution": "bundler", | |
| 9 | + "allowImportingTsExtensions": true, | |
| 10 | + "resolveJsonModule": true, | |
| 11 | + "isolatedModules": true, | |
| 12 | + "noEmit": true, | |
| 13 | + "jsx": "react-jsx", | |
| 14 | + "strict": true, | |
| 15 | + "noUnusedLocals": false, | |
| 16 | + "noUnusedParameters": false, | |
| 17 | + "noFallthroughCasesInSwitch": true | |
| 18 | + }, | |
| 19 | + "include": ["src"] | |
| 20 | +} | |
added
frontend/tsconfig.tsbuildinfo
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/cookieconsent.tsx","./src/components/formationcard.tsx","./src/pages/formation.tsx","./src/pages/home.tsx","./src/pages/privacy.tsx","./src/pages/sources.tsx","./src/pages/stats.tsx"],"version":"5.9.3"} | |
| \ No newline at end of file | ||
added
frontend/vite.config.ts
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// vite.config.ts : configuration Vite (proxy API en dev, build vers dist/) | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import { defineConfig } from "vite"; | |
| 7 | +import react from "@vitejs/plugin-react"; | |
| 8 | + | |
| 9 | +export default defineConfig({ | |
| 10 | + plugins: [react()], | |
| 11 | + server: { | |
| 12 | + proxy: { "/api": "http://localhost:8080" }, | |
| 13 | + }, | |
| 14 | + build: { outDir: "dist" }, | |
| 15 | +}); | |
added
requirements.txt
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +# Forma-Ka — dépendances backend | |
| 2 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +fastapi>=0.110 | |
| 4 | +uvicorn>=0.29 | |
| 5 | +requests>=2.31 | |
| 6 | +beautifulsoup4>=4.12 | |
added
run.py
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ----------------------------------------------------------------------------- | |
| 3 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 4 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 5 | +# run.py : point d'entrée — `sync`, `watch`, `serve` | |
| 6 | +# ----------------------------------------------------------------------------- | |
| 7 | +"""Utilisation : | |
| 8 | + python run.py sync [source ...] # synchronise les formations | |
| 9 | + python run.py watch [minutes] # synchronise en boucle (défaut 360 min) | |
| 10 | + python run.py serve [port] # démarre l'API + le frontend (défaut 8080) | |
| 11 | +""" | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import os | |
| 15 | +import sys | |
| 16 | +from pathlib import Path | |
| 17 | + | |
| 18 | +# Charger .env (FIRECRAWL_API_KEY, SCRAPFLY_API_KEY…) sans dépendance externe | |
| 19 | +_env = Path(__file__).parent / ".env" | |
| 20 | +if _env.exists(): | |
| 21 | + for line in _env.read_text().splitlines(): | |
| 22 | + line = line.strip() | |
| 23 | + if line and not line.startswith("#") and "=" in line: | |
| 24 | + k, _, v = line.partition("=") | |
| 25 | + os.environ.setdefault(k.strip(), v.strip()) | |
| 26 | + | |
| 27 | + | |
| 28 | +def main() -> None: | |
| 29 | + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve" | |
| 30 | + if cmd == "sync": | |
| 31 | + from formaka import ingest | |
| 32 | + ingest.run(sys.argv[2:] or None) | |
| 33 | + elif cmd == "watch": | |
| 34 | + from formaka import ingest | |
| 35 | + minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 360 | |
| 36 | + ingest.watch(minutes * 60) | |
| 37 | + elif cmd == "serve": | |
| 38 | + import uvicorn | |
| 39 | + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080 | |
| 40 | + uvicorn.run("formaka.web:app", host="0.0.0.0", port=port) | |
| 41 | + else: | |
| 42 | + print(__doc__) | |
| 43 | + sys.exit(1) | |
| 44 | + | |
| 45 | + | |
| 46 | +if __name__ == "__main__": | |
| 47 | + main() | |
added
tests/test_normalize.py
+95 −0
@@ -0,0 +1,95 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Forma-Ka — Agrégateur de formations (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# tests/test_normalize.py : couche de normalisation commune | |
| 5 | +# ----------------------------------------------------------------------------- | |
| 6 | +from formaka.normalize import ( | |
| 7 | + extract_details, | |
| 8 | + normalize_language, | |
| 9 | + normalize_mode, | |
| 10 | + normalize_type, | |
| 11 | + parse_date_fr, | |
| 12 | + parse_duration_hours, | |
| 13 | + parse_price, | |
| 14 | +) | |
| 15 | +from formaka.schema import Formation | |
| 16 | + | |
| 17 | + | |
| 18 | +def test_parse_price(): | |
| 19 | + assert parse_price("1 295,00 $ + tx") == 1295.0 | |
| 20 | + assert parse_price("795 $") == 795.0 | |
| 21 | + assert parse_price("$1,250.00") == 1250.0 | |
| 22 | + assert parse_price("Gratuit") == 0.0 | |
| 23 | + assert parse_price("Sans frais") == 0.0 | |
| 24 | + assert parse_price("") is None | |
| 25 | + assert parse_price("Sur demande") is None | |
| 26 | + | |
| 27 | + | |
| 28 | +def test_parse_duration_hours(): | |
| 29 | + assert parse_duration_hours("2 jours") == 14.0 | |
| 30 | + assert parse_duration_hours("45 heures") == 45.0 | |
| 31 | + assert parse_duration_hours("3,5 h") == 3.5 | |
| 32 | + assert parse_duration_hours("90 minutes") == 1.5 | |
| 33 | + assert parse_duration_hours("demi-journée") == 3.5 | |
| 34 | + assert parse_duration_hours("") is None | |
| 35 | + | |
| 36 | + | |
| 37 | +def test_normalize_mode(): | |
| 38 | + assert normalize_mode("Classe virtuelle") == "en ligne" | |
| 39 | + assert normalize_mode("À distance") == "en ligne" | |
| 40 | + assert normalize_mode("En salle") == "présentiel" | |
| 41 | + assert normalize_mode("Comodal") == "hybride" | |
| 42 | + assert normalize_mode("À votre rythme") == "asynchrone" | |
| 43 | + | |
| 44 | + | |
| 45 | +def test_normalize_type(): | |
| 46 | + assert normalize_type("Séminaire de 2 jours") == "Séminaire" | |
| 47 | + assert normalize_type("Cours de 1er cycle") == "Cours universitaire" | |
| 48 | + assert normalize_type("AEC en bureautique") == "Cours collégial" | |
| 49 | + assert normalize_type("Webinaire gratuit") == "Webinaire" | |
| 50 | + assert normalize_type("Formation continue") == "Formation continue" | |
| 51 | + | |
| 52 | + | |
| 53 | +def test_normalize_language(): | |
| 54 | + assert normalize_language("Français") == "fr" | |
| 55 | + assert normalize_language("English") == "en" | |
| 56 | + assert normalize_language("FR et EN") == "fr/en" | |
| 57 | + | |
| 58 | + | |
| 59 | +def test_parse_date_fr(): | |
| 60 | + assert parse_date_fr("Débute le 14 octobre 2026") == "2026-10-14" | |
| 61 | + assert parse_date_fr("1er décembre 2026") == "2026-12-01" | |
| 62 | + assert parse_date_fr("2026-09-08") == "2026-09-08" | |
| 63 | + assert parse_date_fr("14/10/2026") == "2026-10-14" | |
| 64 | + assert parse_date_fr("aucune date") is None | |
| 65 | + | |
| 66 | + | |
| 67 | +def test_extract_details(): | |
| 68 | + d = extract_details("Ce cours de 3 crédits (1,4 UEC), niveau débutant, 45 heures") | |
| 69 | + assert d["credits"] == 3.0 | |
| 70 | + assert d["uec"] == 1.4 | |
| 71 | + assert d["level"] == "débutant" | |
| 72 | + assert d["duration_hours"] == 45.0 | |
| 73 | + | |
| 74 | + | |
| 75 | +def test_finalize_prix_optionnel(): | |
| 76 | + """Un cours universitaire sans prix est parfaitement valide.""" | |
| 77 | + f = Formation(source="t", external_id="1", url="u", title=" Cours X ", | |
| 78 | + description="Cours de 3 crédits offert à distance.").finalize() | |
| 79 | + assert f.price is None | |
| 80 | + assert f.is_free is None | |
| 81 | + assert f.credits == "3 crédits" | |
| 82 | + assert f.title == "Cours X" | |
| 83 | + | |
| 84 | + | |
| 85 | +def test_finalize_complet(): | |
| 86 | + f = Formation(source="t", external_id="2", url="u", title="Atelier", | |
| 87 | + training_type="atelier", duration="2 jours", | |
| 88 | + price_label="À partir de 795 $", | |
| 89 | + schedule_label="Prochaine séance : 14 octobre 2026").finalize() | |
| 90 | + assert f.training_type == "Atelier" | |
| 91 | + assert f.price == 795.0 | |
| 92 | + assert f.is_free is False | |
| 93 | + assert f.duration_hours == 14.0 | |
| 94 | + assert f.start_date == "2026-10-14" | |
| 95 | + assert f.details.get("price_from") is True | |
| 96 | ||