SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

KA Agent v2 (widget) : plein écran à l'ouverture (desktop+mobile), fond gelé + calage visualViewport (clavier mobile stable), rendu Markdown complet en streaming (titres, listes, tableaux, code, liens nommés)

Simon-Pierre Boucher committed 1 mo ago (Aug 19, 2026) parent b543611

101 changed files +6,896 −1,860

modified data/sources.json +5 −5
@@ -80,12 +80,12 @@
80 80 "id": "kw_distinction",
81 81 "name": "Keller Williams Distinction",
82 82 "url": "https://www.kwdistinction.com",
83 − "listing_url": "https://www.kwdistinction.com/sitemap.xml",
83 + "listing_url": "https://www.kwdistinction.com/maison-a-vendre-au-quebec/condo-a-vendre-au-quebec/",
84 84 "coverage": "Gatineau / Outaouais",
85 85 "connector": "kw_distinction",
86 86 "status": "actif",
87 87 "type": "franchise",
88 − "note": "ASP.NET (tonikwebstudio) : grille/fiche en AJAX (prix/photos non rendus statiquement, ni fiablement via Firecrawl). Enumération via sitemap.xml (167 propr.) ; slug d'URL = ville/type/chambres/rue/no-Centris. Champs liste: adresse, ville, secteur, type, chambres, Centris. Prix/photos NON dispo."
88 + "note": "ASP.NET (Olivex/tonikweb) : page resultats rendue serveur, paginee PageID=1..N (~6 cartes/page) — cartes completes (type, prix, adresse, ville/secteur, MLS, cac/sdb, resume, photo). Fiches detail rendues serveur : galerie /_upload/photos/{id}-{n}.jpg, description, tableau Details, proximite. NE PAS utiliser sitemap.xml (URLs anciennes sector--city -> /Pages/Erreur/). ~197 inscriptions dont ~144 avec prix (reste locations/commercial au pi2)."
89 89 },
90 90 {
91 91 "id": "vendirect",
@@ -301,12 +301,12 @@
301 301 "id": "kw_prestige",
302 302 "name": "Keller Williams Prestige",
303 303 "url": "https://www.kwprestige.com",
304 − "listing_url": "https://www.kwprestige.com/sitemap.xml",
304 + "listing_url": "https://www.kwprestige.com/fr/proprietes/",
305 305 "coverage": "Grand Montréal — bannière KW prestige",
306 306 "connector": "kw_prestige",
307 307 "status": "actif",
308 308 "type": "franchise",
309 − "note": "Grille/fiche en widget JS (pagination AJAX non exploitable). Enumeration via sitemap.xml (277 fiches a-vendre) ; slug URL = type-a-vendre-ville-secteur-{noCentris}-detail-Fr. Champs liste: type, ville, secteur, n Centris. Prix/photos = widget JS (list cards rendus serveur mais pagination JS ; enrichissement futur via Scrapfly js_scenario)."
309 + "note": "Plateforme macleweb.com : API interne /ajax/getProps.php (JSON {html, nbPages}, qty=100) — cartes completes (prix, adresse, ville/secteur, cac/sdb, resume, photo). Fiches detail rendues serveur : galerie data-centris/large_photo, description, tableau Centris, GPS (iframe maps/embed), courtier. NE PAS utiliser sitemap.xml (fige 2025-09, fiches expirees = gabarit vide). ~800 inscriptions dont ventes ~300 (reste locations/Vendu)."
310 310 },
311 311 {
312 312 "id": "coldwell_banker",
@@ -349,7 +349,7 @@
349 349 "connector": "exp_ag_qc",
350 350 "status": "actif",
351 351 "type": "agence",
352 − "note": "Site officiel eXp QC (expquebec.com, WordPress + MW Properties). API cliente marketingwebsites.ca bloquée (whitelist IP) MAIS inventaire complet en SSR sur /fr/properties/?pages=N (9/page, param 'pages', stop après 3 pages vides). Carte: data-href mls={Centris}, data-price ($US), data-address, data-pic. Ville via map-property.php?mls={Centris}&lang=fr (enrich requests, plafonné+cache). source_id _ag_ = dédup Centris."
352 + "note": "Site officiel eXp QC (expquebec.com, WordPress + MW Properties). API cliente marketingwebsites.ca bloquée (whitelist IP) MAIS inventaire complet en SSR sur /fr/properties/?pages=N (9/page, param 'pages', stop après 3 pages vides). Carte: data-href mls={Centris}, data-price ($US), data-address, data-pic. Fiche détail /fr/properties/mls/{Centris}/ server-rendered et riche: galerie complète pleine résolution (ancres data-lightbox), remarque+addendum, inclusions/exclusions, icônes (CAC/SDB/superficie MC|PC/année), tables BÂTIMENT/ÉVALUATION/DÉPENSES/CARACTÉRISTIQUES, pièces, GPS exact (embed Street View), courtier+tél. Enrich plafonné IMMOKA_EXP_DETAIL_LIMIT (400/cycle) + cache BD; repli ville via map-property.php (IMMOKA_EXP_CITY_LIMIT). source_id _ag_ = dédup Centris."
353 353 },
354 354 {
355 355 "id": "si_ag_theagency",
modified docs/connecteurs/INDEX.md +56 −56
@@ -1,10 +1,10 @@
1 1 # Immo-Ka — Connecteurs (documentation standardisée)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 **101 connecteurs** enregistrés (auto-découverte `immoka/connectors/__init__.py`) : 50 connecteurs nommés (`data/sources.json`) + 4 familles dynamiques (RE/MAX sous-agences, Via Capitale, Century 21, source.immo).
6 6
7 −Base vivante `data/immoka.db` : 145 724 annonces, 129 862 actives, 79 229 visibles après dédup (50 633 masquées).
7 +Base vivante `data/immoka.db` : 146 432 annonces, 130 293 actives, 79 319 visibles après dédup (50 974 masquées).
8 8
9 9 ## Mécanismes transverses (tous connecteurs)
10 10
@@ -15,14 +15,14 @@ Base vivante `data/immoka.db` : 145 724 annonces, 129 862 actives, 79 229 visibl
15 15 sur plusieurs cycles, tout le parc finit enrichi.
16 16 - **Dédup Centris + adresse** (`immoka/db.py`) : les doublons inter-sources
17 17 (même n° Centris via les sous-agences, ou même adresse normalisée) sont
18 − marqués `dup_hidden=1` — 50 633 annonces masquées actuellement — et la
18 + marqués `dup_hidden=1` — 50 974 annonces masquées actuellement — et la
19 19 lecture filtre en `AND dup_hidden=0`.
20 20 - **Jointure rôle d'évaluation** (`immoka/vraiprix_local.py`) : appariement
21 21 local FTS par adresse contre `data/vraiprix.db` (3,7 M unités d'évaluation).
22 22 Remplit lat/lng manquants, l'estimation Vrai-Prix (P10-P90 + lien) et les
23 23 champs du rôle (année de construction, superficies, évaluations). Impact :
24 − **year_built rempli à 68 %** des annonces visibles, dont
25 − 31 % proviennent du rôle (`year_built_source: role`).
24 + **year_built rempli à 70 %** des annonces visibles, dont
25 + 24 % proviennent du rôle (`year_built_source: role`).
26 26 - **Historique — vagues du 2026-08-18** :
27 27 - `7911d58` Vague 1 : clé Algolia auto-extraite (Barnes), taxes/éval RE/MAX
28 28 (cache détail v4), specs Via Capitale, Kijiji étendu.
@@ -33,62 +33,62 @@ Base vivante `data/immoka.db` : 145 724 annonces, 129 862 actives, 79 229 visibl
33 33
34 34 | Famille | Fiche | Membres | Visibles |
35 35 |---|---|---|---|
36 −| Famille RE/MAX sous-agences | [famille-remax-ag](famille-remax-ag.md) | 50 | 3 150 |
37 −| Famille Via Capitale agences | [famille-via-ag](famille-via-ag.md) | 4 | 50 |
38 −| Famille Century 21 | [famille-c21-ag](famille-c21-ag.md) | 2 | 1 576 |
39 −| Famille source.immo | [famille-source-immo](famille-source-immo.md) | 13 | 3 312 |
36 +| Famille RE/MAX sous-agences | [famille-remax-ag](famille-remax-ag.md) | 50 | 3 172 |
37 +| Famille Via Capitale agences | [famille-via-ag](famille-via-ag.md) | 4 | 47 |
38 +| Famille Century 21 | [famille-c21-ag](famille-c21-ag.md) | 2 | 1 586 |
39 +| Famille source.immo | [famille-source-immo](famille-source-immo.md) | 13 | 3 328 |
40 40
41 41 ## Connecteurs nommés
42 42
43 43 | id | Nom | Type | Statut | Visibles | Année (dont rôle) | Dernier sync | OK |
44 44 |---|---|---|---|---|---|---|---|
45 −| [`remax_quebec`](remax_quebec.md) | RE/MAX Québec | franchise | actif | 25 348 | 82.8 % | 2026-08-18 05:11 | ✅ |
46 −| [`duproprio`](duproprio.md) | DuProprio (sans courtier) | fsbo | actif | 7 393 | 79.3 % | 2026-08-18 02:37 | ✅ |
47 −| [`kijiji`](kijiji.md) | Kijiji Québec | — | actif | 7 104 | 4.2 % | 2026-08-18 02:59 | ✅ |
48 −| [`via_capitale`](via_capitale.md) | Via Capitale | franchise | actif | 5 153 | 68.9 % | 2026-08-18 00:27 | ✅ |
49 −| [`sutton`](sutton.md) | Groupe Sutton Québec | franchise | actif | 4 515 | 65.4 % | 2026-08-18 02:17 | ✅ |
50 −| [`lespac`](lespac.md) | LesPAC Québec | — | actif | 2 756 | 57.9 % | 2026-08-18 03:10 | ✅ |
51 −| [`proprio_direct`](proprio_direct.md) | Proprio Direct | agence | actif | 2 464 | 67.5 % | 2026-08-18 03:14 | ✅ |
52 −| [`c21_ag_quebec`](c21_ag_quebec.md) | Century 21 Québec (All Quebec) | — | actif | 1 576 | 75.6 % | 2026-08-18 02:35 | ✅ |
53 −| [`domicilia`](domicilia.md) | Domicilia Agence Immobilière | agence | actif | 1 445 | 73.4 % | 2026-08-18 02:27 | ✅ |
54 −| [`cio_ag_outaouais`](cio_ag_outaouais.md) | Chambre immobilière de l'Outaouais | portail | actif | 1 394 | 69.6 % | 2026-08-18 02:35 | ✅ |
55 −| [`engel_volkers`](engel_volkers.md) | Engel & Völkers Montréal / Québec | luxe | actif | 1 228 | 69.9 % | 2026-08-18 02:37 | ✅ |
56 −| [`ubee`](ubee.md) | Ubee | — | actif | 1 176 | 100.0 % | 2026-08-18 00:23 | ✅ |
57 −| [`vendirect`](vendirect.md) | Vendirect | agence | actif | 1 170 | 79.4 % | 2026-08-18 00:23 | ✅ |
58 −| [`si_ag_ciemcq`](si_ag_ciemcq.md) | Chambre immobilière Estrie–Mauricie–Centre-du-Québec (Mon-Toit) | chambre | actif | 1 169 | 62.8 % | 2026-08-18 05:56 | ✅ |
59 −| [`royal_lepage`](royal_lepage.md) | Royal LePage (Québec) | franchise | actif | 1 008 | 89.0 % | 2026-08-18 05:19 | ✅ |
60 −| [`exp_ag_qc`](exp_ag_qc.md) | eXp Agence immobilière (Québec) | agence | actif | 888 | 55.6 % | 2026-08-18 02:40 | ✅ |
61 −| [`sothebys_quebec`](sothebys_quebec.md) | Sotheby's International Realty Québec | luxe | actif | 869 | 3.5 % | 2026-08-18 06:05 | ✅ |
62 −| [`charisma_ag_qc`](charisma_ag_qc.md) | Les Immeubles Charisma | agence | actif | 696 | 81.2 % | 2026-08-18 02:37 | ✅ |
63 −| [`kw_urbain`](kw_urbain.md) | Keller Williams Urbain | franchise | actif | 351 | 68.7 % | 2026-08-18 02:59 | ✅ |
64 −| [`profusion`](profusion.md) | Profusion Immobilier (Christie's) | luxe | actif | 351 | 61.5 % | 2026-08-18 03:14 | ✅ |
65 −| [`realtor_ag_ca`](realtor_ag_ca.md) | Realtor.ca (MLS Québec) | agrégateur | actif | 343 | 0.0 % | 2026-08-18 06:06 | ✅ |
66 −| [`barnes_quebec`](barnes_quebec.md) | BARNES Québec | luxe | actif | 336 | 81.5 % | 2026-08-18 02:35 | ✅ |
67 −| [`kw_prestige`](kw_prestige.md) | Keller Williams Prestige | franchise | actif | 277 | 0.0 % | 2026-08-18 02:59 | ✅ |
68 −| [`si_ag_theagency`](si_ag_theagency.md) | The Agency Montréal | agence | actif | 236 | 90.3 % | 2026-08-18 02:27 | ✅ |
69 −| [`glmc_ag_qc`](glmc_ag_qc.md) | Les Immeubles GLMC | agence | actif | 176 | 36.4 % | 2026-08-18 02:40 | ✅ |
70 −| [`kw_distinction`](kw_distinction.md) | Keller Williams Distinction | franchise | actif | 167 | 0.0 % | 2026-08-18 02:59 | ✅ |
71 −| [`imcha_ag_qc`](imcha_ag_qc.md) | Les Immeubles Charlevoix | agence | actif | 107 | 0.0 % | 2026-08-18 02:40 | ✅ |
72 −| [`guidehabitation`](guidehabitation.md) | Guide Habitation (projets neufs) | répertoire | actif | 94 | 0.0 % | 2026-08-18 05:56 | ✅ |
73 −| [`immodev`](immodev.md) | Immodev Agence Immobilière | agence | actif | 92 | 64.1 % | 2026-08-18 02:27 | ✅ |
74 −| [`bhhs_ag_qc`](bhhs_ag_qc.md) | Berkshire Hathaway HomeServices Québec | franchise | actif | 91 | 86.8 % | 2026-08-18 02:35 | ✅ |
75 −| [`realta`](realta.md) | Réalta Agence Immobilière | agence | actif | 82 | 80.5 % | 2026-08-18 02:27 | ✅ |
76 −| [`provad`](provad.md) | Proulx Vadnais & Associés | agence | actif | 80 | 41.2 % | 2026-08-18 05:56 | ✅ |
77 −| [`image_ag_qc`](image_ag_qc.md) | Image, l'agence immobilière | agence | actif | 77 | 0.0 % | 2026-08-18 02:27 | ✅ |
78 −| [`m_immobilier`](m_immobilier.md) | M Immobilier | luxe | actif | 67 | 85.1 % | 2026-08-18 03:13 | ✅ |
79 −| [`si_ag_jga`](si_ag_jga.md) | JGA Équipe Commerciale (Royal LePage) | équipe | actif | 59 | 28.8 % | 2026-08-18 05:56 | ✅ |
80 −| [`peladeau`](peladeau.md) | Péladeau Agence Immobilière | agence | actif | 47 | 83.0 % | 2026-08-18 05:56 | ✅ |
81 −| [`contact_ag_qc`](contact_ag_qc.md) | Les Immeubles Contact | agence | actif | 43 | 83.7 % | 2026-08-18 02:40 | ✅ |
82 −| [`avenue`](avenue.md) | Avenue Immobilier | agence | actif | 42 | 95.2 % | 2026-08-18 05:56 | ✅ |
83 −| [`si_ag_katiasamson`](si_ag_katiasamson.md) | Katia Samson (RE/MAX L'Espace) | équipe | actif | 25 | 100.0 % | 2026-08-18 05:56 | ✅ |
84 −| [`ferri`](ferri.md) | Ferri Services Immobiliers | agence | actif | 22 | 95.5 % | 2026-08-18 05:56 | ✅ |
85 −| [`via_ag_select`](via_ag_select.md) | Via Capitale Select | franchise | actif | 13 | 100.0 % | 2026-08-18 00:28 | ✅ |
86 −| [`gaillardetz`](gaillardetz.md) | Gaillardetz Agence Immobilière | agence | actif | 13 | 92.3 % | 2026-08-18 02:27 | ✅ |
87 −| [`via_ag_elite`](via_ag_elite.md) | Via Capitale Elite | franchise | actif | 10 | 80.0 % | 2026-08-18 00:28 | ✅ |
88 −| [`expert_immobilier_pm`](expert_immobilier_pm.md) | L'Expert Immobilier P.M. | agence | actif | 9 | 88.9 % | 2026-08-18 02:40 | ✅ |
89 −| [`via_ag_acces`](via_ag_acces.md) | Via Capitale Acces | franchise | actif | 7 | 100.0 % | 2026-08-18 00:27 | ✅ |
45 +| [`remax_quebec`](remax_quebec.md) | RE/MAX Québec | franchise | actif | 25 386 | 82.8 % | 2026-08-18 13:20 | ✅ |
46 +| [`duproprio`](duproprio.md) | DuProprio (sans courtier) | fsbo | actif | 7 411 | 79.3 % | 2026-08-18 17:49 | ✅ |
47 +| [`kijiji`](kijiji.md) | Kijiji Québec | — | actif | 7 141 | 4.2 % | 2026-08-18 12:36 | ✅ |
48 +| [`via_capitale`](via_capitale.md) | Via Capitale | franchise | actif | 5 153 | 69.4 % | 2026-08-18 13:37 | ✅ |
49 +| [`sutton`](sutton.md) | Groupe Sutton Québec | franchise | actif | 4 572 | 77.0 % | 2026-08-18 13:32 | ✅ |
50 +| [`lespac`](lespac.md) | LesPAC Québec | — | actif | 2 732 | 58.1 % | 2026-08-18 12:47 | ✅ |
51 +| [`proprio_direct`](proprio_direct.md) | Proprio Direct | agence | actif | 2 468 | 67.6 % | 2026-08-18 12:51 | ✅ |
52 +| [`c21_ag_quebec`](c21_ag_quebec.md) | Century 21 Québec (All Quebec) | — | actif | 1 585 | 75.7 % | 2026-08-18 17:47 | ✅ |
53 +| [`domicilia`](domicilia.md) | Domicilia Agence Immobilière | agence | actif | 1 449 | 73.4 % | 2026-08-18 17:38 | ✅ |
54 +| [`cio_ag_outaouais`](cio_ag_outaouais.md) | Chambre immobilière de l'Outaouais | portail | actif | 1 405 | 68.8 % | 2026-08-18 17:47 | ✅ |
55 +| [`engel_volkers`](engel_volkers.md) | Engel & Völkers Montréal / Québec | luxe | actif | 1 232 | 70.0 % | 2026-08-18 17:49 | ✅ |
56 +| [`si_ag_ciemcq`](si_ag_ciemcq.md) | Chambre immobilière Estrie–Mauricie–Centre-du-Québec (Mon-Toit) | chambre | actif | 1 179 | 68.7 % | 2026-08-18 17:38 | ✅ |
57 +| [`ubee`](ubee.md) | Ubee | — | actif | 1 175 | 100.0 % | 2026-08-18 13:33 | ✅ |
58 +| [`vendirect`](vendirect.md) | Vendirect | agence | actif | 1 174 | 79.5 % | 2026-08-18 13:33 | ✅ |
59 +| [`royal_lepage`](royal_lepage.md) | Royal LePage (Québec) | franchise | actif | 1 051 | 92.7 % | 2026-08-18 13:21 | ✅ |
60 +| [`exp_ag_qc`](exp_ag_qc.md) | eXp Agence immobilière (Québec) | agence | actif | 888 | 55.4 % | 2026-08-18 17:52 | ✅ |
61 +| [`sothebys_quebec`](sothebys_quebec.md) | Sotheby's International Realty Québec | luxe | actif | 869 | 60.2 % | 2026-08-18 13:26 | ✅ |
62 +| [`charisma_ag_qc`](charisma_ag_qc.md) | Les Immeubles Charisma | agence | actif | 696 | 81.2 % | 2026-08-18 17:49 | ✅ |
63 +| [`realtor_ag_ca`](realtor_ag_ca.md) | Realtor.ca (MLS Québec) | agrégateur | actif | 429 | 82.3 % | 2026-08-18 13:15 | ✅ |
64 +| [`profusion`](profusion.md) | Profusion Immobilier (Christie's) | luxe | actif | 353 | 61.5 % | 2026-08-18 12:51 | ✅ |
65 +| [`kw_urbain`](kw_urbain.md) | Keller Williams Urbain | franchise | actif | 351 | 68.7 % | 2026-08-18 12:36 | ✅ |
66 +| [`barnes_quebec`](barnes_quebec.md) | BARNES Québec | luxe | actif | 338 | 81.1 % | 2026-08-18 17:47 | ✅ |
67 +| [`kw_prestige`](kw_prestige.md) | Keller Williams Prestige | franchise | actif | 277 | 0.0 % | 2026-08-18 12:36 | ✅ |
68 +| [`si_ag_theagency`](si_ag_theagency.md) | The Agency Montréal | agence | actif | 236 | 90.3 % | 2026-08-18 17:38 | ✅ |
69 +| [`glmc_ag_qc`](glmc_ag_qc.md) | Les Immeubles GLMC | agence | actif | 176 | 36.4 % | 2026-08-18 17:52 | ✅ |
70 +| [`kw_distinction`](kw_distinction.md) | Keller Williams Distinction | franchise | actif | 167 | 0.0 % | 2026-08-18 12:36 | ✅ |
71 +| [`imcha_ag_qc`](imcha_ag_qc.md) | Les Immeubles Charlevoix | agence | actif | 106 | 0.0 % | 2026-08-18 17:52 | ✅ |
72 +| [`guidehabitation`](guidehabitation.md) | Guide Habitation (projets neufs) | répertoire | actif | 94 | 27.7 % | 2026-08-18 17:52 | ✅ |
73 +| [`immodev`](immodev.md) | Immodev Agence Immobilière | agence | actif | 92 | 64.1 % | 2026-08-18 18:10 | ✅ |
74 +| [`bhhs_ag_qc`](bhhs_ag_qc.md) | Berkshire Hathaway HomeServices Québec | franchise | actif | 91 | 86.8 % | 2026-08-18 17:47 | ✅ |
75 +| [`realta`](realta.md) | Réalta Agence Immobilière | agence | actif | 82 | 80.5 % | 2026-08-18 18:09 | ✅ |
76 +| [`provad`](provad.md) | Proulx Vadnais & Associés | agence | actif | 81 | 43.2 % | 2026-08-18 17:38 | ✅ |
77 +| [`image_ag_qc`](image_ag_qc.md) | Image, l'agence immobilière | agence | actif | 78 | 0.0 % | 2026-08-18 17:38 | ✅ |
78 +| [`m_immobilier`](m_immobilier.md) | M Immobilier | luxe | actif | 66 | 86.4 % | 2026-08-18 12:51 | ✅ |
79 +| [`si_ag_jga`](si_ag_jga.md) | JGA Équipe Commerciale (Royal LePage) | équipe | actif | 59 | 37.3 % | 2026-08-18 17:38 | ✅ |
80 +| [`peladeau`](peladeau.md) | Péladeau Agence Immobilière | agence | actif | 47 | 93.6 % | 2026-08-18 17:38 | ✅ |
81 +| [`contact_ag_qc`](contact_ag_qc.md) | Les Immeubles Contact | agence | actif | 43 | 83.7 % | 2026-08-18 17:52 | ✅ |
82 +| [`avenue`](avenue.md) | Avenue Immobilier | agence | actif | 42 | 97.6 % | 2026-08-18 17:38 | ✅ |
83 +| [`si_ag_katiasamson`](si_ag_katiasamson.md) | Katia Samson (RE/MAX L'Espace) | équipe | actif | 26 | 100.0 % | 2026-08-18 17:38 | ✅ |
84 +| [`ferri`](ferri.md) | Ferri Services Immobiliers | agence | actif | 22 | 95.5 % | 2026-08-18 17:38 | ✅ |
85 +| [`gaillardetz`](gaillardetz.md) | Gaillardetz Agence Immobilière | agence | actif | 13 | 92.3 % | 2026-08-18 18:10 | ✅ |
86 +| [`via_ag_select`](via_ag_select.md) | Via Capitale Select | franchise | actif | 11 | 100.0 % | 2026-08-18 13:38 | ✅ |
87 +| [`expert_immobilier_pm`](expert_immobilier_pm.md) | L'Expert Immobilier P.M. | agence | actif | 9 | 88.9 % | 2026-08-18 17:52 | ✅ |
88 +| [`via_ag_elite`](via_ag_elite.md) | Via Capitale Elite | franchise | actif | 9 | 77.8 % | 2026-08-18 13:38 | ✅ |
89 +| [`via_ag_acces`](via_ag_acces.md) | Via Capitale Acces | franchise | actif | 7 | 100.0 % | 2026-08-18 13:38 | ✅ |
90 +| [`c21_ag_vision`](c21_ag_vision.md) | Century 21 Vision (Outaouais) | — | actif | 1 | 100.0 % | 2026-08-18 17:47 | ✅ |
90 91 | [`century21`](century21.md) | Century 21 (Québec) | franchise | actif | 0 | — | — | — |
91 92 | [`via_ag_dumontroyal`](via_ag_dumontroyal.md) | Via Capitale Dumontroyal | franchise | inactif | 0 | — | 2026-08-18 00:27 | ✅ |
92 −| [`c21_ag_vision`](c21_ag_vision.md) | Century 21 Vision (Outaouais) | — | actif | 0 | — | 2026-08-18 02:35 | ✅ |
93 −| [`niddamour`](niddamour.md) | Nid d'Amour Agence Immobilière | agence | a_faire | 0 | — | 2026-08-18 02:27 | ✅ |
93 +| [`niddamour`](niddamour.md) | Nid d'Amour Agence Immobilière | agence | a_faire | 0 | — | 2026-08-18 17:38 | ✅ |
94 94 | [`coldwell_banker`](coldwell_banker.md) | Coldwell Banker Québec | franchise | a_faire | 0 | — | — | — |
modified docs/connecteurs/avenue.md +6 −6
@@ -1,6 +1,6 @@
1 1 # Avenue Immobilier (`avenue`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSIAvenue`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -47,13 +47,13 @@
47 47 | Prix | 100.0 % |
48 48 | CAC (chambres) | 88.1 % |
49 49 | SDB (salles de bains) | 88.1 % |
50 −| Année de construction | 95.2 % (dont rôle : 0.0 %) |
50 +| Année de construction | 97.6 % (dont rôle : 2.4 %) |
51 51 | Taxes / évaluation municipale | 90.5 % |
52 −| Estimation Vrai-Prix (rôle) | 0.0 % |
52 +| Estimation Vrai-Prix (rôle) | 100.0 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 05:56 — trouvé 42, +42 / ~0 / -0 — OK
57 −- **Erreurs récentes** : 0 sur les 1 derniers syncs
56 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 42, +0 / ~0 / -0 — OK
57 +- **Erreurs récentes** : 0 sur les 4 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/barnes_quebec.md +20 −12
@@ -1,6 +1,6 @@
1 1 # BARNES Québec (`barnes_quebec`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -32,6 +32,14 @@
32 32 > RÉSILIENCE : la clé Algolia a déjà été rotée (403 le 2026-08-15) — on
33 33 > l'extrait donc À CHAQUE FOIS du HTML public du site (avec cache process),
34 34 > avec repli sur la dernière clé connue si l'extraction échoue.
35 +> FIABILITÉ (2026-08-18) : l'index Algolia conserve des fiches PÉRIMÉES
36 +> (vendues/retirées de WordPress) dont la page ET les photos renvoient 404
37 +> (~100 fiches sur ~340, soit les « annonces sans images »). Le sitemap
38 +> Yoast (property/rental/new-construction) est la source de vérité de ce
39 +> qui est publié : on filtre les fiches mortes, et on répare les slugs
40 +> renommés via la réf MLS présente dans les noms de fichiers image du
41 +> sitemap. La galerie de la fiche est aussi isolée par sa réf MLS (les
42 +> pages affichent des « propriétés similaires » qui polluaient la galerie).
35 43
36 44 ## Mécanismes clés
37 45
@@ -42,22 +50,22 @@
42 50
43 51 | Indicateur | Valeur |
44 52 |---|---|
45 −| Annonces en base (toutes) | 351 |
46 −| Actives | 336 |
47 −| Visibles (après dédup) | 336 |
53 +| Annonces en base (toutes) | 353 |
54 +| Actives | 338 |
55 +| Visibles (après dédup) | 338 |
48 56 | Masquées par la dédup | 0 |
49 −| GPS (lat/lng) | 99.4 % |
50 −| Photos | 68.5 % |
51 −| Prix | 99.7 % |
57 +| GPS (lat/lng) | 98.8 % |
58 +| Photos | 68.6 % |
59 +| Prix | 99.1 % |
52 60 | CAC (chambres) | 92.0 % |
53 −| SDB (salles de bains) | 91.1 % |
54 −| Année de construction | 81.5 % (dont rôle : 100.0 %) |
55 −| Taxes / évaluation municipale | 49.4 % |
56 −| Estimation Vrai-Prix (rôle) | 100.0 % |
61 +| SDB (salles de bains) | 90.8 % |
62 +| Année de construction | 81.1 % (dont rôle : 100.0 %) |
63 +| Taxes / évaluation municipale | 49.7 % |
64 +| Estimation Vrai-Prix (rôle) | 99.4 % |
57 65
58 66 ## Synchronisation
59 67
60 −- **Dernier sync** : 2026-08-18 02:35 — trouvé 336, +0 / ~0 / -0 — OK
68 +- **Dernier sync** : 2026-08-18 17:47 — trouvé 338, +2 / ~0 / -0 — OK
61 69 - **Erreurs récentes** : 0 sur les 10 derniers syncs
62 70
63 71 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/bhhs_ag_qc.md +14 −5
@@ -1,6 +1,6 @@
1 1 # Berkshire Hathaway HomeServices Québec (`bhhs_ag_qc`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -16,6 +16,7 @@
16 16
17 17 - Module : `immoka/connectors/bhhs_quebec.py` — classe `BhhsQuebecConnector`
18 18 - Délai entre requêtes : 0.4 s ; cache détail BD : oui
19 +- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_BHHS_DETAIL_LIMIT` (défaut 150)
19 20
20 21 **En-tête du module :**
21 22
@@ -27,6 +28,14 @@
27 28 > liste rendue SERVEUR sur /proprietes/, paginée par ?pages=N (~9/page,
28 29 > ~11 pages). Chaque carte porte : n° Centris (URL + image + property-meta),
29 30 > région, adresse, ville, prix ($US), chambres/sdb et photo.
31 +> Page détail : description + addenda (<div class="description block">,
32 +> collapse #showAddendum), bloc « features » (Année de construction, pièces,
33 +> chambres, sdb… en h5/p), tableau « Caractéristiques » (#charateristics —
34 +> sic — th/td), GPS inline (google.maps.LatLng(lat,lng)), visite virtuelle
35 +> (iframe #virtualCollapse), courtier (.agents : nom + tel:) et galerie
36 +> complète property-images/{Centris}/{Centris}-NN.jpg (pleine résolution,
37 +> ordre d'origine ; les miniatures /thumbs-320/ sont ignorées).
38 +> Enrichissement plafonné + cache (du.enrich).
30 39 > source_id « bhhs_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup).
31 40
32 41 ## Volumétrie & complétude (BD live)
@@ -39,16 +48,16 @@
39 48 | Masquées par la dédup | 2 |
40 49 | GPS (lat/lng) | 90.1 % |
41 50 | Photos | 100.0 % |
42 −| Prix | 67.0 % |
51 +| Prix | 65.9 % |
43 52 | CAC (chambres) | 93.4 % |
44 53 | SDB (salles de bains) | 93.4 % |
45 −| Année de construction | 86.8 % (dont rôle : 100.0 %) |
54 +| Année de construction | 86.8 % (dont rôle : 98.7 %) |
46 55 | Taxes / évaluation municipale | 0.0 % |
47 56 | Estimation Vrai-Prix (rôle) | 100.0 % |
48 57
49 58 ## Synchronisation
50 59
51 −- **Dernier sync** : 2026-08-18 02:35 — trouvé 93, +0 / ~0 / -4 — OK
52 −- **Erreurs récentes** : 0 sur les 3 derniers syncs
60 +- **Dernier sync** : 2026-08-18 17:47 — trouvé 93, +0 / ~0 / -0 — OK
61 +- **Erreurs récentes** : 0 sur les 6 derniers syncs
53 62
54 63 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/c21_ag_quebec.md +10 −10
@@ -1,6 +1,6 @@
1 1 # Century 21 Québec (All Quebec) (`c21_ag_quebec`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/century21_agences.py` — classe `C21AgQuebec`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -38,22 +38,22 @@
38 38
39 39 | Indicateur | Valeur |
40 40 |---|---|
41 −| Annonces en base (toutes) | 1 663 |
42 −| Actives | 1 603 |
43 −| Visibles (après dédup) | 1 576 |
41 +| Annonces en base (toutes) | 1 674 |
42 +| Actives | 1 612 |
43 +| Visibles (après dédup) | 1 585 |
44 44 | Masquées par la dédup | 27 |
45 45 | GPS (lat/lng) | 99.9 % |
46 46 | Photos | 100.0 % |
47 47 | Prix | 100.0 % |
48 48 | CAC (chambres) | 75.1 % |
49 −| SDB (salles de bains) | 72.8 % |
50 −| Année de construction | 75.6 % (dont rôle : 10.0 %) |
51 −| Taxes / évaluation municipale | 83.7 % |
52 −| Estimation Vrai-Prix (rôle) | 100.0 % |
49 +| SDB (salles de bains) | 72.9 % |
50 +| Année de construction | 75.7 % (dont rôle : 9.9 %) |
51 +| Taxes / évaluation municipale | 83.8 % |
52 +| Estimation Vrai-Prix (rôle) | 99.6 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 02:35 — trouvé 1 603, +0 / ~0 / -0 — OK
56 +- **Dernier sync** : 2026-08-18 17:47 — trouvé 1 603, +6 / ~7 / -0 — OK
57 57 - **Erreurs récentes** : 0 sur les 10 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/c21_ag_vision.md +14 −14
@@ -1,6 +1,6 @@
1 1 # Century 21 Vision (Outaouais) (`c21_ag_vision`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/century21_agences.py` — classe `C21AgVision`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -38,22 +38,22 @@
38 38
39 39 | Indicateur | Valeur |
40 40 |---|---|
41 −| Annonces en base (toutes) | 142 |
42 −| Actives | 138 |
43 −| Visibles (après dédup) | 0 |
41 +| Annonces en base (toutes) | 143 |
42 +| Actives | 139 |
43 +| Visibles (après dédup) | 1 |
44 44 | Masquées par la dédup | 138 |
45 −| GPS (lat/lng) | — |
46 −| Photos | — |
47 −| Prix | — |
48 −| CAC (chambres) | — |
49 −| SDB (salles de bains) | — |
50 −| Année de construction | — |
51 −| Taxes / évaluation municipale | — |
52 −| Estimation Vrai-Prix (rôle) | — |
45 +| GPS (lat/lng) | 100.0 % |
46 +| Photos | 100.0 % |
47 +| Prix | 100.0 % |
48 +| CAC (chambres) | 100.0 % |
49 +| SDB (salles de bains) | 100.0 % |
50 +| Année de construction | 100.0 % (dont rôle : 0.0 %) |
51 +| Taxes / évaluation municipale | 100.0 % |
52 +| Estimation Vrai-Prix (rôle) | 0.0 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 02:35 — trouvé 138, +0 / ~0 / -0 — OK
56 +- **Dernier sync** : 2026-08-18 17:47 — trouvé 139, +1 / ~1 / -0 — OK
57 57 - **Erreurs récentes** : 0 sur les 10 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/century21.md +1 −1
@@ -1,6 +1,6 @@
1 1 # Century 21 (Québec) (`century21`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
modified docs/connecteurs/charisma_ag_qc.md +14 −5
@@ -1,6 +1,6 @@
1 1 # Les Immeubles Charisma (`charisma_ag_qc`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -16,6 +16,7 @@
16 16
17 17 - Module : `immoka/connectors/charisma.py` — classe `CharismaConnector`
18 18 - Délai entre requêtes : 0.4 s ; cache détail BD : oui
19 +- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_DETAIL_LIMIT` (défaut 200)
19 20
20 21 **En-tête du module :**
21 22
@@ -27,6 +28,14 @@
27 28 > liste rendue SERVEUR sur /fr/proprietes/, paginée par ?pages=N (~10/page,
28 29 > ~84 pages). Chaque carte (box-house) porte : n° Centris (URL + image), prix,
29 30 > ville, chambres/sdb et photo (property-images/{Centris}/).
31 +> ENRICHISSEMENT DÉTAIL (v2) : la page /fr/properties/{adresse}/{centris}/ est
32 +> rendue serveur elle aussi et porte TOUT : galerie fancybox complète (toutes
33 +> les photos property-images pleine taille, ordre d'origine), blocs
34 +> Description / Inclusions / Exclusions / Plus d'information, box-icons
35 +> (sdb, salles d'eau, année, chambres, superficie « 58.9 MC »), tableaux
36 +> th.prop-table/td (Bâtiment + Caractéristiques), Détails de pièce, courtier
37 +> inscripteur (seller-info : nom + tel), coordonnées GPS (google.maps.LatLng).
38 +> Cache BD via _detailutil.enrich (seules les fiches nouvelles sont relues).
30 39 > ⚠ DÉDUP : Charisma a fusionné avec L'Expert Immobilier P.M. (déjà couvert)
31 40 > en 2021 → beaucoup de co-inscriptions. source_id « charisma_ag_qc » : l'infixe
32 41 > _ag_ (db.refresh_dedup) masque les fiches dont le n° Centris est déjà porté
@@ -36,13 +45,13 @@
36 45
37 46 | Indicateur | Valeur |
38 47 |---|---|
39 −| Annonces en base (toutes) | 760 |
48 +| Annonces en base (toutes) | 761 |
40 49 | Actives | 755 |
41 50 | Visibles (après dédup) | 696 |
42 51 | Masquées par la dédup | 59 |
43 52 | GPS (lat/lng) | 92.4 % |
44 53 | Photos | 100.0 % |
45 −| Prix | 53.0 % |
54 +| Prix | 52.9 % |
46 55 | CAC (chambres) | 100.0 % |
47 56 | SDB (salles de bains) | 100.0 % |
48 57 | Année de construction | 81.2 % (dont rôle : 100.0 %) |
@@ -51,7 +60,7 @@
51 60
52 61 ## Synchronisation
53 62
54 −- **Dernier sync** : 2026-08-18 02:37 — trouvé 755, +0 / ~0 / -5 — OK
55 −- **Erreurs récentes** : 0 sur les 3 derniers syncs
63 +- **Dernier sync** : 2026-08-18 17:49 — trouvé 755, +0 / ~0 / -0 — OK
64 +- **Erreurs récentes** : 0 sur les 6 derniers syncs
56 65
57 66 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/cio_ag_outaouais.md +18 −11
@@ -1,6 +1,6 @@
1 1 # Chambre immobilière de l'Outaouais (`cio_ag_outaouais`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -16,7 +16,7 @@
16 16
17 17 - Module : `immoka/connectors/avecuncourtier.py` — classe `AvecUnCourtierConnector`
18 18 - Délai entre requêtes : 0.4 s ; cache détail BD : oui
19 −- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_AUC_MAX_PAGES` (défaut 400)
19 +- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_AUC_MAX_PAGES` (défaut 400), `IMMOKA_DETAIL_LIMIT` (défaut 300)
20 20
21 21 **En-tête du module :**
22 22
@@ -29,6 +29,13 @@
29 29 > Concrete5, Tonik Web Studio), paginée par ?page=N (12 cartes/page, ~341
30 30 > pages). Chaque carte porte déjà : n° MLS (Centris), adresse, ville/secteur,
31 31 > type, aire habitable, prix et photo.
32 +> ENRICHISSEMENT DÉTAIL (v2) : /fr/proprietes/details/{mls}-{slug} est rendue
33 +> serveur : galerie complète (/centris/photos/{mls}/{n}.jpg, ordre d'origine),
34 +> description + Addendas, GPS (data-lat/data-lon), accordéons « Proximité »,
35 +> « Dimension des pièces » (4 col.), « Taxes et dépenses » + « Bâtiment et
36 +> intérieur » (paires td/td : chambres, sdb, aire habitable en m², terrain,
37 +> année, évaluations…), visites libres, courtier(s) inscripteur(s) + agence
38 +> réelle (RE/MAX Vision, Exit…). Cache BD via _detailutil.enrich.
32 39 > source_id « cio_ag_outaouais » : l'infixe _ag_ active la dédup Centris
33 40 > (db.refresh_dedup, sources %_ag_%). Comme le portail agrège les inscriptions
34 41 > des bannières déjà couvertes (RE/MAX, Royal LePage, Via Capitale, Proprio
@@ -40,22 +47,22 @@
40 47
41 48 | Indicateur | Valeur |
42 49 |---|---|
43 −| Annonces en base (toutes) | 4 148 |
44 −| Actives | 4 104 |
45 −| Visibles (après dédup) | 1 394 |
46 −| Masquées par la dédup | 2 710 |
47 −| GPS (lat/lng) | 93.7 % |
50 +| Annonces en base (toutes) | 4 177 |
51 +| Actives | 4 110 |
52 +| Visibles (après dédup) | 1 405 |
53 +| Masquées par la dédup | 2 705 |
54 +| GPS (lat/lng) | 92.7 % |
48 55 | Photos | 100.0 % |
49 56 | Prix | 99.9 % |
50 57 | CAC (chambres) | 33.7 % |
51 58 | SDB (salles de bains) | 0.0 % |
52 −| Année de construction | 69.6 % (dont rôle : 100.0 %) |
59 +| Année de construction | 68.8 % (dont rôle : 98.0 %) |
53 60 | Taxes / évaluation municipale | 0.0 % |
54 −| Estimation Vrai-Prix (rôle) | 99.9 % |
61 +| Estimation Vrai-Prix (rôle) | 98.7 % |
55 62
56 63 ## Synchronisation
57 64
58 −- **Dernier sync** : 2026-08-18 02:35 — trouvé 4 104, +0 / ~0 / -44 — OK
59 −- **Erreurs récentes** : 0 sur les 3 derniers syncs
65 +- **Dernier sync** : 2026-08-18 17:47 — trouvé 4 093, +18 / ~18 / -18 — OK
66 +- **Erreurs récentes** : 0 sur les 6 derniers syncs
60 67
61 68 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/coldwell_banker.md +1 −1
@@ -1,6 +1,6 @@
1 1 # Coldwell Banker Québec (`coldwell_banker`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
modified docs/connecteurs/contact_ag_qc.md +3 −3
@@ -1,6 +1,6 @@
1 1 # Les Immeubles Contact (`contact_ag_qc`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -50,7 +50,7 @@
50 50
51 51 ## Synchronisation
52 52
53 −- **Dernier sync** : 2026-08-18 02:40 — trouvé 43, +0 / ~0 / -1 — OK
54 −- **Erreurs récentes** : 0 sur les 3 derniers syncs
53 +- **Dernier sync** : 2026-08-18 17:52 — trouvé 43, +0 / ~0 / -0 — OK
54 +- **Erreurs récentes** : 0 sur les 6 derniers syncs
55 55
56 56 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/domicilia.md +10 −10
@@ -1,6 +1,6 @@
1 1 # Domicilia Agence Immobilière (`domicilia`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSIDomicilia`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -38,22 +38,22 @@
38 38
39 39 | Indicateur | Valeur |
40 40 |---|---|
41 −| Annonces en base (toutes) | 1 485 |
42 −| Actives | 1 445 |
43 −| Visibles (après dédup) | 1 445 |
41 +| Annonces en base (toutes) | 1 492 |
42 +| Actives | 1 449 |
43 +| Visibles (après dédup) | 1 449 |
44 44 | Masquées par la dédup | 0 |
45 45 | GPS (lat/lng) | 99.9 % |
46 46 | Photos | 100.0 % |
47 47 | Prix | 100.0 % |
48 −| CAC (chambres) | 67.8 % |
49 −| SDB (salles de bains) | 66.6 % |
50 −| Année de construction | 73.4 % (dont rôle : 6.2 %) |
48 +| CAC (chambres) | 67.7 % |
49 +| SDB (salles de bains) | 66.5 % |
50 +| Année de construction | 73.4 % (dont rôle : 6.1 %) |
51 51 | Taxes / évaluation municipale | 89.5 % |
52 −| Estimation Vrai-Prix (rôle) | 100.0 % |
52 +| Estimation Vrai-Prix (rôle) | 99.9 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 02:27 — trouvé 1 445, +0 / ~0 / -0 — OK
56 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 1 446, +1 / ~1 / -1 — OK
57 57 - **Erreurs récentes** : 0 sur les 10 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/duproprio.md +8 −8
@@ -1,6 +1,6 @@
1 1 # DuProprio (sans courtier) (`duproprio`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -36,22 +36,22 @@
36 36
37 37 | Indicateur | Valeur |
38 38 |---|---|
39 −| Annonces en base (toutes) | 7 569 |
40 −| Actives | 7 393 |
41 −| Visibles (après dédup) | 7 393 |
39 +| Annonces en base (toutes) | 7 586 |
40 +| Actives | 7 411 |
41 +| Visibles (après dédup) | 7 411 |
42 42 | Masquées par la dédup | 0 |
43 −| GPS (lat/lng) | 90.8 % |
43 +| GPS (lat/lng) | 90.9 % |
44 44 | Photos | 100.0 % |
45 45 | Prix | 98.6 % |
46 46 | CAC (chambres) | 80.5 % |
47 47 | SDB (salles de bains) | 79.8 % |
48 −| Année de construction | 79.3 % (dont rôle : 9.0 %) |
48 +| Année de construction | 79.3 % (dont rôle : 8.9 %) |
49 49 | Taxes / évaluation municipale | 52.7 % |
50 −| Estimation Vrai-Prix (rôle) | 100.0 % |
50 +| Estimation Vrai-Prix (rôle) | 99.8 % |
51 51
52 52 ## Synchronisation
53 53
54 −- **Dernier sync** : 2026-08-18 02:37 — trouvé 7 393, +0 / ~0 / -30 — OK
54 +- **Dernier sync** : 2026-08-18 17:49 — trouvé 7 395, +17 / ~4 / -0 — OK
55 55 - **Erreurs récentes** : 0 sur les 10 derniers syncs
56 56
57 57 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/engel_volkers.md +9 −9
@@ -1,6 +1,6 @@
1 1 # Engel & Völkers Montréal / Québec (`engel_volkers`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -32,22 +32,22 @@
32 32
33 33 | Indicateur | Valeur |
34 34 |---|---|
35 −| Annonces en base (toutes) | 1 282 |
36 −| Actives | 1 228 |
37 −| Visibles (après dédup) | 1 228 |
35 +| Annonces en base (toutes) | 1 289 |
36 +| Actives | 1 232 |
37 +| Visibles (après dédup) | 1 232 |
38 38 | Masquées par la dédup | 0 |
39 −| GPS (lat/lng) | 87.1 % |
39 +| GPS (lat/lng) | 87.3 % |
40 40 | Photos | 98.9 % |
41 −| Prix | 89.7 % |
41 +| Prix | 89.9 % |
42 42 | CAC (chambres) | 35.2 % |
43 43 | SDB (salles de bains) | 20.4 % |
44 −| Année de construction | 69.9 % (dont rôle : 100.0 %) |
44 +| Année de construction | 70.0 % (dont rôle : 100.0 %) |
45 45 | Taxes / évaluation municipale | 0.0 % |
46 −| Estimation Vrai-Prix (rôle) | 89.3 % |
46 +| Estimation Vrai-Prix (rôle) | 89.4 % |
47 47
48 48 ## Synchronisation
49 49
50 −- **Dernier sync** : 2026-08-18 02:37 — trouvé 1 228, +0 / ~0 / -0 — OK
50 +- **Dernier sync** : 2026-08-18 17:49 — trouvé 1 232, +0 / ~0 / -3 — OK
51 51 - **Erreurs récentes** : 0 sur les 10 derniers syncs
52 52
53 53 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/exp_ag_qc.md +21 −12
@@ -1,6 +1,6 @@
1 1 # eXp Agence immobilière (Québec) (`exp_ag_qc`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -10,13 +10,13 @@
10 10 - **Type** : agence
11 11 - **Statut** : actif
12 12
13 −> Site officiel eXp QC (expquebec.com, WordPress + MW Properties). API cliente marketingwebsites.ca bloquée (whitelist IP) MAIS inventaire complet en SSR sur /fr/properties/?pages=N (9/page, param 'pages', stop après 3 pages vides). Carte: data-href mls={Centris}, data-price ($US), data-address, data-pic. Ville via map-property.php?mls={Centris}&lang=fr (enrich requests, plafonné+cache). source_id _ag_ = dédup Centris.
13 +> Site officiel eXp QC (expquebec.com, WordPress + MW Properties). API cliente marketingwebsites.ca bloquée (whitelist IP) MAIS inventaire complet en SSR sur /fr/properties/?pages=N (9/page, param 'pages', stop après 3 pages vides). Carte: data-href mls={Centris}, data-price ($US), data-address, data-pic. Fiche détail /fr/properties/mls/{Centris}/ server-rendered et riche: galerie complète pleine résolution (ancres data-lightbox), remarque+addendum, inclusions/exclusions, icônes (CAC/SDB/superficie MC|PC/année), tables BÂTIMENT/ÉVALUATION/DÉPENSES/CARACTÉRISTIQUES, pièces, GPS exact (embed Street View), courtier+tél. Enrich plafonné IMMOKA_EXP_DETAIL_LIMIT (400/cycle) + cache BD; repli ville via map-property.php (IMMOKA_EXP_CITY_LIMIT). source_id _ag_ = dédup Centris.
14 14
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/exp_quebec.py` — classe `ExpQuebecConnector`
18 18 - Délai entre requêtes : 0.4 s ; cache détail BD : oui
19 −- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_EXP_DETAIL_LIMIT` (défaut 1200)
19 +- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_EXP_DETAIL_LIMIT` (défaut 400), `IMMOKA_EXP_CITY_LIMIT` (défaut 1200)
20 20
21 21 **En-tête du module :**
22 22
@@ -29,9 +29,18 @@
29 29 > /fr/properties/?pages={N} (9 fiches/page, param « pages » uniquement).
30 30 > Chaque carte porte data-href /fr/properties/mls/{MLS}, data-price ($US),
31 31 > data-address (rue) et data-pic (property-images/{MLS}/). Le n° MLS = n° Centris.
32 −> La VILLE (absente de la liste) vient de l'endpoint SSR interne
33 −> /wp-content/themes/canvas/load/map-property.php?mls={MLS}&lang=fr
34 −> (enrichissement requests direct, plafonné + cache).
32 +> FICHE DÉTAIL /fr/properties/mls/{MLS}/ : server-rendered elle aussi
33 +> (requêtes directes, aucun anti-bot) et TRÈS riche — galerie complète en
34 +> pleine résolution (~1728px, ancres data-lightbox="gallery-item"), remarque
35 +> du courtier + addendum, inclusions/exclusions, icônes (chambres/sdb/
36 +> superficie MC|PC/année), tables th/td par section (BÂTIMENT, ÉVALUATION,
37 +> DÉPENSES, CARACTÉRISTIQUES, DÉTAILS DE PIÈCE), GPS exact dans l'embed
38 +> Street View, courtier inscripteur (photo alt + tel: du bloc latéral).
39 +> Enrichissement via _detailutil.enrich (cache BD + plafond
40 +> IMMOKA_EXP_DETAIL_LIMIT, ~400/cycle).
41 +> La VILLE vient de la fiche détail (en-tête h2) ; repli sur l'endpoint SSR
42 +> /wp-content/themes/canvas/load/map-property.php?mls={MLS}&lang=fr pour les
43 +> fiches au-delà du budget détail (cache « city » déjà peuplé).
35 44 > ⚠ PLAFOND : le SSR reflète le limit=1000 de l'API sous-jacente → au plus
36 45 > ~1000 fiches (pages 1..~114, crawl vérifié). C'est l'inventaire pratique
37 46 > complet ; si eXp QC dépasse un jour 1000 inscriptions, segmenter par région
@@ -43,22 +52,22 @@
43 52
44 53 | Indicateur | Valeur |
45 54 |---|---|
46 −| Annonces en base (toutes) | 1 018 |
55 +| Annonces en base (toutes) | 1 023 |
47 56 | Actives | 1 000 |
48 57 | Visibles (après dédup) | 888 |
49 58 | Masquées par la dédup | 112 |
50 −| GPS (lat/lng) | 67.3 % |
59 +| GPS (lat/lng) | 67.1 % |
51 60 | Photos | 100.0 % |
52 61 | Prix | 99.8 % |
53 −| CAC (chambres) | 14.6 % |
62 +| CAC (chambres) | 14.4 % |
54 63 | SDB (salles de bains) | 0.0 % |
55 −| Année de construction | 55.6 % (dont rôle : 100.0 %) |
64 +| Année de construction | 55.4 % (dont rôle : 99.6 %) |
56 65 | Taxes / évaluation municipale | 0.0 % |
57 66 | Estimation Vrai-Prix (rôle) | 100.0 % |
58 67
59 68 ## Synchronisation
60 69
61 −- **Dernier sync** : 2026-08-18 02:40 — trouvé 1 000, +0 / ~0 / -18 — OK
62 −- **Erreurs récentes** : 0 sur les 3 derniers syncs
70 +- **Dernier sync** : 2026-08-18 17:52 — trouvé 1 000, +0 / ~0 / -0 — OK
71 +- **Erreurs récentes** : 0 sur les 6 derniers syncs
63 72
64 73 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/expert_immobilier_pm.md +4 −4
@@ -1,6 +1,6 @@
1 1 # L'Expert Immobilier P.M. (`expert_immobilier_pm`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -33,13 +33,13 @@
33 33
34 34 | Indicateur | Valeur |
35 35 |---|---|
36 −| Annonces en base (toutes) | 26 |
36 +| Annonces en base (toutes) | 27 |
37 37 | Actives | 9 |
38 38 | Visibles (après dédup) | 9 |
39 39 | Masquées par la dédup | 0 |
40 40 | GPS (lat/lng) | 88.9 % |
41 41 | Photos | 100.0 % |
42 −| Prix | 88.9 % |
42 +| Prix | 77.8 % |
43 43 | CAC (chambres) | 100.0 % |
44 44 | SDB (salles de bains) | 100.0 % |
45 45 | Année de construction | 88.9 % (dont rôle : 100.0 %) |
@@ -48,7 +48,7 @@
48 48
49 49 ## Synchronisation
50 50
51 −- **Dernier sync** : 2026-08-18 02:40 — trouvé 9, +0 / ~0 / -0 — OK
51 +- **Dernier sync** : 2026-08-18 17:52 — trouvé 9, +0 / ~0 / -0 — OK
52 52 - **Erreurs récentes** : 0 sur les 10 derniers syncs
53 53
54 54 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/famille-c21-ag.md +10 −10
@@ -1,6 +1,6 @@
1 1 # Famille Century 21 (`c21_ag_*`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 Connecteurs par compte Century 21, générés depuis `data/century21_agencies.json` (plateforme source.immo).
6 6
@@ -29,8 +29,8 @@ Connecteurs par compte Century 21, générés depuis `data/century21_agencies.js
29 29
30 30 | source_id | Domaine | Plateforme | Visibles | Actives | Masquées dédup | Dernier sync | OK |
31 31 |---|---|---|---|---|---|---|---|
32 −| [`c21_ag_quebec`](c21_ag_quebec.md) | — | source.immo | 1 576 | 1 603 | 27 | 2026-08-18 02:35 | ✅ |
33 −| [`c21_ag_vision`](c21_ag_vision.md) | — | source.immo | 0 | 138 | 138 | 2026-08-18 02:35 | ✅ |
32 +| [`c21_ag_quebec`](c21_ag_quebec.md) | — | source.immo | 1 585 | 1 612 | 27 | 2026-08-18 17:47 | ✅ |
33 +| [`c21_ag_vision`](c21_ag_vision.md) | — | source.immo | 1 | 139 | 138 | 2026-08-18 17:47 | ✅ |
34 34
35 35 Entrées du registre sans connecteur instancié (2) : `quebec.c21.ca`, `vision.c21.ca`
36 36
@@ -38,16 +38,16 @@ Entrées du registre sans connecteur instancié (2) : `quebec.c21.ca`, `vision.c
38 38
39 39 | Indicateur | Valeur |
40 40 |---|---|
41 −| Annonces en base (toutes) | 1 805 |
42 −| Actives | 1 741 |
43 −| Visibles (après dédup) | 1 576 |
41 +| Annonces en base (toutes) | 1 817 |
42 +| Actives | 1 751 |
43 +| Visibles (après dédup) | 1 586 |
44 44 | Masquées par la dédup | 165 |
45 45 | GPS (lat/lng) | 99.9 % |
46 46 | Photos | 100.0 % |
47 47 | Prix | 100.0 % |
48 48 | CAC (chambres) | 75.1 % |
49 −| SDB (salles de bains) | 72.8 % |
50 −| Année de construction | 75.6 % (dont rôle : 10.0 %) |
51 −| Taxes / évaluation municipale | 83.7 % |
52 −| Estimation Vrai-Prix (rôle) | 100.0 % |
49 +| SDB (salles de bains) | 72.9 % |
50 +| Année de construction | 75.7 % (dont rôle : 9.9 %) |
51 +| Taxes / évaluation municipale | 83.8 % |
52 +| Estimation Vrai-Prix (rôle) | 99.6 % |
53 53
modified docs/connecteurs/famille-remax-ag.md +56 −56
@@ -1,6 +1,6 @@
1 1 # Famille RE/MAX sous-agences (`remax_ag_*`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 Un connecteur par sous-agence RE/MAX (sites propres, indépendants du portail central), généré dynamiquement depuis `data/remax_agencies.json`.
6 6
@@ -36,56 +36,56 @@ Budgets env : `IMMOKA_AGENCE_DETAIL_LIMIT` (défaut 0)
36 36
37 37 | source_id | Domaine | Plateforme | Visibles | Actives | Masquées dédup | Dernier sync | OK |
38 38 |---|---|---|---|---|---|---|---|
39 −| `remax_ag_1erchoix` | www.remax1erchoix.com | nos-proprietes | 203 | 1 056 | 853 | 2026-08-18 03:37 | ✅ |
40 −| `remax_ag_2000` | www.remax-2000.com | centris-other | 65 | 728 | 663 | 2026-08-18 03:37 | ✅ |
41 −| `remax_ag_2001` | www.remax2001.com | nos-proprietes | 37 | 366 | 329 | 2026-08-18 03:37 | ✅ |
42 −| `remax_ag_3000` | remax3000.com | nos-proprietes | 46 | 775 | 729 | 2026-08-18 03:37 | ✅ |
43 −| `remax_ag_acces` | www.remaxacces.com | nos-proprietes | 135 | 617 | 482 | 2026-08-18 03:37 | ✅ |
44 −| `remax_ag_action` | remax-action.ca | nos-proprietes | 79 | 1 355 | 1 276 | 2026-08-18 03:37 | ✅ |
45 −| `remax_ag_alliance` | www.remax-alliance.ca | nos-proprietes | 170 | 1 243 | 1 073 | 2026-08-18 03:37 | ✅ |
46 −| `remax_ag_avantages` | www.remax-avantages.com | nos-proprietes | 169 | 765 | 596 | 2026-08-18 03:37 | ✅ |
47 −| `remax_ag_avanttout` | www.remaxavanttout.com | nos-proprietes | 35 | 402 | 367 | 2026-08-18 03:37 | ✅ |
48 −| `remax_ag_cite` | remax-cite.com | nos-proprietes | 12 | 116 | 104 | 2026-08-18 03:37 | ✅ |
49 −| `remax_ag_crystal` | remaxcrystal.com | nos-proprietes | 157 | 1 294 | 1 137 | 2026-08-18 03:37 | ✅ |
50 −| `remax_ag_d_ici` | remaxdici.com | nos-proprietes | 126 | 866 | 740 | 2026-08-18 03:37 | ✅ |
51 −| `remax_ag_dabord` | www.remax-dabord.com | centris-other | 116 | 786 | 670 | 2026-08-18 03:37 | ✅ |
52 −| `remax_ag_defi` | remax-defi1996.com | nos-proprietes | 26 | 347 | 321 | 2026-08-18 03:37 | ✅ |
53 −| `remax_ag_defrancheville` | remaxdefrancheville.com | nos-proprietes | 162 | 1 457 | 1 295 | 2026-08-18 03:37 | ✅ |
54 −| `remax_ag_direct` | remax-direct.com | nos-proprietes | 26 | 1 057 | 1 031 | 2026-08-18 03:37 | ✅ |
55 −| `remax_ag_distinction` | remaxdistinction.com | nos-proprietes | 26 | 148 | 122 | 2026-08-18 03:37 | ✅ |
56 −| `remax_ag_ducartier` | www.remaxducartier.com | centris-other | 215 | 1 838 | 1 623 | 2026-08-18 03:37 | ✅ |
57 −| `remax_ag_ducartieractif` | www.remaxactif.com | centris-other | 25 | 119 | 94 | 2026-08-18 03:37 | ✅ |
58 −| `remax_ag_ducartierbonjour` | remaxbonjour.com | centris-other | 110 | 1 538 | 1 428 | 2026-08-18 03:37 | ✅ |
59 −| `remax_ag_dynamique` | remaxdynamique.com | nos-proprietes | 2 | 150 | 148 | 2026-08-18 03:37 | ✅ |
60 −| `remax_ag_dynastie` | remax-dynastie.com | centris-other | 5 | 236 | 231 | 2026-08-18 03:37 | ✅ |
61 −| `remax_ag_elegance` | remax-elegance.ca | nos-proprietes | 12 | 86 | 74 | 2026-08-18 03:37 | ✅ |
62 −| `remax_ag_elite` | remax-elite.ca | nos-proprietes | 78 | 851 | 773 | 2026-08-18 03:37 | ✅ |
63 −| `remax_ag_energie` | remax-energie.com | nos-proprietes | 8 | 173 | 165 | 2026-08-18 03:37 | ✅ |
64 −| `remax_ag_evolution` | remax-evolution.com | centris-other | 39 | 494 | 455 | 2026-08-18 03:37 | ✅ |
65 −| `remax_ag_excellence` | remax-excellence.ca | nos-proprietes | 1 | 281 | 280 | 2026-08-18 03:37 | ✅ |
66 −| `remax_ag_extra` | remax-extra.ca | centris-other | 122 | 672 | 550 | 2026-08-18 03:37 | ✅ |
67 −| `remax_ag_fortindelage` | remaxfortindelage.com | nos-proprietes | 65 | 406 | 341 | 2026-08-18 03:37 | ✅ |
68 −| `remax_ag_futur` | remax-futur.ca | centris-other | 3 | 195 | 192 | 2026-08-18 03:37 | ✅ |
69 −| `remax_ag_harmonie` | remaxharmonie.com | nos-proprietes | 55 | 373 | 318 | 2026-08-18 03:37 | ✅ |
70 −| `remax_ag_harricana` | remax-harricana.com | nos-proprietes | 6 | 155 | 149 | 2026-08-18 03:37 | ✅ |
71 −| `remax_ag_horizon` | remax-horizon.ca | nos-proprietes | 93 | 463 | 370 | 2026-08-18 03:37 | ✅ |
72 −| `remax_ag_imagine` | remax-imagineprivilege.com | nos-proprietes | 59 | 452 | 393 | 2026-08-18 03:37 | ✅ |
73 −| `remax_ag_immobilia` | remaximmobilia.ca | nos-proprietes | 0 | 7 | 7 | 2026-08-18 03:38 | ✅ |
74 −| `remax_ag_immobilierplus` | remax-immobilierplus.com | nos-proprietes | 5 | 56 | 51 | 2026-08-18 03:38 | ✅ |
75 −| `remax_ag_infinite` | remax-infinite.com | nos-proprietes | 0 | 59 | 59 | 2026-08-18 03:38 | ✅ |
76 −| `remax_ag_invest` | remaxinvest.ca | nos-proprietes | 0 | 1 | 1 | 2026-08-18 03:38 | ✅ |
77 −| `remax_ag_lespace` | www.remaxlespace.com | nos-proprietes | 38 | 469 | 431 | 2026-08-18 03:38 | ✅ |
78 −| `remax_ag_lespace_commercial` | remaxlespace.com | nos-proprietes | 0 | 469 | 469 | 2026-08-18 03:38 | ✅ |
79 −| `remax_ag_performance` | remaxperformance.net | nos-proprietes | 19 | 203 | 184 | 2026-08-18 03:38 | ✅ |
80 −| `remax_ag_platine` | www.remax-platine.com | nos-proprietes | 95 | 1 303 | 1 208 | 2026-08-18 03:38 | ✅ |
81 −| `remax_ag_plus` | remaxplus.ca | nos-proprietes | 70 | 348 | 278 | 2026-08-18 03:38 | ✅ |
82 −| `remax_ag_prestige` | remaxprestige.ca | nos-proprietes | 88 | 476 | 388 | 2026-08-18 03:38 | ✅ |
83 −| `remax_ag_privilege` | remax-imagineprivilege.com | nos-proprietes | 0 | 452 | 452 | 2026-08-18 03:38 | ✅ |
84 −| `remax_ag_professionnel` | www.remax-professionnel.com | nos-proprietes | 74 | 722 | 648 | 2026-08-18 03:38 | ✅ |
85 −| `remax_ag_renaissance` | remax-renaissance.ca | nos-proprietes | 34 | 300 | 266 | 2026-08-18 03:38 | ✅ |
86 −| `remax_ag_royaljordan` | remax-royaljordan.com | nos-proprietes | 130 | 1 303 | 1 173 | 2026-08-18 03:38 | ✅ |
87 −| `remax_ag_signature` | remaxsignature.ca | nos-proprietes | 80 | 561 | 481 | 2026-08-18 03:38 | ✅ |
88 −| `remax_ag_vision` | remaxvision.ca | nos-proprietes | 29 | 1 067 | 1 038 | 2026-08-18 03:38 | ✅ |
39 +| `remax_ag_1erchoix` | www.remax1erchoix.com | nos-proprietes | 203 | 1 059 | 856 | 2026-08-18 13:15 | ✅ |
40 +| `remax_ag_2000` | www.remax-2000.com | centris-other | 65 | 728 | 663 | 2026-08-18 13:16 | ✅ |
41 +| `remax_ag_2001` | www.remax2001.com | nos-proprietes | 37 | 368 | 331 | 2026-08-18 13:16 | ✅ |
42 +| `remax_ag_3000` | remax3000.com | nos-proprietes | 47 | 777 | 730 | 2026-08-18 13:16 | ✅ |
43 +| `remax_ag_acces` | www.remaxacces.com | nos-proprietes | 134 | 616 | 482 | 2026-08-18 13:16 | ✅ |
44 +| `remax_ag_action` | remax-action.ca | nos-proprietes | 79 | 1 357 | 1 278 | 2026-08-18 13:16 | ✅ |
45 +| `remax_ag_alliance` | www.remax-alliance.ca | nos-proprietes | 170 | 1 241 | 1 071 | 2026-08-18 13:16 | ✅ |
46 +| `remax_ag_avantages` | www.remax-avantages.com | nos-proprietes | 169 | 769 | 600 | 2026-08-18 13:16 | ✅ |
47 +| `remax_ag_avanttout` | www.remaxavanttout.com | nos-proprietes | 35 | 402 | 367 | 2026-08-18 13:16 | ✅ |
48 +| `remax_ag_cite` | remax-cite.com | nos-proprietes | 12 | 116 | 104 | 2026-08-18 13:16 | ✅ |
49 +| `remax_ag_crystal` | remaxcrystal.com | nos-proprietes | 163 | 1 303 | 1 140 | 2026-08-18 13:16 | ✅ |
50 +| `remax_ag_d_ici` | remaxdici.com | nos-proprietes | 128 | 869 | 741 | 2026-08-18 13:16 | ✅ |
51 +| `remax_ag_dabord` | www.remax-dabord.com | centris-other | 116 | 786 | 670 | 2026-08-18 13:16 | ✅ |
52 +| `remax_ag_defi` | remax-defi1996.com | nos-proprietes | 26 | 349 | 323 | 2026-08-18 13:16 | ✅ |
53 +| `remax_ag_defrancheville` | remaxdefrancheville.com | nos-proprietes | 164 | 1 459 | 1 295 | 2026-08-18 13:16 | ✅ |
54 +| `remax_ag_direct` | remax-direct.com | nos-proprietes | 26 | 1 060 | 1 034 | 2026-08-18 13:16 | ✅ |
55 +| `remax_ag_distinction` | remaxdistinction.com | nos-proprietes | 27 | 149 | 122 | 2026-08-18 13:16 | ✅ |
56 +| `remax_ag_ducartier` | www.remaxducartier.com | centris-other | 215 | 1 838 | 1 623 | 2026-08-18 13:16 | ✅ |
57 +| `remax_ag_ducartieractif` | www.remaxactif.com | centris-other | 25 | 119 | 94 | 2026-08-18 13:16 | ✅ |
58 +| `remax_ag_ducartierbonjour` | remaxbonjour.com | centris-other | 112 | 1 538 | 1 426 | 2026-08-18 13:16 | ✅ |
59 +| `remax_ag_dynamique` | remaxdynamique.com | nos-proprietes | 2 | 151 | 149 | 2026-08-18 13:16 | ✅ |
60 +| `remax_ag_dynastie` | remax-dynastie.com | centris-other | 5 | 236 | 231 | 2026-08-18 13:16 | ✅ |
61 +| `remax_ag_elegance` | remax-elegance.ca | nos-proprietes | 12 | 86 | 74 | 2026-08-18 13:16 | ✅ |
62 +| `remax_ag_elite` | remax-elite.ca | nos-proprietes | 80 | 853 | 773 | 2026-08-18 13:16 | ✅ |
63 +| `remax_ag_energie` | remax-energie.com | nos-proprietes | 8 | 173 | 165 | 2026-08-18 13:16 | ✅ |
64 +| `remax_ag_evolution` | remax-evolution.com | centris-other | 39 | 494 | 455 | 2026-08-18 13:16 | ✅ |
65 +| `remax_ag_excellence` | remax-excellence.ca | nos-proprietes | 1 | 283 | 282 | 2026-08-18 13:16 | ✅ |
66 +| `remax_ag_extra` | remax-extra.ca | centris-other | 123 | 672 | 549 | 2026-08-18 13:16 | ✅ |
67 +| `remax_ag_fortindelage` | remaxfortindelage.com | nos-proprietes | 65 | 408 | 343 | 2026-08-18 13:16 | ✅ |
68 +| `remax_ag_futur` | remax-futur.ca | centris-other | 3 | 195 | 192 | 2026-08-18 13:16 | ✅ |
69 +| `remax_ag_harmonie` | remaxharmonie.com | nos-proprietes | 55 | 375 | 320 | 2026-08-18 13:16 | ✅ |
70 +| `remax_ag_harricana` | remax-harricana.com | nos-proprietes | 6 | 155 | 149 | 2026-08-18 13:16 | ✅ |
71 +| `remax_ag_horizon` | remax-horizon.ca | nos-proprietes | 92 | 463 | 371 | 2026-08-18 13:16 | ✅ |
72 +| `remax_ag_imagine` | remax-imagineprivilege.com | nos-proprietes | 59 | 454 | 395 | 2026-08-18 13:16 | ✅ |
73 +| `remax_ag_immobilia` | remaximmobilia.ca | nos-proprietes | 0 | 7 | 7 | 2026-08-18 13:16 | ✅ |
74 +| `remax_ag_immobilierplus` | remax-immobilierplus.com | nos-proprietes | 5 | 56 | 51 | 2026-08-18 13:16 | ✅ |
75 +| `remax_ag_infinite` | remax-infinite.com | nos-proprietes | 0 | 59 | 59 | 2026-08-18 13:16 | ✅ |
76 +| `remax_ag_invest` | remaxinvest.ca | nos-proprietes | 0 | 1 | 1 | 2026-08-18 13:16 | ✅ |
77 +| `remax_ag_lespace` | www.remaxlespace.com | nos-proprietes | 38 | 469 | 431 | 2026-08-18 13:16 | ✅ |
78 +| `remax_ag_lespace_commercial` | remaxlespace.com | nos-proprietes | 0 | 469 | 469 | 2026-08-18 13:16 | ✅ |
79 +| `remax_ag_performance` | remaxperformance.net | nos-proprietes | 19 | 203 | 184 | 2026-08-18 13:16 | ✅ |
80 +| `remax_ag_platine` | www.remax-platine.com | nos-proprietes | 99 | 1 308 | 1 209 | 2026-08-18 13:16 | ✅ |
81 +| `remax_ag_plus` | remaxplus.ca | nos-proprietes | 70 | 349 | 279 | 2026-08-18 13:16 | ✅ |
82 +| `remax_ag_prestige` | remaxprestige.ca | nos-proprietes | 88 | 476 | 388 | 2026-08-18 13:17 | ✅ |
83 +| `remax_ag_privilege` | remax-imagineprivilege.com | nos-proprietes | 0 | 454 | 454 | 2026-08-18 13:17 | ✅ |
84 +| `remax_ag_professionnel` | www.remax-professionnel.com | nos-proprietes | 73 | 720 | 647 | 2026-08-18 13:17 | ✅ |
85 +| `remax_ag_renaissance` | remax-renaissance.ca | nos-proprietes | 34 | 300 | 266 | 2026-08-18 13:17 | ✅ |
86 +| `remax_ag_royaljordan` | remax-royaljordan.com | nos-proprietes | 130 | 1 305 | 1 175 | 2026-08-18 13:17 | ✅ |
87 +| `remax_ag_signature` | remaxsignature.ca | nos-proprietes | 82 | 564 | 482 | 2026-08-18 13:17 | ✅ |
88 +| `remax_ag_vision` | remaxvision.ca | nos-proprietes | 31 | 1 070 | 1 039 | 2026-08-18 13:17 | ✅ |
89 89
90 90 Entrées du registre sans connecteur instancié (2) : `remax-pro-commercial.ca`, `nancyforlini.com`
91 91
@@ -93,16 +93,16 @@ Entrées du registre sans connecteur instancié (2) : `remax-pro-commercial.ca`,
93 93
94 94 | Indicateur | Valeur |
95 95 |---|---|
96 −| Annonces en base (toutes) | 31 174 |
97 −| Actives | 29 656 |
98 −| Visibles (après dédup) | 3 150 |
99 −| Masquées par la dédup | 26 506 |
96 +| Annonces en base (toutes) | 31 221 |
97 +| Actives | 29 711 |
98 +| Visibles (après dédup) | 3 172 |
99 +| Masquées par la dédup | 26 539 |
100 100 | GPS (lat/lng) | 97.1 % |
101 101 | Photos | 0.0 % |
102 102 | Prix | 0.0 % |
103 103 | CAC (chambres) | 7.9 % |
104 104 | SDB (salles de bains) | 0.0 % |
105 −| Année de construction | 87.1 % (dont rôle : 100.0 %) |
105 +| Année de construction | 86.9 % (dont rôle : 100.0 %) |
106 106 | Taxes / évaluation municipale | 0.0 % |
107 107 | Estimation Vrai-Prix (rôle) | 100.0 % |
108 108
modified docs/connecteurs/famille-source-immo.md +23 −23
@@ -1,6 +1,6 @@
1 1 # Famille source.immo (agences indépendantes)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 Agences indépendantes servies par la plateforme source.immo (comptes/API dans `data/source_immo_agencies.json`).
6 6
@@ -29,19 +29,19 @@ Agences indépendantes servies par la plateforme source.immo (comptes/API dans `
29 29
30 30 | source_id | Domaine | Plateforme | Visibles | Actives | Masquées dédup | Dernier sync | OK |
31 31 |---|---|---|---|---|---|---|---|
32 −| [`avenue`](avenue.md) | — | source.immo | 42 | 42 | 0 | 2026-08-18 05:56 | ✅ |
33 −| [`domicilia`](domicilia.md) | — | source.immo | 1 445 | 1 445 | 0 | 2026-08-18 02:27 | ✅ |
34 −| [`ferri`](ferri.md) | — | source.immo | 22 | 22 | 0 | 2026-08-18 05:56 | ✅ |
35 −| [`gaillardetz`](gaillardetz.md) | — | source.immo | 13 | 13 | 0 | 2026-08-18 02:27 | ✅ |
36 −| [`immodev`](immodev.md) | — | source.immo | 92 | 92 | 0 | 2026-08-18 02:27 | ✅ |
37 −| [`niddamour`](niddamour.md) | — | source.immo | 0 | 0 | 0 | 2026-08-18 02:27 | ✅ |
38 −| [`peladeau`](peladeau.md) | — | source.immo | 47 | 47 | 0 | 2026-08-18 05:56 | ✅ |
39 −| [`provad`](provad.md) | — | source.immo | 80 | 80 | 0 | 2026-08-18 05:56 | ✅ |
40 −| [`realta`](realta.md) | — | source.immo | 82 | 82 | 0 | 2026-08-18 02:27 | ✅ |
41 −| [`si_ag_ciemcq`](si_ag_ciemcq.md) | — | source.immo | 1 169 | 3 920 | 2 751 | 2026-08-18 05:56 | ✅ |
42 −| [`si_ag_jga`](si_ag_jga.md) | — | source.immo | 59 | 64 | 5 | 2026-08-18 05:56 | ✅ |
43 −| [`si_ag_katiasamson`](si_ag_katiasamson.md) | — | source.immo | 25 | 30 | 5 | 2026-08-18 05:56 | ✅ |
44 −| [`si_ag_theagency`](si_ag_theagency.md) | — | source.immo | 236 | 304 | 68 | 2026-08-18 02:27 | ✅ |
32 +| [`avenue`](avenue.md) | — | source.immo | 42 | 42 | 0 | 2026-08-18 17:38 | ✅ |
33 +| [`domicilia`](domicilia.md) | — | source.immo | 1 449 | 1 449 | 0 | 2026-08-18 17:38 | ✅ |
34 +| [`ferri`](ferri.md) | — | source.immo | 22 | 22 | 0 | 2026-08-18 17:38 | ✅ |
35 +| [`gaillardetz`](gaillardetz.md) | — | source.immo | 13 | 13 | 0 | 2026-08-18 18:10 | ✅ |
36 +| [`immodev`](immodev.md) | — | source.immo | 92 | 92 | 0 | 2026-08-18 18:10 | ✅ |
37 +| [`niddamour`](niddamour.md) | — | source.immo | 0 | 0 | 0 | 2026-08-18 17:38 | ✅ |
38 +| [`peladeau`](peladeau.md) | — | source.immo | 47 | 47 | 0 | 2026-08-18 17:38 | ✅ |
39 +| [`provad`](provad.md) | — | source.immo | 81 | 81 | 0 | 2026-08-18 17:38 | ✅ |
40 +| [`realta`](realta.md) | — | source.immo | 82 | 82 | 0 | 2026-08-18 18:09 | ✅ |
41 +| [`si_ag_ciemcq`](si_ag_ciemcq.md) | — | source.immo | 1 179 | 3 931 | 2 752 | 2026-08-18 17:38 | ✅ |
42 +| [`si_ag_jga`](si_ag_jga.md) | — | source.immo | 59 | 64 | 5 | 2026-08-18 17:38 | ✅ |
43 +| [`si_ag_katiasamson`](si_ag_katiasamson.md) | — | source.immo | 26 | 31 | 5 | 2026-08-18 17:38 | ✅ |
44 +| [`si_ag_theagency`](si_ag_theagency.md) | — | source.immo | 236 | 305 | 69 | 2026-08-18 17:38 | ✅ |
45 45
46 46 Entrées du registre sans connecteur instancié (13) : `www.realta.ca`, `www.immodev.ca`, `www.gaillardetz.ca`, `www.domicilia.com`, `www.niddamour.ca`, `theagencymontreal.com`, `www.mon-toit.net`, `avenueimmo.com`, `provad.ca`, `www.epeladeau.com`, `www.ferri.ca`, `jgacommercial.com`, `www.katiasamson.com`
47 47
@@ -49,16 +49,16 @@ Entrées du registre sans connecteur instancié (13) : `www.realta.ca`, `www.imm
49 49
50 50 | Indicateur | Valeur |
51 51 |---|---|
52 −| Annonces en base (toutes) | 6 189 |
53 −| Actives | 6 141 |
54 −| Visibles (après dédup) | 3 312 |
55 −| Masquées par la dédup | 2 829 |
52 +| Annonces en base (toutes) | 6 211 |
53 +| Actives | 6 159 |
54 +| Visibles (après dédup) | 3 328 |
55 +| Masquées par la dédup | 2 831 |
56 56 | GPS (lat/lng) | 99.9 % |
57 57 | Photos | 100.0 % |
58 58 | Prix | 100.0 % |
59 −| CAC (chambres) | 65.0 % |
60 −| SDB (salles de bains) | 61.6 % |
61 −| Année de construction | 70.0 % (dont rôle : 4.0 %) |
62 −| Taxes / évaluation municipale | 86.1 % |
63 −| Estimation Vrai-Prix (rôle) | 56.4 % |
59 +| CAC (chambres) | 65.1 % |
60 +| SDB (salles de bains) | 61.7 % |
61 +| Année de construction | 72.5 % (dont rôle : 6.6 %) |
62 +| Taxes / évaluation municipale | 86.2 % |
63 +| Estimation Vrai-Prix (rôle) | 99.6 % |
64 64
modified docs/connecteurs/famille-via-ag.md +13 −13
@@ -1,6 +1,6 @@
1 1 # Famille Via Capitale agences (`via_ag_*`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 Connecteurs par bannière Via Capitale, générés depuis `data/via_capitale_agencies.json` (comptes API source.immo par agence).
6 6
@@ -28,10 +28,10 @@ Connecteurs par bannière Via Capitale, générés depuis `data/via_capitale_age
28 28
29 29 | source_id | Domaine | Plateforme | Visibles | Actives | Masquées dédup | Dernier sync | OK |
30 30 |---|---|---|---|---|---|---|---|
31 −| [`via_ag_acces`](via_ag_acces.md) | — | source.immo | 7 | 181 | 174 | 2026-08-18 00:27 | ✅ |
32 −| [`via_ag_elite`](via_ag_elite.md) | — | source.immo | 10 | 319 | 309 | 2026-08-18 00:28 | ✅ |
33 −| `via_ag_partenaires` | — | source.immo | 20 | 444 | 424 | 2026-08-18 00:28 | ✅ |
34 −| [`via_ag_select`](via_ag_select.md) | — | source.immo | 13 | 580 | 567 | 2026-08-18 00:28 | ✅ |
31 +| [`via_ag_acces`](via_ag_acces.md) | — | source.immo | 7 | 182 | 175 | 2026-08-18 13:38 | ✅ |
32 +| [`via_ag_elite`](via_ag_elite.md) | — | source.immo | 9 | 317 | 308 | 2026-08-18 13:38 | ✅ |
33 +| `via_ag_partenaires` | — | source.immo | 20 | 444 | 424 | 2026-08-18 13:38 | ✅ |
34 +| [`via_ag_select`](via_ag_select.md) | — | source.immo | 11 | 584 | 573 | 2026-08-18 13:38 | ✅ |
35 35
36 36 Entrées du registre sans connecteur instancié (5) : `viacapitaleacces.com`, `viacapitaledumontroyal.com`, `viacapitaleelite.immo`, `viacapitalepartenaires.com`, `viacapitaleselect.com`
37 37
@@ -39,16 +39,16 @@ Entrées du registre sans connecteur instancié (5) : `viacapitaleacces.com`, `v
39 39
40 40 | Indicateur | Valeur |
41 41 |---|---|
42 −| Annonces en base (toutes) | 1 580 |
43 −| Actives | 1 524 |
44 −| Visibles (après dédup) | 50 |
45 −| Masquées par la dédup | 1 474 |
42 +| Annonces en base (toutes) | 1 586 |
43 +| Actives | 1 527 |
44 +| Visibles (après dédup) | 47 |
45 +| Masquées par la dédup | 1 480 |
46 46 | GPS (lat/lng) | 100.0 % |
47 47 | Photos | 100.0 % |
48 48 | Prix | 100.0 % |
49 −| CAC (chambres) | 54.0 % |
50 −| SDB (salles de bains) | 50.0 % |
51 −| Année de construction | 92.0 % (dont rôle : 8.7 %) |
52 −| Taxes / évaluation municipale | 90.0 % |
49 +| CAC (chambres) | 53.2 % |
50 +| SDB (salles de bains) | 48.9 % |
51 +| Année de construction | 91.5 % (dont rôle : 9.3 %) |
52 +| Taxes / évaluation municipale | 89.4 % |
53 53 | Estimation Vrai-Prix (rôle) | 100.0 % |
54 54
modified docs/connecteurs/ferri.md +5 −5
@@ -1,6 +1,6 @@
1 1 # Ferri Services Immobiliers (`ferri`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSIFerri`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -49,11 +49,11 @@
49 49 | SDB (salles de bains) | 95.5 % |
50 50 | Année de construction | 95.5 % (dont rôle : 0.0 %) |
51 51 | Taxes / évaluation municipale | 100.0 % |
52 −| Estimation Vrai-Prix (rôle) | 0.0 % |
52 +| Estimation Vrai-Prix (rôle) | 100.0 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 05:56 — trouvé 22, +22 / ~0 / -0 — OK
57 −- **Erreurs récentes** : 0 sur les 1 derniers syncs
56 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 22, +0 / ~0 / -0 — OK
57 +- **Erreurs récentes** : 0 sur les 4 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/gaillardetz.md +6 −6
@@ -1,6 +1,6 @@
1 1 # Gaillardetz Agence Immobilière (`gaillardetz`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSIGaillardetz`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -45,15 +45,15 @@
45 45 | GPS (lat/lng) | 100.0 % |
46 46 | Photos | 100.0 % |
47 47 | Prix | 100.0 % |
48 −| CAC (chambres) | 0.0 % |
49 −| SDB (salles de bains) | 0.0 % |
50 −| Année de construction | 92.3 % (dont rôle : 16.7 %) |
48 +| CAC (chambres) | 7.7 % |
49 +| SDB (salles de bains) | 15.4 % |
50 +| Année de construction | 92.3 % (dont rôle : 0.0 %) |
51 51 | Taxes / évaluation municipale | 100.0 % |
52 52 | Estimation Vrai-Prix (rôle) | 100.0 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 02:27 — trouvé 13, +0 / ~0 / -0 — OK
56 +- **Dernier sync** : 2026-08-18 18:10 — trouvé 13, +0 / ~13 / -0 — OK
57 57 - **Erreurs récentes** : 0 sur les 10 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/glmc_ag_qc.md +13 −3
@@ -1,6 +1,6 @@
1 1 # Les Immeubles GLMC (`glmc_ag_qc`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -30,6 +30,16 @@
30 30 > Centris termine le slug), adresse+ville, photo. Prix/chambres via la page
31 31 > détail (enrichissement plafonné + cache). La section « Vendus » (fiches
32 32 > vendues, après la pagination) est exclue du parsing.
33 +> Page détail (thème X / Cornerstone) : PAS de JSON-LD propriété — tout est
34 +> dans le HTML : stats à icônes « N pièces / N chambre(s) / N salle(s) de
35 +> bain / N salle d'eau », description dans <ul class="remarques-list">,
36 +> caractéristiques dans des grilles x-cell alternant libellé/valeur
37 +> (Genre, Année de construction, Terrain [dimensions puis superficie],
38 +> évaluations, taxes…), Inclusions/Exclusions en x-text, courtier(s) dans
39 +> .mw-courtier-card (nom + tel:), galerie = photos Centris re-hébergées
40 +> /wp-content/uploads/…/{md5}.jpg (nom 32 hex, pleine résolution, ordre
41 +> d'origine — les logos/portraits ont un nom lisible et sont donc exclus).
42 +> Coordonnées GPS : chargées en AJAX par mw-centris-map → non exposées.
33 43 > source_id « glmc_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —
34 44 > si le n° Centris est déjà porté par une bannière couverte, la fiche est
35 45 > masquée ; les inscriptions propres de GLMC restent visibles.
@@ -53,7 +63,7 @@
53 63
54 64 ## Synchronisation
55 65
56 −- **Dernier sync** : 2026-08-18 02:40 — trouvé 176, +0 / ~0 / -1 — OK
57 −- **Erreurs récentes** : 0 sur les 4 derniers syncs
66 +- **Dernier sync** : 2026-08-18 17:52 — trouvé 176, +0 / ~0 / -0 — OK
67 +- **Erreurs récentes** : 0 sur les 7 derniers syncs
58 68
59 69 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/guidehabitation.md +12 −5
@@ -1,6 +1,6 @@
1 1 # Guide Habitation (projets neufs) (`guidehabitation`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -16,6 +16,7 @@
16 16
17 17 - Module : `immoka/connectors/guidehabitation.py` — classe `GuideHabitationConnector`
18 18 - Délai entre requêtes : 1.0 s ; cache détail BD : oui
19 +- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_GH_DETAIL_LIMIT` (défaut 150)
19 20
20 21 **En-tête du module :**
21 22
@@ -30,6 +31,12 @@
30 31 > description, chambres offertes, photo. Aucune clé, aucun rendu JS.
31 32 > On écarte les projets LOCATIFS (prix mensuel / « locatif » dans le texte) :
32 33 > Immo-Ka couvre le neuf À VENDRE. external_id = data-id du projet.
34 +> Page détail projet : UN gros JSON-LD RealEstateListing très riche —
35 +> about.articleBody (présentation longue, pseudo-markdown), mainEntity.image
36 +> (galerie complète pleine résolution, ordre d'origine), mainEntity.geo,
37 +> amenityFeature, offers[] (promoteur offeredBy : nom/téléphone/site,
38 +> chambres, garantie, date de livraison), additionalProperty (statut du
39 +> projet, site web externe). Enrichissement plafonné + cache (du.enrich).
33 40
34 41 ## Volumétrie & complétude (BD live)
35 42
@@ -44,13 +51,13 @@
44 51 | Prix | 100.0 % |
45 52 | CAC (chambres) | 100.0 % |
46 53 | SDB (salles de bains) | 0.0 % |
47 −| Année de construction | 0.0 % |
54 +| Année de construction | 27.7 % (dont rôle : 100.0 %) |
48 55 | Taxes / évaluation municipale | 0.0 % |
49 −| Estimation Vrai-Prix (rôle) | 0.0 % |
56 +| Estimation Vrai-Prix (rôle) | 100.0 % |
50 57
51 58 ## Synchronisation
52 59
53 −- **Dernier sync** : 2026-08-18 05:56 — trouvé 95, +95 / ~0 / -0 — OK
54 −- **Erreurs récentes** : 0 sur les 1 derniers syncs
60 +- **Dernier sync** : 2026-08-18 17:52 — trouvé 95, +0 / ~1 / -0 — OK
61 +- **Erreurs récentes** : 0 sur les 4 derniers syncs
55 62
56 63 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/image_ag_qc.md +6 −6
@@ -1,6 +1,6 @@
1 1 # Image, l'agence immobilière (`image_ag_qc`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -34,9 +34,9 @@
34 34
35 35 | Indicateur | Valeur |
36 36 |---|---|
37 −| Annonces en base (toutes) | 77 |
38 −| Actives | 77 |
39 −| Visibles (après dédup) | 77 |
37 +| Annonces en base (toutes) | 78 |
38 +| Actives | 78 |
39 +| Visibles (après dédup) | 78 |
40 40 | Masquées par la dédup | 0 |
41 41 | GPS (lat/lng) | 100.0 % |
42 42 | Photos | 100.0 % |
@@ -49,7 +49,7 @@
49 49
50 50 ## Synchronisation
51 51
52 −- **Dernier sync** : 2026-08-18 02:27 — trouvé 77, +0 / ~0 / -0 — OK
53 −- **Erreurs récentes** : 0 sur les 4 derniers syncs
52 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 78, +0 / ~0 / -0 — OK
53 +- **Erreurs récentes** : 0 sur les 7 derniers syncs
54 54
55 55 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/imcha_ag_qc.md +19 −8
@@ -1,6 +1,6 @@
1 1 # Les Immeubles Charlevoix (`imcha_ag_qc`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -16,6 +16,7 @@
16 16
17 17 - Module : `immoka/connectors/imcha.py` — classe `ImchaConnector`
18 18 - Délai entre requêtes : 0.4 s ; cache détail BD : oui
19 +- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_IMCHA_DETAIL_LIMIT` (défaut 150)
19 20
20 21 **En-tête du module :**
21 22
@@ -28,20 +29,30 @@
28 29 > (li.estate-single) porte : code interne, type, ville, prix, chambres et
29 30 > photo. Le n° Centris est encodé dans le nom de fichier photo
30 31 > (/uploads/photos/max/{Centris}-…) → clé de déduplication.
32 +> Page détail : description dans <h3 class="title--small">Description</h3>
33 +> + <p>, galerie fancybox /uploads/photos/max/… (pleine résolution, ordre
34 +> d'origine — préfixe = n° Centris), specs à icônes (Chambres / Salles de
35 +> bain / Salle d'eau, ordre libellé/nombre variable), sections
36 +> .estate__info (Type + année, Évaluation municipale, Bâtiment, Terrain
37 +> [façade/profondeur/superficie en mètres], Inclusions, Exclusions en
38 +> <dt>/<dd>). AUCUNE coordonnée GPS ni adresse civique exposée (pas de
39 +> carte sur la fiche) — impossible à géocoder honnêtement. ⚠️ Les réponses
40 +> HTTP n'annoncent pas de charset alors que le HTML est en UTF-8 → forcer
41 +> resp.encoding partout. Enrichissement plafonné + cache (du.enrich).
31 42 > source_id « imcha_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup).
32 43
33 44 ## Volumétrie & complétude (BD live)
34 45
35 46 | Indicateur | Valeur |
36 47 |---|---|
37 −| Annonces en base (toutes) | 111 |
38 −| Actives | 111 |
39 −| Visibles (après dédup) | 107 |
48 +| Annonces en base (toutes) | 112 |
49 +| Actives | 110 |
50 +| Visibles (après dédup) | 106 |
40 51 | Masquées par la dédup | 4 |
41 52 | GPS (lat/lng) | 0.0 % |
42 53 | Photos | 100.0 % |
43 −| Prix | 41.1 % |
44 −| CAC (chambres) | 61.7 % |
54 +| Prix | 40.6 % |
55 +| CAC (chambres) | 62.3 % |
45 56 | SDB (salles de bains) | 0.0 % |
46 57 | Année de construction | 0.0 % |
47 58 | Taxes / évaluation municipale | 0.0 % |
@@ -49,7 +60,7 @@
49 60
50 61 ## Synchronisation
51 62
52 −- **Dernier sync** : 2026-08-18 02:40 — trouvé 111, +0 / ~0 / -0 — OK
53 −- **Erreurs récentes** : 0 sur les 3 derniers syncs
63 +- **Dernier sync** : 2026-08-18 17:52 — trouvé 110, +0 / ~0 / -2 — OK
64 +- **Erreurs récentes** : 0 sur les 6 derniers syncs
54 65
55 66 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/immodev.md +6 −6
@@ -1,6 +1,6 @@
1 1 # Immodev Agence Immobilière (`immodev`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSIImmodev`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -45,15 +45,15 @@
45 45 | GPS (lat/lng) | 100.0 % |
46 46 | Photos | 100.0 % |
47 47 | Prix | 100.0 % |
48 −| CAC (chambres) | 8.7 % |
48 +| CAC (chambres) | 12.0 % |
49 49 | SDB (salles de bains) | 1.1 % |
50 −| Année de construction | 64.1 % (dont rôle : 15.3 %) |
51 −| Taxes / évaluation municipale | 95.7 % |
50 +| Année de construction | 64.1 % (dont rôle : 0.0 %) |
51 +| Taxes / évaluation municipale | 96.7 % |
52 52 | Estimation Vrai-Prix (rôle) | 100.0 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 02:27 — trouvé 92, +0 / ~0 / -0 — OK
56 +- **Dernier sync** : 2026-08-18 18:10 — trouvé 92, +0 / ~92 / -0 — OK
57 57 - **Erreurs récentes** : 0 sur les 10 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/kijiji.md +8 −8
@@ -1,6 +1,6 @@
1 1 # Kijiji Québec (`kijiji`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -29,22 +29,22 @@
29 29
30 30 | Indicateur | Valeur |
31 31 |---|---|
32 −| Annonces en base (toutes) | 8 113 |
33 −| Actives | 7 109 |
34 −| Visibles (après dédup) | 7 104 |
35 −| Masquées par la dédup | 5 |
32 +| Annonces en base (toutes) | 8 148 |
33 +| Actives | 7 147 |
34 +| Visibles (après dédup) | 7 141 |
35 +| Masquées par la dédup | 6 |
36 36 | GPS (lat/lng) | 100.0 % |
37 37 | Photos | 99.8 % |
38 38 | Prix | 98.1 % |
39 39 | CAC (chambres) | 82.6 % |
40 −| SDB (salles de bains) | 63.7 % |
41 −| Année de construction | 4.2 % (dont rôle : 0.0 %) |
40 +| SDB (salles de bains) | 67.3 % |
41 +| Année de construction | 4.2 % (dont rôle : 1.0 %) |
42 42 | Taxes / évaluation municipale | 0.0 % |
43 43 | Estimation Vrai-Prix (rôle) | 6.4 % |
44 44
45 45 ## Synchronisation
46 46
47 −- **Dernier sync** : 2026-08-18 02:59 — trouvé 7 107, +0 / ~7107 / -3 — OK
47 +- **Dernier sync** : 2026-08-18 12:36 — trouvé 7 116, +34 / ~190 / -0 — OK
48 48 - **Erreurs récentes** : 0 sur les 10 derniers syncs
49 49
50 50 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/kw_distinction.md +2 −2
@@ -1,6 +1,6 @@
1 1 # Keller Williams Distinction (`kw_distinction`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -48,7 +48,7 @@
48 48
49 49 ## Synchronisation
50 50
51 −- **Dernier sync** : 2026-08-18 02:59 — trouvé 167, +0 / ~0 / -0 — OK
51 +- **Dernier sync** : 2026-08-18 12:36 — trouvé 167, +0 / ~0 / -0 — OK
52 52 - **Erreurs récentes** : 0 sur les 10 derniers syncs
53 53
54 54 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/kw_prestige.md +2 −2
@@ -1,6 +1,6 @@
1 1 # Keller Williams Prestige (`kw_prestige`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -48,7 +48,7 @@
48 48
49 49 ## Synchronisation
50 50
51 −- **Dernier sync** : 2026-08-18 02:59 — trouvé 277, +0 / ~0 / -0 — OK
51 +- **Dernier sync** : 2026-08-18 12:36 — trouvé 277, +0 / ~0 / -0 — OK
52 52 - **Erreurs récentes** : 0 sur les 10 derniers syncs
53 53
54 54 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/kw_urbain.md +2 −2
@@ -1,6 +1,6 @@
1 1 # Keller Williams Urbain (`kw_urbain`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -45,7 +45,7 @@
45 45
46 46 ## Synchronisation
47 47
48 −- **Dernier sync** : 2026-08-18 02:59 — trouvé 351, +0 / ~0 / -0 — OK
48 +- **Dernier sync** : 2026-08-18 12:36 — trouvé 351, +0 / ~0 / -0 — OK
49 49 - **Erreurs récentes** : 0 sur les 10 derniers syncs
50 50
51 51 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/lespac.md +12 −12
@@ -1,6 +1,6 @@
1 1 # LesPAC Québec (`lespac`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -30,22 +30,22 @@
30 30
31 31 | Indicateur | Valeur |
32 32 |---|---|
33 −| Annonces en base (toutes) | 4 343 |
34 −| Actives | 4 132 |
35 −| Visibles (après dédup) | 2 756 |
36 −| Masquées par la dédup | 1 376 |
37 −| GPS (lat/lng) | 46.8 % |
33 +| Annonces en base (toutes) | 4 350 |
34 +| Actives | 4 138 |
35 +| Visibles (après dédup) | 2 732 |
36 +| Masquées par la dédup | 1 406 |
37 +| GPS (lat/lng) | 46.3 % |
38 38 | Photos | 99.6 % |
39 −| Prix | 97.9 % |
39 +| Prix | 97.8 % |
40 40 | CAC (chambres) | 54.6 % |
41 −| SDB (salles de bains) | 15.7 % |
42 −| Année de construction | 57.9 % (dont rôle : 0.0 %) |
43 −| Taxes / évaluation municipale | 8.1 % |
44 −| Estimation Vrai-Prix (rôle) | 70.2 % |
41 +| SDB (salles de bains) | 26.1 % |
42 +| Année de construction | 58.1 % (dont rôle : 0.1 %) |
43 +| Taxes / évaluation municipale | 22.3 % |
44 +| Estimation Vrai-Prix (rôle) | 69.8 % |
45 45
46 46 ## Synchronisation
47 47
48 −- **Dernier sync** : 2026-08-18 03:10 — trouvé 4 132, +0 / ~4132 / -0 — OK
48 +- **Dernier sync** : 2026-08-18 12:47 — trouvé 4 136, +3 / ~432 / -4 — OK
49 49 - **Erreurs récentes** : 0 sur les 10 derniers syncs
50 50
51 51 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/m_immobilier.md +8 −8
@@ -1,6 +1,6 @@
1 1 # M Immobilier (`m_immobilier`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -33,21 +33,21 @@
33 33 | Indicateur | Valeur |
34 34 |---|---|
35 35 | Annonces en base (toutes) | 84 |
36 −| Actives | 67 |
37 −| Visibles (après dédup) | 67 |
36 +| Actives | 66 |
37 +| Visibles (après dédup) | 66 |
38 38 | Masquées par la dédup | 0 |
39 −| GPS (lat/lng) | 94.0 % |
39 +| GPS (lat/lng) | 93.9 % |
40 40 | Photos | 100.0 % |
41 41 | Prix | 98.5 % |
42 −| CAC (chambres) | 94.0 % |
43 −| SDB (salles de bains) | 94.0 % |
44 −| Année de construction | 85.1 % (dont rôle : 100.0 %) |
42 +| CAC (chambres) | 93.9 % |
43 +| SDB (salles de bains) | 93.9 % |
44 +| Année de construction | 86.4 % (dont rôle : 100.0 %) |
45 45 | Taxes / évaluation municipale | 0.0 % |
46 46 | Estimation Vrai-Prix (rôle) | 98.5 % |
47 47
48 48 ## Synchronisation
49 49
50 −- **Dernier sync** : 2026-08-18 03:13 — trouvé 66, +0 / ~0 / -0 — OK
50 +- **Dernier sync** : 2026-08-18 12:51 — trouvé 66, +0 / ~0 / -0 — OK
51 51 - **Erreurs récentes** : 0 sur les 10 derniers syncs
52 52
53 53 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/niddamour.md +3 −3
@@ -1,6 +1,6 @@
1 1 # Nid d'Amour Agence Immobilière (`niddamour`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSINiddamour`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -40,7 +40,7 @@ _Aucune annonce en base pour cette source (voir la note du registre : couverture
40 40
41 41 ## Synchronisation
42 42
43 −- **Dernier sync** : 2026-08-18 02:27 — trouvé 0, +0 / ~0 / -0 — OK
43 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 0, +0 / ~0 / -0 — OK
44 44 - **Erreurs récentes** : 0 sur les 10 derniers syncs
45 45
46 46 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/peladeau.md +6 −6
@@ -1,6 +1,6 @@
1 1 # Péladeau Agence Immobilière (`peladeau`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSIPeladeau`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -47,13 +47,13 @@
47 47 | Prix | 100.0 % |
48 48 | CAC (chambres) | 93.6 % |
49 49 | SDB (salles de bains) | 95.7 % |
50 −| Année de construction | 83.0 % (dont rôle : 0.0 %) |
50 +| Année de construction | 93.6 % (dont rôle : 11.4 %) |
51 51 | Taxes / évaluation municipale | 78.7 % |
52 −| Estimation Vrai-Prix (rôle) | 0.0 % |
52 +| Estimation Vrai-Prix (rôle) | 100.0 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 05:56 — trouvé 47, +47 / ~0 / -0 — OK
57 −- **Erreurs récentes** : 0 sur les 1 derniers syncs
56 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 47, +0 / ~0 / -0 — OK
57 +- **Erreurs récentes** : 0 sur les 4 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/profusion.md +12 −12
@@ -1,6 +1,6 @@
1 1 # Profusion Immobilier (Christie's) (`profusion`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -32,22 +32,22 @@
32 32
33 33 | Indicateur | Valeur |
34 34 |---|---|
35 −| Annonces en base (toutes) | 363 |
36 −| Actives | 351 |
37 −| Visibles (après dédup) | 351 |
35 +| Annonces en base (toutes) | 365 |
36 +| Actives | 353 |
37 +| Visibles (après dédup) | 353 |
38 38 | Masquées par la dédup | 0 |
39 −| GPS (lat/lng) | 66.7 % |
40 −| Photos | 70.7 % |
41 −| Prix | 59.3 % |
42 −| CAC (chambres) | 70.7 % |
43 −| SDB (salles de bains) | 13.1 % |
44 −| Année de construction | 61.5 % (dont rôle : 11.6 %) |
39 +| GPS (lat/lng) | 66.9 % |
40 +| Photos | 70.8 % |
41 +| Prix | 59.5 % |
42 +| CAC (chambres) | 70.8 % |
43 +| SDB (salles de bains) | 13.0 % |
44 +| Année de construction | 61.5 % (dont rôle : 11.5 %) |
45 45 | Taxes / évaluation municipale | 0.0 % |
46 −| Estimation Vrai-Prix (rôle) | 70.7 % |
46 +| Estimation Vrai-Prix (rôle) | 70.8 % |
47 47
48 48 ## Synchronisation
49 49
50 −- **Dernier sync** : 2026-08-18 03:14 — trouvé 351, +0 / ~0 / -0 — OK
50 +- **Dernier sync** : 2026-08-18 12:51 — trouvé 353, +0 / ~0 / -0 — OK
51 51 - **Erreurs récentes** : 0 sur les 10 derniers syncs
52 52
53 53 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/proprio_direct.md +6 −6
@@ -1,6 +1,6 @@
1 1 # Proprio Direct (`proprio_direct`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -32,22 +32,22 @@
32 32
33 33 | Indicateur | Valeur |
34 34 |---|---|
35 −| Annonces en base (toutes) | 2 556 |
36 −| Actives | 2 464 |
37 −| Visibles (après dédup) | 2 464 |
35 +| Annonces en base (toutes) | 2 561 |
36 +| Actives | 2 468 |
37 +| Visibles (après dédup) | 2 468 |
38 38 | Masquées par la dédup | 0 |
39 39 | GPS (lat/lng) | 100.0 % |
40 40 | Photos | 100.0 % |
41 41 | Prix | 100.0 % |
42 42 | CAC (chambres) | 73.4 % |
43 43 | SDB (salles de bains) | 72.6 % |
44 −| Année de construction | 67.5 % (dont rôle : 100.0 %) |
44 +| Année de construction | 67.6 % (dont rôle : 99.0 %) |
45 45 | Taxes / évaluation municipale | 90.3 % |
46 46 | Estimation Vrai-Prix (rôle) | 100.0 % |
47 47
48 48 ## Synchronisation
49 49
50 −- **Dernier sync** : 2026-08-18 03:14 — trouvé 2 464, +0 / ~0 / -3 — OK
50 +- **Dernier sync** : 2026-08-18 12:51 — trouvé 2 465, +3 / ~5 / -1 — OK
51 51 - **Erreurs récentes** : 0 sur les 10 derniers syncs
52 52
53 53 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/provad.md +10 −10
@@ -1,6 +1,6 @@
1 1 # Proulx Vadnais & Associés (`provad`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSIProvad`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -38,22 +38,22 @@
38 38
39 39 | Indicateur | Valeur |
40 40 |---|---|
41 −| Annonces en base (toutes) | 80 |
42 −| Actives | 80 |
43 −| Visibles (après dédup) | 80 |
41 +| Annonces en base (toutes) | 81 |
42 +| Actives | 81 |
43 +| Visibles (après dédup) | 81 |
44 44 | Masquées par la dédup | 0 |
45 45 | GPS (lat/lng) | 100.0 % |
46 46 | Photos | 100.0 % |
47 47 | Prix | 100.0 % |
48 48 | CAC (chambres) | 1.2 % |
49 49 | SDB (salles de bains) | 0.0 % |
50 −| Année de construction | 41.2 % (dont rôle : 0.0 %) |
51 −| Taxes / évaluation municipale | 42.5 % |
52 −| Estimation Vrai-Prix (rôle) | 0.0 % |
50 +| Année de construction | 43.2 % (dont rôle : 2.9 %) |
51 +| Taxes / évaluation municipale | 43.2 % |
52 +| Estimation Vrai-Prix (rôle) | 100.0 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 05:56 — trouvé 80, +80 / ~0 / -0 — OK
57 −- **Erreurs récentes** : 0 sur les 1 derniers syncs
56 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 81, +0 / ~0 / -0 — OK
57 +- **Erreurs récentes** : 0 sur les 4 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/realta.md +4 −4
@@ -1,6 +1,6 @@
1 1 # Réalta Agence Immobilière (`realta`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSIRealta`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -47,13 +47,13 @@
47 47 | Prix | 100.0 % |
48 48 | CAC (chambres) | 80.5 % |
49 49 | SDB (salles de bains) | 78.0 % |
50 −| Année de construction | 80.5 % (dont rôle : 3.0 %) |
50 +| Année de construction | 80.5 % (dont rôle : 0.0 %) |
51 51 | Taxes / évaluation municipale | 95.1 % |
52 52 | Estimation Vrai-Prix (rôle) | 100.0 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 02:27 — trouvé 82, +0 / ~0 / -0 — OK
56 +- **Dernier sync** : 2026-08-18 18:09 — trouvé 82, +0 / ~82 / -0 — OK
57 57 - **Erreurs récentes** : 0 sur les 10 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/realtor_ag_ca.md +22 −13
@@ -1,6 +1,6 @@
1 1 # Realtor.ca (MLS Québec) (`realtor_ag_ca`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,8 +15,8 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/realtor_ca.py` — classe `RealtorCaConnector`
18 −- Délai entre requêtes : 0.2 s ; cache détail BD : non
19 −- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_REALTOR_MAX` (défaut 2000)
18 +- Délai entre requêtes : 0.2 s ; cache détail BD : oui
19 +- Budgets env (enrichissement détail plafonné/exécution) : `IMMOKA_REALTOR_MAX` (défaut 2000), `IMMOKA_REALTOR_DETAIL_LIMIT` (défaut 100)
20 20
21 21 **En-tête du module :**
22 22
@@ -40,27 +40,36 @@
40 40 > activent la dédup existante — toute fiche déjà couverte par un connecteur
41 41 > direct (RE/MAX, Royal LePage, Sutton…) est masquée, seules les fiches
42 42 > UNIQUES (petites bannières sans connecteur) restent visibles.
43 +> ENRICHISSEMENT DÉTAIL (2026-08-18) : la réponse PropertySearch_Post ne
44 +> contient qu'UNE photo et un PublicRemarks VIDE (vérifié) — la galerie
45 +> complète, la description et les caractéristiques Building/Land viennent de
46 +> l'API Listing.svc/PropertyDetails (GET, mêmes protections → Scrapfly ASP
47 +> sans render_js, 1 crédit-appel), rejouée avec CultureId=2 (français) via
48 +> ReferenceNumber=<MLS> + PropertyID=<Id interne>. Cache BD detail_cache
49 +> (_detailutil.enrich) : chaque fiche n'est détaillée qu'UNE fois ; budget
50 +> IMMOKA_REALTOR_DETAIL_LIMIT appels/cycle (défaut 100, override ponctuel
51 +> IMMOKA_DETAIL_LIMIT) → rattrapage progressif du stock, nouveautés ensuite.
43 52
44 53 ## Volumétrie & complétude (BD live)
45 54
46 55 | Indicateur | Valeur |
47 56 |---|---|
48 −| Annonces en base (toutes) | 2 145 |
49 −| Actives | 2 145 |
50 −| Visibles (après dédup) | 343 |
51 −| Masquées par la dédup | 1 802 |
57 +| Annonces en base (toutes) | 2 344 |
58 +| Actives | 2 344 |
59 +| Visibles (après dédup) | 429 |
60 +| Masquées par la dédup | 1 915 |
52 61 | GPS (lat/lng) | 100.0 % |
53 62 | Photos | 100.0 % |
54 63 | Prix | 100.0 % |
55 −| CAC (chambres) | 85.7 % |
56 −| SDB (salles de bains) | 86.0 % |
57 −| Année de construction | 0.0 % |
64 +| CAC (chambres) | 86.5 % |
65 +| SDB (salles de bains) | 86.7 % |
66 +| Année de construction | 82.3 % (dont rôle : 98.0 %) |
58 67 | Taxes / évaluation municipale | 0.0 % |
59 −| Estimation Vrai-Prix (rôle) | 0.0 % |
68 +| Estimation Vrai-Prix (rôle) | 100.0 % |
60 69
61 70 ## Synchronisation
62 71
63 −- **Dernier sync** : 2026-08-18 06:06 — trouvé 2 145, +2145 / ~0 / -0 — OK
64 −- **Erreurs récentes** : 0 sur les 1 derniers syncs
72 +- **Dernier sync** : 2026-08-18 13:15 — trouvé 2 142, +199 / ~46 / -0 — OK
73 +- **Erreurs récentes** : 0 sur les 3 derniers syncs
65 74
66 75 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/remax_quebec.md +5 −5
@@ -1,6 +1,6 @@
1 1 # RE/MAX Québec (`remax_quebec`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -36,9 +36,9 @@
36 36
37 37 | Indicateur | Valeur |
38 38 |---|---|
39 −| Annonces en base (toutes) | 26 702 |
40 −| Actives | 25 486 |
41 −| Visibles (après dédup) | 25 348 |
39 +| Annonces en base (toutes) | 26 753 |
40 +| Actives | 25 524 |
41 +| Visibles (après dédup) | 25 386 |
42 42 | Masquées par la dédup | 138 |
43 43 | GPS (lat/lng) | 100.0 % |
44 44 | Photos | 100.0 % |
@@ -51,7 +51,7 @@
51 51
52 52 ## Synchronisation
53 53
54 −- **Dernier sync** : 2026-08-18 05:11 — trouvé 25 486, +0 / ~25426 / -0 — OK
54 +- **Dernier sync** : 2026-08-18 13:20 — trouvé 25 504, +19 / ~23 / -16 — OK
55 55 - **Erreurs récentes** : 0 sur les 10 derniers syncs
56 56
57 57 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/royal_lepage.md +11 −11
@@ -1,6 +1,6 @@
1 1 # Royal LePage (Québec) (`royal_lepage`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -36,22 +36,22 @@
36 36
37 37 | Indicateur | Valeur |
38 38 |---|---|
39 −| Annonces en base (toutes) | 1 461 |
40 −| Actives | 1 011 |
41 −| Visibles (après dédup) | 1 008 |
42 −| Masquées par la dédup | 3 |
39 +| Annonces en base (toutes) | 1 508 |
40 +| Actives | 1 055 |
41 +| Visibles (après dédup) | 1 051 |
42 +| Masquées par la dédup | 4 |
43 43 | GPS (lat/lng) | 100.0 % |
44 44 | Photos | 100.0 % |
45 −| Prix | 80.5 % |
46 −| CAC (chambres) | 85.6 % |
47 −| SDB (salles de bains) | 84.5 % |
48 −| Année de construction | 89.0 % (dont rôle : 47.6 %) |
49 −| Taxes / évaluation municipale | 38.0 % |
45 +| Prix | 79.9 % |
46 +| CAC (chambres) | 88.8 % |
47 +| SDB (salles de bains) | 87.6 % |
48 +| Année de construction | 92.7 % (dont rôle : 0.9 %) |
49 +| Taxes / évaluation municipale | 75.3 % |
50 50 | Estimation Vrai-Prix (rôle) | 100.0 % |
51 51
52 52 ## Synchronisation
53 53
54 −- **Dernier sync** : 2026-08-18 05:19 — trouvé 1 010, +0 / ~498 / -1 — OK
54 +- **Dernier sync** : 2026-08-18 13:21 — trouvé 1 011, +43 / ~17 / -4 — OK
55 55 - **Erreurs récentes** : 0 sur les 10 derniers syncs
56 56
57 57 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/si_ag_ciemcq.md +12 −12
@@ -1,6 +1,6 @@
1 1 # Chambre immobilière Estrie–Mauricie–Centre-du-Québec (Mon-Toit) (`si_ag_ciemcq`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSISiAgCiemcq`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -38,22 +38,22 @@
38 38
39 39 | Indicateur | Valeur |
40 40 |---|---|
41 −| Annonces en base (toutes) | 3 920 |
42 −| Actives | 3 920 |
43 −| Visibles (après dédup) | 1 169 |
44 −| Masquées par la dédup | 2 751 |
41 +| Annonces en base (toutes) | 3 931 |
42 +| Actives | 3 931 |
43 +| Visibles (après dédup) | 1 179 |
44 +| Masquées par la dédup | 2 752 |
45 45 | GPS (lat/lng) | 100.0 % |
46 46 | Photos | 100.0 % |
47 47 | Prix | 100.0 % |
48 −| CAC (chambres) | 66.7 % |
48 +| CAC (chambres) | 66.8 % |
49 49 | SDB (salles de bains) | 60.1 % |
50 −| Année de construction | 62.8 % (dont rôle : 0.0 %) |
51 −| Taxes / évaluation municipale | 83.7 % |
52 −| Estimation Vrai-Prix (rôle) | 0.0 % |
50 +| Année de construction | 68.7 % (dont rôle : 8.4 %) |
51 +| Taxes / évaluation municipale | 83.9 % |
52 +| Estimation Vrai-Prix (rôle) | 99.1 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 05:56 — trouvé 3 920, +3920 / ~0 / -0 — OK
57 −- **Erreurs récentes** : 0 sur les 1 derniers syncs
56 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 3 918, +11 / ~13 / -0 — OK
57 +- **Erreurs récentes** : 0 sur les 4 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/si_ag_jga.md +6 −6
@@ -1,6 +1,6 @@
1 1 # JGA Équipe Commerciale (Royal LePage) (`si_ag_jga`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSISiAgJga`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -47,13 +47,13 @@
47 47 | Prix | 100.0 % |
48 48 | CAC (chambres) | 8.5 % |
49 49 | SDB (salles de bains) | 5.1 % |
50 −| Année de construction | 28.8 % (dont rôle : 0.0 %) |
50 +| Année de construction | 37.3 % (dont rôle : 22.7 %) |
51 51 | Taxes / évaluation municipale | 91.5 % |
52 −| Estimation Vrai-Prix (rôle) | 0.0 % |
52 +| Estimation Vrai-Prix (rôle) | 100.0 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 05:56 — trouvé 64, +64 / ~0 / -0 — OK
57 −- **Erreurs récentes** : 0 sur les 1 derniers syncs
56 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 64, +0 / ~0 / -0 — OK
57 +- **Erreurs récentes** : 0 sur les 4 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/si_ag_katiasamson.md +11 −11
@@ -1,6 +1,6 @@
1 1 # Katia Samson (RE/MAX L'Espace) (`si_ag_katiasamson`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSISiAgKatiasamson`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -38,22 +38,22 @@
38 38
39 39 | Indicateur | Valeur |
40 40 |---|---|
41 −| Annonces en base (toutes) | 30 |
42 −| Actives | 30 |
43 −| Visibles (après dédup) | 25 |
41 +| Annonces en base (toutes) | 31 |
42 +| Actives | 31 |
43 +| Visibles (après dédup) | 26 |
44 44 | Masquées par la dédup | 5 |
45 45 | GPS (lat/lng) | 100.0 % |
46 46 | Photos | 100.0 % |
47 47 | Prix | 100.0 % |
48 −| CAC (chambres) | 96.0 % |
49 −| SDB (salles de bains) | 96.0 % |
48 +| CAC (chambres) | 96.2 % |
49 +| SDB (salles de bains) | 96.2 % |
50 50 | Année de construction | 100.0 % (dont rôle : 0.0 %) |
51 −| Taxes / évaluation municipale | 64.0 % |
52 −| Estimation Vrai-Prix (rôle) | 0.0 % |
51 +| Taxes / évaluation municipale | 65.4 % |
52 +| Estimation Vrai-Prix (rôle) | 96.2 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 05:56 — trouvé 30, +30 / ~0 / -0 — OK
57 −- **Erreurs récentes** : 0 sur les 1 derniers syncs
56 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 31, +1 / ~0 / -0 — OK
57 +- **Erreurs récentes** : 0 sur les 4 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/si_ag_theagency.md +8 −8
@@ -1,6 +1,6 @@
1 1 # The Agency Montréal (`si_ag_theagency`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/agences_source_immo.py` — classe `AgenceSISiAgTheagency`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -38,10 +38,10 @@
38 38
39 39 | Indicateur | Valeur |
40 40 |---|---|
41 −| Annonces en base (toutes) | 308 |
42 −| Actives | 304 |
41 +| Annonces en base (toutes) | 310 |
42 +| Actives | 305 |
43 43 | Visibles (après dédup) | 236 |
44 −| Masquées par la dédup | 68 |
44 +| Masquées par la dédup | 69 |
45 45 | GPS (lat/lng) | 100.0 % |
46 46 | Photos | 100.0 % |
47 47 | Prix | 100.0 % |
@@ -49,11 +49,11 @@
49 49 | SDB (salles de bains) | 76.7 % |
50 50 | Année de construction | 90.3 % (dont rôle : 6.6 %) |
51 51 | Taxes / évaluation municipale | 84.7 % |
52 −| Estimation Vrai-Prix (rôle) | 100.0 % |
52 +| Estimation Vrai-Prix (rôle) | 99.6 % |
53 53
54 54 ## Synchronisation
55 55
56 −- **Dernier sync** : 2026-08-18 02:27 — trouvé 304, +0 / ~0 / -0 — OK
57 −- **Erreurs récentes** : 0 sur les 4 derniers syncs
56 +- **Dernier sync** : 2026-08-18 17:38 — trouvé 305, +1 / ~1 / -1 — OK
57 +- **Erreurs récentes** : 0 sur les 7 derniers syncs
58 58
59 59 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/sothebys_quebec.md +6 −6
@@ -1,6 +1,6 @@
1 1 # Sotheby's International Realty Québec (`sothebys_quebec`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -48,15 +48,15 @@
48 48 | GPS (lat/lng) | 99.8 % |
49 49 | Photos | 100.0 % |
50 50 | Prix | 100.0 % |
51 −| CAC (chambres) | 76.9 % |
52 −| SDB (salles de bains) | 74.5 % |
53 −| Année de construction | 3.5 % (dont rôle : 0.0 %) |
51 +| CAC (chambres) | 77.7 % |
52 +| SDB (salles de bains) | 74.6 % |
53 +| Année de construction | 60.2 % (dont rôle : 75.9 %) |
54 54 | Taxes / évaluation municipale | 0.0 % |
55 −| Estimation Vrai-Prix (rôle) | 6.7 % |
55 +| Estimation Vrai-Prix (rôle) | 100.0 % |
56 56
57 57 ## Synchronisation
58 58
59 −- **Dernier sync** : 2026-08-18 06:05 — trouvé 869, +809 / ~60 / -0 — OK
59 +- **Dernier sync** : 2026-08-18 13:26 — trouvé 869, +0 / ~199 / -0 — OK
60 60 - **Erreurs récentes** : 0 sur les 10 derniers syncs
61 61
62 62 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/sutton.md +13 −13
@@ -1,6 +1,6 @@
1 1 # Groupe Sutton Québec (`sutton`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -29,22 +29,22 @@
29 29
30 30 | Indicateur | Valeur |
31 31 |---|---|
32 −| Annonces en base (toutes) | 4 751 |
33 −| Actives | 4 525 |
34 −| Visibles (après dédup) | 4 515 |
35 −| Masquées par la dédup | 10 |
36 −| GPS (lat/lng) | 94.2 % |
37 −| Photos | 69.3 % |
38 −| Prix | 69.3 % |
39 −| CAC (chambres) | 71.4 % |
40 −| SDB (salles de bains) | 68.4 % |
41 −| Année de construction | 65.4 % (dont rôle : 98.6 %) |
42 −| Taxes / évaluation municipale | 68.3 % |
32 +| Annonces en base (toutes) | 4 765 |
33 +| Actives | 4 585 |
34 +| Visibles (après dédup) | 4 572 |
35 +| Masquées par la dédup | 13 |
36 +| GPS (lat/lng) | 94.1 % |
37 +| Photos | 91.3 % |
38 +| Prix | 91.3 % |
39 +| CAC (chambres) | 91.5 % |
40 +| SDB (salles de bains) | 90.6 % |
41 +| Année de construction | 77.0 % (dont rôle : 9.4 %) |
42 +| Taxes / évaluation municipale | 90.0 % |
43 43 | Estimation Vrai-Prix (rôle) | 100.0 % |
44 44
45 45 ## Synchronisation
46 46
47 −- **Dernier sync** : 2026-08-18 02:17 — trouvé 4 525, +0 / ~49 / -0 — OK
47 +- **Dernier sync** : 2026-08-18 13:32 — trouvé 4 575, +1 / ~379 / -10 — OK
48 48 - **Erreurs récentes** : 0 sur les 10 derniers syncs
49 49
50 50 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/ubee.md +16 −4
@@ -1,6 +1,6 @@
1 1 # Ubee (`ubee`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -23,14 +23,26 @@
23 23 > (pageIndex=N, 24 résultats/page, JSON riche : adresse, GPS, prix, pièces,
24 24 > superficies m², année, galerie Cloudinary complète).
25 25 > Deux volets À VENDRE (listingType=Seller) : résidentiel + commercial.
26 +> ENRICHISSEMENT DÉTAIL (fiche complète) :
27 +> GET api.ubee.ca/api/anonymous/Listing/{id}
28 +> retourne TOUT : description longue (FR/EN), n° Centris, ~30 listes de
29 +> caractéristiques énumérées (chauffage, sous-sol, garage, piscine, vue,
30 +> proximité…), taxes/frais (expenses), évaluation municipale, garantie
31 +> légale, délai d'occupation, stationnements, courtier inscripteur
32 +> (nom/agence/téléphone/courriel), visite virtuelle, lien Centris original.
33 +> Mise en cache BD (detail_cache) — clé = prix + photo de couverture, seules
34 +> les fiches nouvelles/modifiées sont re-lues (plafond IMMOKA_DETAIL_LIMIT).
35 +> NB : l'endpoint détail refuse les User-Agent non navigateur (403) — le
36 +> User-Agent de BaseConnector passe. Images Cloudinary : transformations
37 +> nommées strictes, t_default_size (1260 px) est le maximum public.
26 38
27 39 ## Volumétrie & complétude (BD live)
28 40
29 41 | Indicateur | Valeur |
30 42 |---|---|
31 43 | Annonces en base (toutes) | 1 220 |
32 −| Actives | 1 200 |
33 −| Visibles (après dédup) | 1 176 |
44 +| Actives | 1 199 |
45 +| Visibles (après dédup) | 1 175 |
34 46 | Masquées par la dédup | 24 |
35 47 | GPS (lat/lng) | 100.0 % |
36 48 | Photos | 100.0 % |
@@ -43,7 +55,7 @@
43 55
44 56 ## Synchronisation
45 57
46 −- **Dernier sync** : 2026-08-18 00:23 — trouvé 1 200, +0 / ~0 / -2 — OK
58 +- **Dernier sync** : 2026-08-18 13:33 — trouvé 1 199, +0 / ~1 / -1 — OK
47 59 - **Erreurs récentes** : 0 sur les 10 derniers syncs
48 60
49 61 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/vendirect.md +9 −9
@@ -1,6 +1,6 @@
1 1 # Vendirect (`vendirect`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -15,7 +15,7 @@
15 15 ## Mécanique (introspection du code)
16 16
17 17 - Module : `immoka/connectors/vendirect.py` — classe `VendirectConnector`
18 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
18 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
19 19
20 20 **En-tête du module :**
21 21
@@ -31,22 +31,22 @@
31 31
32 32 | Indicateur | Valeur |
33 33 |---|---|
34 −| Annonces en base (toutes) | 1 198 |
35 −| Actives | 1 170 |
36 −| Visibles (après dédup) | 1 170 |
34 +| Annonces en base (toutes) | 1 206 |
35 +| Actives | 1 174 |
36 +| Visibles (après dédup) | 1 174 |
37 37 | Masquées par la dédup | 0 |
38 38 | GPS (lat/lng) | 97.7 % |
39 39 | Photos | 100.0 % |
40 40 | Prix | 100.0 % |
41 −| CAC (chambres) | 74.2 % |
42 −| SDB (salles de bains) | 73.7 % |
43 −| Année de construction | 79.4 % (dont rôle : 6.9 %) |
41 +| CAC (chambres) | 74.3 % |
42 +| SDB (salles de bains) | 73.8 % |
43 +| Année de construction | 79.5 % (dont rôle : 6.9 %) |
44 44 | Taxes / évaluation municipale | 80.3 % |
45 45 | Estimation Vrai-Prix (rôle) | 100.0 % |
46 46
47 47 ## Synchronisation
48 48
49 −- **Dernier sync** : 2026-08-18 00:23 — trouvé 1 168, +3 / ~2 / -2 — OK
49 +- **Dernier sync** : 2026-08-18 13:33 — trouvé 1 173, +3 / ~6 / -2 — OK
50 50 - **Erreurs récentes** : 0 sur les 10 derniers syncs
51 51
52 52 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/via_ag_acces.md +6 −6
@@ -1,6 +1,6 @@
1 1 # Via Capitale Acces (`via_ag_acces`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -14,7 +14,7 @@
14 14 ## Mécanique (introspection du code)
15 15
16 16 - Module : `immoka/connectors/via_capitale_agences.py` — classe `ViaAgAcces`
17 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
17 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
18 18
19 19 **En-tête du module :**
20 20
@@ -36,10 +36,10 @@
36 36
37 37 | Indicateur | Valeur |
38 38 |---|---|
39 −| Annonces en base (toutes) | 186 |
40 −| Actives | 181 |
39 +| Annonces en base (toutes) | 187 |
40 +| Actives | 182 |
41 41 | Visibles (après dédup) | 7 |
42 −| Masquées par la dédup | 174 |
42 +| Masquées par la dédup | 175 |
43 43 | GPS (lat/lng) | 100.0 % |
44 44 | Photos | 100.0 % |
45 45 | Prix | 100.0 % |
@@ -51,7 +51,7 @@
51 51
52 52 ## Synchronisation
53 53
54 −- **Dernier sync** : 2026-08-18 00:27 — trouvé 181, +0 / ~0 / -0 — OK
54 +- **Dernier sync** : 2026-08-18 13:38 — trouvé 182, +1 / ~1 / -0 — OK
55 55 - **Erreurs récentes** : 0 sur les 10 derniers syncs
56 56
57 57 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/via_ag_dumontroyal.md +1 −1
@@ -1,6 +1,6 @@
1 1 # Via Capitale Dumontroyal (`via_ag_dumontroyal`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
modified docs/connecteurs/via_ag_elite.md +10 −10
@@ -1,6 +1,6 @@
1 1 # Via Capitale Elite (`via_ag_elite`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -14,7 +14,7 @@
14 14 ## Mécanique (introspection du code)
15 15
16 16 - Module : `immoka/connectors/via_capitale_agences.py` — classe `ViaAgElite`
17 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
17 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
18 18
19 19 **En-tête du module :**
20 20
@@ -37,21 +37,21 @@
37 37 | Indicateur | Valeur |
38 38 |---|---|
39 39 | Annonces en base (toutes) | 327 |
40 −| Actives | 319 |
41 −| Visibles (après dédup) | 10 |
42 −| Masquées par la dédup | 309 |
40 +| Actives | 317 |
41 +| Visibles (après dédup) | 9 |
42 +| Masquées par la dédup | 308 |
43 43 | GPS (lat/lng) | 100.0 % |
44 44 | Photos | 100.0 % |
45 45 | Prix | 100.0 % |
46 −| CAC (chambres) | 60.0 % |
47 −| SDB (salles de bains) | 60.0 % |
48 −| Année de construction | 80.0 % (dont rôle : 12.5 %) |
49 −| Taxes / évaluation municipale | 90.0 % |
46 +| CAC (chambres) | 55.6 % |
47 +| SDB (salles de bains) | 55.6 % |
48 +| Année de construction | 77.8 % (dont rôle : 14.3 %) |
49 +| Taxes / évaluation municipale | 88.9 % |
50 50 | Estimation Vrai-Prix (rôle) | 100.0 % |
51 51
52 52 ## Synchronisation
53 53
54 −- **Dernier sync** : 2026-08-18 00:28 — trouvé 317, +0 / ~0 / -1 — OK
54 +- **Dernier sync** : 2026-08-18 13:38 — trouvé 316, +0 / ~2 / -0 — OK
55 55 - **Erreurs récentes** : 0 sur les 10 derniers syncs
56 56
57 57 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/via_ag_select.md +10 −10
@@ -1,6 +1,6 @@
1 1 # Via Capitale Select (`via_ag_select`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -14,7 +14,7 @@
14 14 ## Mécanique (introspection du code)
15 15
16 16 - Module : `immoka/connectors/via_capitale_agences.py` — classe `ViaAgSelect`
17 −- Délai entre requêtes : 0.25 s ; cache détail BD : non
17 +- Délai entre requêtes : 0.25 s ; cache détail BD : oui
18 18
19 19 **En-tête du module :**
20 20
@@ -36,22 +36,22 @@
36 36
37 37 | Indicateur | Valeur |
38 38 |---|---|
39 −| Annonces en base (toutes) | 611 |
40 −| Actives | 580 |
41 −| Visibles (après dédup) | 13 |
42 −| Masquées par la dédup | 567 |
39 +| Annonces en base (toutes) | 616 |
40 +| Actives | 584 |
41 +| Visibles (après dédup) | 11 |
42 +| Masquées par la dédup | 573 |
43 43 | GPS (lat/lng) | 100.0 % |
44 44 | Photos | 100.0 % |
45 45 | Prix | 100.0 % |
46 −| CAC (chambres) | 61.5 % |
47 −| SDB (salles de bains) | 61.5 % |
46 +| CAC (chambres) | 63.6 % |
47 +| SDB (salles de bains) | 63.6 % |
48 48 | Année de construction | 100.0 % (dont rôle : 0.0 %) |
49 −| Taxes / évaluation municipale | 92.3 % |
49 +| Taxes / évaluation municipale | 90.9 % |
50 50 | Estimation Vrai-Prix (rôle) | 100.0 % |
51 51
52 52 ## Synchronisation
53 53
54 −- **Dernier sync** : 2026-08-18 00:28 — trouvé 580, +3 / ~5 / -8 — OK
54 +- **Dernier sync** : 2026-08-18 13:38 — trouvé 582, +5 / ~2 / -1 — OK
55 55 - **Erreurs récentes** : 0 sur les 10 derniers syncs
56 56
57 57 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified docs/connecteurs/via_capitale.md +6 −6
@@ -1,6 +1,6 @@
1 1 # Via Capitale (`via_capitale`)
2 2
3 −*Généré le 2026-08-18 06:06 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
3 +*Généré le 2026-08-18 18:11 par `scripts/gen_connector_docs.py` — fichier produit automatiquement, ne pas éditer à la main.*
4 4
5 5 ## Registre (`data/sources.json`)
6 6
@@ -40,15 +40,15 @@
40 40 | GPS (lat/lng) | 97.0 % |
41 41 | Photos | 100.0 % |
42 42 | Prix | 78.7 % |
43 −| CAC (chambres) | 19.6 % |
44 −| SDB (salles de bains) | 3.6 % |
45 −| Année de construction | 68.9 % (dont rôle : 100.0 %) |
46 −| Taxes / évaluation municipale | 87.1 % |
43 +| CAC (chambres) | 25.9 % |
44 +| SDB (salles de bains) | 11.0 % |
45 +| Année de construction | 69.4 % (dont rôle : 66.9 %) |
46 +| Taxes / évaluation municipale | 87.2 % |
47 47 | Estimation Vrai-Prix (rôle) | 100.0 % |
48 48
49 49 ## Synchronisation
50 50
51 −- **Dernier sync** : 2026-08-18 00:27 — trouvé 5 200, +0 / ~0 / -61 — OK
51 +- **Dernier sync** : 2026-08-18 13:37 — trouvé 5 200, +0 / ~200 / -0 — OK
52 52 - **Erreurs récentes** : 0 sur les 10 derniers syncs
53 53
54 54 Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) (budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db).
modified frontend/package-lock.json +39 −377
@@ -8,7 +8,8 @@
8 8 "name": "immo-ka-frontend",
9 9 "version": "1.0.0",
10 10 "dependencies": {
11 − "maplibre-gl": "^4.7.1",
11 + "@groupe-ka/ka-maps": "file:../../ka-maps",
12 + "mapbox-gl": "^3.28.1",
12 13 "react": "^18.3.1",
13 14 "react-dom": "^18.3.1",
14 15 "react-router-dom": "^6.26.0"
@@ -21,6 +22,28 @@
21 22 "vite": "^5.4.0"
22 23 }
23 24 },
25 + "../../ka-maps": {
26 + "name": "@groupe-ka/ka-maps",
27 + "version": "0.1.0",
28 + "license": "UNLICENSED",
29 + "dependencies": {
30 + "@types/geojson": "^7946.0.16",
31 + "mapbox-gl": "^3.28.1"
32 + },
33 + "devDependencies": {
34 + "@types/react": "^19.0.0",
35 + "typescript": "^5.8.0",
36 + "vitest": "^3.0.0"
37 + },
38 + "peerDependencies": {
39 + "react": ">=18"
40 + },
41 + "peerDependenciesMeta": {
42 + "react": {
43 + "optional": true
44 + }
45 + }
46 + },
24 47 "node_modules/@babel/code-frame": {
25 48 "version": "7.29.7",
26 49 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -694,6 +717,10 @@
694 717 "node": ">=12"
695 718 }
696 719 },
720 + "node_modules/@groupe-ka/ka-maps": {
721 + "resolved": "../../ka-maps",
722 + "link": true
723 + },
697 724 "node_modules/@jridgewell/gen-mapping": {
698 725 "version": "0.3.13",
699 726 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -744,90 +771,6 @@
744 771 "@jridgewell/sourcemap-codec": "^1.4.14"
745 772 }
746 773 },
747 − "node_modules/@mapbox/geojson-rewind": {
748 − "version": "0.5.2",
749 − "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
750 − "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
751 − "license": "ISC",
752 − "dependencies": {
753 − "get-stream": "^6.0.1",
754 − "minimist": "^1.2.6"
755 − },
756 − "bin": {
757 − "geojson-rewind": "geojson-rewind"
758 − }
759 − },
760 − "node_modules/@mapbox/jsonlint-lines-primitives": {
761 − "version": "2.0.3",
762 − "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
763 − "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
764 − "license": "MIT",
765 − "engines": {
766 − "node": ">= 22"
767 − }
768 − },
769 − "node_modules/@mapbox/point-geometry": {
770 − "version": "0.1.0",
771 − "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
772 − "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==",
773 − "license": "ISC"
774 − },
775 − "node_modules/@mapbox/tiny-sdf": {
776 − "version": "2.2.0",
777 − "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
778 − "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
779 − "license": "BSD-2-Clause"
780 − },
781 − "node_modules/@mapbox/unitbezier": {
782 − "version": "0.0.1",
783 − "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
784 − "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
785 − "license": "BSD-2-Clause"
786 − },
787 − "node_modules/@mapbox/vector-tile": {
788 − "version": "1.3.1",
789 − "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
790 − "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
791 − "license": "BSD-3-Clause",
792 − "dependencies": {
793 − "@mapbox/point-geometry": "~0.1.0"
794 − }
795 − },
796 − "node_modules/@mapbox/whoots-js": {
797 − "version": "3.1.0",
798 − "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
799 − "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
800 − "license": "ISC",
801 − "engines": {
802 − "node": ">=6.0.0"
803 − }
804 − },
805 − "node_modules/@maplibre/maplibre-gl-style-spec": {
806 − "version": "20.4.0",
807 − "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
808 − "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
809 − "license": "ISC",
810 − "dependencies": {
811 − "@mapbox/jsonlint-lines-primitives": "~2.0.2",
812 − "@mapbox/unitbezier": "^0.0.1",
813 − "json-stringify-pretty-compact": "^4.0.0",
814 − "minimist": "^1.2.8",
815 − "quickselect": "^2.0.0",
816 − "rw": "^1.3.3",
817 − "tinyqueue": "^3.0.0"
818 − },
819 − "bin": {
820 − "gl-style-format": "dist/gl-style-format.mjs",
821 − "gl-style-migrate": "dist/gl-style-migrate.mjs",
822 − "gl-style-validate": "dist/gl-style-validate.mjs"
823 − }
824 − },
825 − "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": {
826 − "version": "2.0.0",
827 − "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
828 − "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
829 − "license": "ISC"
830 − },
831 774 "node_modules/@napi-rs/lzma-linux-x64-gnu": {
832 775 "version": "1.5.1",
833 776 "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1305,44 +1248,6 @@
1305 1248 "dev": true,
1306 1249 "license": "MIT"
1307 1250 },
1308 − "node_modules/@types/geojson": {
1309 − "version": "7946.0.16",
1310 − "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
1311 − "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
1312 − "license": "MIT"
1313 − },
1314 − "node_modules/@types/geojson-vt": {
1315 − "version": "3.2.5",
1316 − "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
1317 − "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
1318 − "license": "MIT",
1319 − "dependencies": {
1320 − "@types/geojson": "*"
1321 − }
1322 − },
1323 − "node_modules/@types/mapbox__point-geometry": {
1324 − "version": "0.1.4",
1325 − "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
1326 − "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==",
1327 − "license": "MIT"
1328 − },
1329 − "node_modules/@types/mapbox__vector-tile": {
1330 − "version": "1.3.4",
1331 − "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
1332 − "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
1333 − "license": "MIT",
1334 − "dependencies": {
1335 − "@types/geojson": "*",
1336 − "@types/mapbox__point-geometry": "*",
1337 − "@types/pbf": "*"
1338 − }
1339 − },
1340 − "node_modules/@types/pbf": {
1341 − "version": "3.0.5",
1342 − "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
1343 − "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
1344 − "license": "MIT"
1345 − },
1346 1251 "node_modules/@types/prop-types": {
1347 1252 "version": "15.7.15",
1348 1253 "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1371,15 +1276,6 @@
1371 1276 "@types/react": "^18.0.0"
1372 1277 }
1373 1278 },
1374 − "node_modules/@types/supercluster": {
1375 − "version": "7.1.3",
1376 − "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
1377 − "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
1378 − "license": "MIT",
1379 − "dependencies": {
1380 − "@types/geojson": "*"
1381 − }
1382 − },
1383 1279 "node_modules/@vitejs/plugin-react": {
1384 1280 "version": "4.7.0",
1385 1281 "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1501,12 +1397,6 @@
1501 1397 }
1502 1398 }
1503 1399 },
1504 − "node_modules/earcut": {
1505 − "version": "3.2.3",
1506 − "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
1507 − "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
1508 − "license": "ISC"
1509 − },
1510 1400 "node_modules/electron-to-chromium": {
1511 1401 "version": "1.5.405",
1512 1402 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
@@ -1588,82 +1478,6 @@
1588 1478 "node": ">=6.9.0"
1589 1479 }
1590 1480 },
1591 − "node_modules/geojson-vt": {
1592 − "version": "4.0.3",
1593 − "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz",
1594 − "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==",
1595 − "license": "ISC"
1596 − },
1597 − "node_modules/get-stream": {
1598 − "version": "6.0.1",
1599 − "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
1600 − "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
1601 − "license": "MIT",
1602 − "engines": {
1603 − "node": ">=10"
1604 − },
1605 − "funding": {
1606 − "url": "https://github.com/sponsors/sindresorhus"
1607 − }
1608 − },
1609 − "node_modules/gl-matrix": {
1610 − "version": "3.4.4",
1611 − "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
1612 − "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
1613 − "license": "MIT"
1614 − },
1615 − "node_modules/global-prefix": {
1616 − "version": "4.0.0",
1617 − "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
1618 − "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
1619 − "license": "MIT",
1620 − "dependencies": {
1621 − "ini": "^4.1.3",
1622 − "kind-of": "^6.0.3",
1623 − "which": "^4.0.0"
1624 − },
1625 − "engines": {
1626 − "node": ">=16"
1627 − }
1628 − },
1629 − "node_modules/ieee754": {
1630 − "version": "1.2.1",
1631 − "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
1632 − "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
1633 − "funding": [
1634 − {
1635 − "type": "github",
1636 − "url": "https://github.com/sponsors/feross"
1637 − },
1638 − {
1639 − "type": "patreon",
1640 − "url": "https://www.patreon.com/feross"
1641 − },
1642 − {
1643 − "type": "consulting",
1644 − "url": "https://feross.org/support"
1645 − }
1646 − ],
1647 − "license": "BSD-3-Clause"
1648 − },
1649 − "node_modules/ini": {
1650 − "version": "4.1.3",
1651 − "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
1652 − "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
1653 − "license": "ISC",
1654 − "engines": {
1655 − "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
1656 − }
1657 − },
1658 − "node_modules/isexe": {
1659 − "version": "3.1.5",
1660 − "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
1661 − "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
1662 − "license": "BlueOak-1.0.0",
1663 − "engines": {
1664 − "node": ">=18"
1665 − }
1666 − },
1667 1481 "node_modules/js-tokens": {
1668 1482 "version": "4.0.0",
1669 1483 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1683,12 +1497,6 @@
1683 1497 "node": ">=6"
1684 1498 }
1685 1499 },
1686 − "node_modules/json-stringify-pretty-compact": {
1687 − "version": "4.0.0",
1688 − "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
1689 − "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
1690 − "license": "MIT"
1691 − },
1692 1500 "node_modules/json5": {
1693 1501 "version": "2.2.3",
1694 1502 "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -1702,21 +1510,6 @@
1702 1510 "node": ">=6"
1703 1511 }
1704 1512 },
1705 − "node_modules/kdbush": {
1706 − "version": "4.1.0",
1707 − "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
1708 − "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
1709 − "license": "ISC"
1710 − },
1711 − "node_modules/kind-of": {
1712 − "version": "6.0.3",
1713 − "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
1714 − "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
1715 − "license": "MIT",
1716 − "engines": {
1717 − "node": ">=0.10.0"
1718 − }
1719 − },
1720 1513 "node_modules/loose-envify": {
1721 1514 "version": "1.4.0",
1722 1515 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1739,55 +1532,17 @@
1739 1532 "yallist": "^3.0.2"
1740 1533 }
1741 1534 },
1742 − "node_modules/maplibre-gl": {
1743 − "version": "4.7.1",
1744 − "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
1745 − "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
1746 − "license": "BSD-3-Clause",
1747 − "dependencies": {
1748 − "@mapbox/geojson-rewind": "^0.5.2",
1749 − "@mapbox/jsonlint-lines-primitives": "^2.0.2",
1750 − "@mapbox/point-geometry": "^0.1.0",
1751 − "@mapbox/tiny-sdf": "^2.0.6",
1752 − "@mapbox/unitbezier": "^0.0.1",
1753 − "@mapbox/vector-tile": "^1.3.1",
1754 − "@mapbox/whoots-js": "^3.1.0",
1755 − "@maplibre/maplibre-gl-style-spec": "^20.3.1",
1756 − "@types/geojson": "^7946.0.14",
1757 − "@types/geojson-vt": "3.2.5",
1758 − "@types/mapbox__point-geometry": "^0.1.4",
1759 − "@types/mapbox__vector-tile": "^1.3.4",
1760 − "@types/pbf": "^3.0.5",
1761 − "@types/supercluster": "^7.1.3",
1762 − "earcut": "^3.0.0",
1763 − "geojson-vt": "^4.0.2",
1764 − "gl-matrix": "^3.4.3",
1765 − "global-prefix": "^4.0.0",
1766 − "kdbush": "^4.0.2",
1767 − "murmurhash-js": "^1.0.0",
1768 − "pbf": "^3.3.0",
1769 − "potpack": "^2.0.0",
1770 − "quickselect": "^3.0.0",
1771 − "supercluster": "^8.0.1",
1772 − "tinyqueue": "^3.0.0",
1773 − "vt-pbf": "^3.1.3"
1774 − },
1775 − "engines": {
1776 − "node": ">=16.14.0",
1777 − "npm": ">=8.1.0"
1778 − },
1779 − "funding": {
1780 − "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
1781 − }
1782 − },
1783 − "node_modules/minimist": {
1784 − "version": "1.2.8",
1785 − "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
1786 − "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
1787 − "license": "MIT",
1788 − "funding": {
1789 − "url": "https://github.com/sponsors/ljharb"
1790 − }
1535 + "node_modules/mapbox-gl": {
1536 + "version": "3.28.1",
1537 + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
1538 + "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
1539 + "license": "SEE LICENSE IN LICENSE.txt",
1540 + "workspaces": [
1541 + "src/style-spec",
1542 + "plugins/mapbox-gl-pmtiles-provider",
1543 + "test/bundlers/*",
1544 + "test/build/typings"
1545 + ]
1791 1546 },
1792 1547 "node_modules/ms": {
1793 1548 "version": "2.1.3",
@@ -1796,12 +1551,6 @@
1796 1551 "dev": true,
1797 1552 "license": "MIT"
1798 1553 },
1799 − "node_modules/murmurhash-js": {
1800 − "version": "1.0.0",
1801 − "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
1802 − "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
1803 − "license": "MIT"
1804 − },
1805 1554 "node_modules/nanoid": {
1806 1555 "version": "3.3.18",
1807 1556 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -1831,19 +1580,6 @@
1831 1580 "node": ">=18"
1832 1581 }
1833 1582 },
1834 − "node_modules/pbf": {
1835 − "version": "3.3.0",
1836 − "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
1837 − "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
1838 − "license": "BSD-3-Clause",
1839 − "dependencies": {
1840 − "ieee754": "^1.1.12",
1841 − "resolve-protobuf-schema": "^2.1.0"
1842 − },
1843 − "bin": {
1844 − "pbf": "bin/pbf"
1845 − }
1846 − },
1847 1583 "node_modules/picocolors": {
1848 1584 "version": "1.1.1",
1849 1585 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1880,24 +1616,6 @@
1880 1616 "node": "^10 || ^12 || >=14"
1881 1617 }
1882 1618 },
1883 − "node_modules/potpack": {
1884 − "version": "2.1.0",
1885 − "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
1886 − "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
1887 − "license": "ISC"
1888 − },
1889 − "node_modules/protocol-buffers-schema": {
1890 − "version": "3.6.1",
1891 − "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
1892 − "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
1893 − "license": "MIT"
1894 − },
1895 − "node_modules/quickselect": {
1896 − "version": "3.0.0",
1897 − "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
1898 − "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
1899 − "license": "ISC"
1900 − },
1901 1619 "node_modules/react": {
1902 1620 "version": "18.3.1",
1903 1621 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1965,15 +1683,6 @@
1965 1683 "react-dom": ">=16.8"
1966 1684 }
1967 1685 },
1968 − "node_modules/resolve-protobuf-schema": {
1969 − "version": "2.1.0",
1970 − "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
1971 − "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
1972 − "license": "MIT",
1973 − "dependencies": {
1974 − "protocol-buffers-schema": "^3.3.1"
1975 − }
1976 − },
1977 1686 "node_modules/rollup": {
1978 1687 "version": "4.62.4",
1979 1688 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
@@ -2020,12 +1729,6 @@
2020 1729 "fsevents": "~2.3.2"
2021 1730 }
2022 1731 },
2023 − "node_modules/rw": {
2024 − "version": "1.3.3",
2025 − "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
2026 − "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
2027 − "license": "BSD-3-Clause"
2028 − },
2029 1732 "node_modules/scheduler": {
2030 1733 "version": "0.23.2",
2031 1734 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -2055,21 +1758,6 @@
2055 1758 "node": ">=0.10.0"
2056 1759 }
2057 1760 },
2058 − "node_modules/supercluster": {
2059 − "version": "8.0.1",
2060 − "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
2061 − "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
2062 − "license": "ISC",
2063 − "dependencies": {
2064 − "kdbush": "^4.0.2"
2065 − }
2066 − },
2067 − "node_modules/tinyqueue": {
2068 − "version": "3.0.0",
2069 − "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
2070 − "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
2071 − "license": "ISC"
2072 − },
2073 1761 "node_modules/typescript": {
2074 1762 "version": "5.9.3",
2075 1763 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2175,32 +1863,6 @@
2175 1863 }
2176 1864 }
2177 1865 },
2178 − "node_modules/vt-pbf": {
2179 − "version": "3.1.3",
2180 − "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
2181 − "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
2182 − "license": "MIT",
2183 − "dependencies": {
2184 − "@mapbox/point-geometry": "0.1.0",
2185 − "@mapbox/vector-tile": "^1.3.1",
2186 − "pbf": "^3.2.1"
2187 − }
2188 − },
2189 − "node_modules/which": {
2190 − "version": "4.0.0",
2191 − "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
2192 − "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
2193 − "license": "ISC",
2194 − "dependencies": {
2195 − "isexe": "^3.1.1"
2196 − },
2197 − "bin": {
2198 − "node-which": "bin/which.js"
2199 − },
2200 − "engines": {
2201 − "node": "^16.13.0 || >=18.0.0"
2202 − }
2203 − },
2204 1866 "node_modules/yallist": {
2205 1867 "version": "3.1.1",
2206 1868 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
modified frontend/package.json +3 −2
@@ -10,7 +10,8 @@
10 10 "preview": "vite preview"
11 11 },
12 12 "dependencies": {
13 − "maplibre-gl": "^4.7.1",
13 + "@groupe-ka/ka-maps": "file:../../ka-maps",
14 + "mapbox-gl": "^3.28.1",
14 15 "react": "^18.3.1",
15 16 "react-dom": "^18.3.1",
16 17 "react-router-dom": "^6.26.0"
@@ -22,4 +23,4 @@
22 23 "typescript": "^5.5.4",
23 24 "vite": "^5.4.0"
24 25 }
25 −}
26 +}
\ No newline at end of file
modified frontend/public/ka-agent.js +177 −48
@@ -3,7 +3,11 @@
3 3 * Groupe KA. Vanilla JS, zéro dépendance, injecté sur les 13 sites.
4 4 * <script>window.KA_AGENT={site:"lou-ka"}</script>
5 5 * <script src="/ka-agent.js" defer></script>
6 − * Bulle flottante en bas à droite → panneau de chat (bulle) → plein écran (⛶).
6 + * v2 (2026-08-19) : ouverture directement PLEIN ÉCRAN (desktop et mobile,
7 + * bouton ▭ pour réduire en fenêtre sur desktop) ; sur téléphone le panneau se
8 + * cale sur window.visualViewport (le clavier ne fait plus grossir/défiler la
9 + * page — fond verrouillé par body position:fixed) ; rendu MARKDOWN complet en
10 + * streaming (titres, listes, tableaux, code, liens nommés, citations).
7 11 * Flux SSE en direct depuis le backend central (texte token par token +
8 12 * indicateur d'outil). Historique conservé en localStorage (par site).
9 13 */
@@ -21,60 +25,147 @@
21 25 border-radius:50%;border:2px solid var(--ink,#141814);background:var(--accent,#d9f26b);
22 26 color:var(--on-accent,#141814);font:700 17px/1 "Space Grotesk",system-ui,sans-serif;
23 27 cursor:pointer;box-shadow:4px 4px 0 rgba(20,24,20,.85);display:flex;align-items:center;
24 − justify-content:center;transition:transform .15s}
28 + justify-content:center;transition:transform .15s;touch-action:manipulation}
25 29 .kaa-btn:hover{transform:translate(-2px,-2px)}
26 − .kaa-panel{position:fixed;right:16px;bottom:calc(86px + env(safe-area-inset-bottom,0px));z-index:2147483001;width:min(392px,calc(100vw - 24px));
27 − height:min(580px,calc(100vh - 110px));height:min(580px,calc(100dvh - 110px));display:none;flex-direction:column;background:var(--paper,#f5f3ee);
28 − border:2px solid var(--ink,#141814);border-radius:14px;box-shadow:8px 8px 0 rgba(20,24,20,.85);
29 − overflow:hidden;font-family:Inter,system-ui,sans-serif}
30 + .kaa-panel{position:fixed;z-index:2147483001;display:none;flex-direction:column;background:var(--paper,#f5f3ee);
31 + overflow:hidden;font-family:Inter,system-ui,sans-serif;touch-action:manipulation}
30 32 .kaa-panel.kaa-open{display:flex}
31 − .kaa-panel.kaa-full{right:0;bottom:0;width:100vw;height:100vh;height:100dvh;border-radius:0;border:0;box-shadow:none}
32 − html.kaa-lock,html.kaa-lock body{overflow:hidden!important;overscroll-behavior:none}
33 + .kaa-panel.kaa-full{left:0;top:0;right:0;bottom:0;width:100vw;height:100vh;height:100dvh;border:0;border-radius:0;box-shadow:none}
34 + .kaa-panel:not(.kaa-full){right:16px;bottom:calc(86px + env(safe-area-inset-bottom,0px));width:min(392px,calc(100vw - 24px));
35 + height:min(580px,calc(100vh - 110px));height:min(580px,calc(100dvh - 110px));
36 + border:2px solid var(--ink,#141814);border-radius:14px;box-shadow:8px 8px 0 rgba(20,24,20,.85)}
37 + html.kaa-lock,html.kaa-lock body{overflow:hidden!important;overscroll-behavior:none;touch-action:none}
33 38 .kaa-head{display:flex;align-items:center;gap:9px;padding:11px 14px;background:var(--ink,#141814);
34 − color:var(--paper,#f5f3ee);flex:none}
39 + color:var(--paper,#f5f3ee);flex:none;padding-top:max(11px,env(safe-area-inset-top,0px))}
35 40 .kaa-head b{font:700 15px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
36 41 .kaa-head .kaa-ka{display:inline-block;background:var(--accent,#d9f26b);color:var(--on-accent,#141814);
37 42 border-radius:6px;padding:1px 7px 2px;transform:rotate(-2deg);font-weight:700}
38 43 .kaa-head small{font:700 9.5px "JetBrains Mono",monospace;letter-spacing:.09em;text-transform:uppercase;opacity:.65}
39 44 .kaa-head .kaa-sp{flex:1}
40 45 .kaa-hbtn{width:34px;height:34px;border:1.5px solid rgba(245,243,238,.4);border-radius:8px;background:none;
41 − color:var(--paper,#f5f3ee);font-size:15px;cursor:pointer;line-height:1}
46 + color:var(--paper,#f5f3ee);font-size:15px;cursor:pointer;line-height:1;flex:none}
42 47 .kaa-hbtn:hover{border-color:var(--accent,#d9f26b);color:var(--accent,#d9f26b)}
43 48 .kaa-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px;
44 49 overscroll-behavior:contain;-webkit-overflow-scrolling:touch}
45 50 .kaa-msg{max-width:86%;padding:9px 13px;border:1.5px solid var(--ink,#141814);border-radius:12px;
46 − font-size:13.5px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
51 + font-size:13.5px;line-height:1.55;word-break:break-word}
52 + .kaa-full .kaa-msg{max-width:min(86%,760px)}
53 + .kaa-full .kaa-log{align-items:stretch}
47 54 .kaa-msg.kaa-u{align-self:flex-end;background:var(--accent,#d9f26b);color:var(--on-accent,#141814);
48 − border-bottom-right-radius:4px}
55 + border-bottom-right-radius:4px;white-space:pre-wrap}
49 56 .kaa-msg.kaa-a{align-self:flex-start;background:#fff;border-bottom-left-radius:4px;box-shadow:3px 3px 0 rgba(20,24,20,.12)}
50 − .kaa-msg.kaa-a a{color:inherit;text-decoration:underline;text-underline-offset:3px}
51 − .kaa-msg.kaa-a b{font-weight:700}
57 + .kaa-msg.kaa-a a{color:inherit;text-decoration:underline;text-underline-offset:3px;font-weight:600}
58 + .kaa-msg.kaa-a p{margin:.35em 0}
59 + .kaa-msg.kaa-a p:first-child{margin-top:0}
60 + .kaa-msg.kaa-a p:last-child{margin-bottom:0}
61 + .kaa-msg.kaa-a h1,.kaa-msg.kaa-a h2,.kaa-msg.kaa-a h3,.kaa-msg.kaa-a h4{
62 + font:700 14px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.01em;margin:.7em 0 .3em}
63 + .kaa-msg.kaa-a h1:first-child,.kaa-msg.kaa-a h2:first-child,.kaa-msg.kaa-a h3:first-child,.kaa-msg.kaa-a h4:first-child{margin-top:.1em}
64 + .kaa-msg.kaa-a h1{font-size:15.5px}.kaa-msg.kaa-a h2{font-size:14.5px}
65 + .kaa-msg.kaa-a ul,.kaa-msg.kaa-a ol{margin:.35em 0;padding-left:1.35em}
66 + .kaa-msg.kaa-a li{margin:.18em 0}
67 + .kaa-msg.kaa-a code{font:600 12px "JetBrains Mono",monospace;background:var(--surface-2,#f0eee7);
68 + border:1px solid rgba(20,24,20,.15);border-radius:5px;padding:.5px 4px}
69 + .kaa-msg.kaa-a pre{margin:.45em 0;padding:9px 11px;background:var(--ink,#141814);color:var(--paper,#f5f3ee);
70 + border-radius:9px;overflow-x:auto}
71 + .kaa-msg.kaa-a pre code{background:none;border:0;color:inherit;padding:0;font-weight:400;font-size:11.5px;line-height:1.5}
72 + .kaa-msg.kaa-a blockquote{margin:.45em 0;padding:.15em .8em;border-left:3px solid var(--accent,#d9f26b);
73 + background:var(--surface-2,#faf9f5);color:var(--ink-2,#4d5551)}
74 + .kaa-msg.kaa-a hr{border:0;border-top:1.5px dashed rgba(20,24,20,.3);margin:.6em 0}
75 + .kaa-msg.kaa-a table{border-collapse:collapse;margin:.45em 0;font-size:12.5px;display:block;overflow-x:auto;max-width:100%}
76 + .kaa-msg.kaa-a th,.kaa-msg.kaa-a td{border:1.5px solid var(--ink,#141814);padding:4px 9px;text-align:left;white-space:nowrap}
77 + .kaa-msg.kaa-a th{background:var(--accent,#d9f26b);color:var(--on-accent,#141814);
78 + font:700 11px "Space Grotesk",system-ui,sans-serif;letter-spacing:.02em}
79 + .kaa-msg.kaa-a td{background:#fff}
52 80 .kaa-tool{align-self:flex-start;font:700 10px "JetBrains Mono",monospace;letter-spacing:.07em;
53 81 text-transform:uppercase;color:var(--ink-2,#4d5551);padding:3px 10px;border:1.5px dashed var(--ink-2,#4d5551);
54 82 border-radius:999px;background:var(--surface-2,#faf9f5)}
55 83 .kaa-in{display:flex;gap:8px;padding:11px;border-top:2px solid var(--ink,#141814);background:#fff;flex:none;
56 84 padding-bottom:calc(11px + env(safe-area-inset-bottom,0))}
85 + .kaa-full .kaa-in{justify-content:center}
86 + .kaa-full .kaa-in textarea{max-width:700px}
57 87 .kaa-in textarea{flex:1;resize:none;min-height:44px;max-height:110px;padding:10px 12px;font:16px/1.4 Inter,system-ui,sans-serif;
58 88 border:1.5px solid var(--ink,#141814);border-radius:9px;background:var(--paper,#f5f3ee);outline:none}
59 89 .kaa-in button{min-width:52px;min-height:44px;border:1.5px solid var(--ink,#141814);border-radius:9px;
60 − background:var(--ink,#141814);color:var(--accent,#d9f26b);font:700 16px "Space Grotesk",system-ui;cursor:pointer}
90 + background:var(--ink,#141814);color:var(--accent,#d9f26b);font:700 16px "Space Grotesk",system-ui;cursor:pointer;touch-action:manipulation}
61 91 .kaa-in button:disabled{opacity:.45}
62 − .kaa-hello{font-size:12.5px;color:var(--ink-2,#4d5551);padding:4px 2px}
92 + .kaa-hello{font-size:12.5px;color:var(--ink-2,#4d5551);padding:4px 2px;max-width:760px}
63 93 .kaa-dots::after{content:"…";animation:kaa-b 1.2s infinite}
64 94 @keyframes kaa-b{0%{opacity:.2}50%{opacity:1}100%{opacity:.2}}
65 − @media (max-width:560px){.kaa-panel{right:8px;left:8px;width:auto}}
95 + @media (max-width:640px){.kaa-x1{display:none}}
66 96 `;
67 97
68 − /* ---------- markdown minimal (gras, liens, puces) — texte échappé d'abord ---------- */
98 + /* ---------- markdown complet (rendu en streaming, texte échappé d'abord) ---------- */
69 99 function esc(s) {
70 100 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
71 101 }
72 − function md(s) {
73 − return esc(s)
74 − .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
75 − .replace(/(https?:\/\/[^\s<)"']+)/g, function (u) {
76 − return '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' + u.replace(/^https?:\/\/(www\.)?/, "").slice(0, 40) + "</a>";
77 − });
102 + function inline(s) {
103 + s = esc(s);
104 + s = s.replace(/`([^`\n]+)`/g, "<code>$1</code>");
105 + s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g,
106 + '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
107 + s = s.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>");
108 + s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\*)/g, "$1<i>$2</i>");
109 + s = s.replace(/(^|[^"'>=\w])(https?:\/\/[^\s<)"']+)/g, function (m, pre, u) {
110 + return pre + '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' +
111 + u.replace(/^https?:\/\/(www\.)?/, "").slice(0, 42) + "</a>";
112 + });
113 + return s;
114 + }
115 + function md(src) {
116 + var lines = String(src).split("\n"), out = [], i = 0, m;
117 + function para(buf) { if (buf.length) out.push("<p>" + buf.map(inline).join("<br>") + "</p>"); }
118 + while (i < lines.length) {
119 + var l = lines[i];
120 + if (/^\s*```/.test(l)) { /* bloc de code */
121 + var code = []; i++;
122 + while (i < lines.length && !/^\s*```/.test(lines[i])) code.push(lines[i++]);
123 + i++;
124 + out.push("<pre><code>" + esc(code.join("\n")) + "</code></pre>");
125 + } else if ((m = l.match(/^\s*(#{1,4})\s+(.*)$/))) { /* titres */
126 + out.push("<h" + m[1].length + ">" + inline(m[2]) + "</h" + m[1].length + ">"); i++;
127 + } else if (/^\s*(---+|\*\*\*+)\s*$/.test(l)) { /* filet */
128 + out.push("<hr>"); i++;
129 + } else if (/^\s*&gt;|^\s*>/.test(l)) { /* citation */
130 + var q = [];
131 + while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, ""));
132 + out.push("<blockquote>" + q.map(inline).join("<br>") + "</blockquote>");
133 + } else if (/^\s*\|.*\|\s*$/.test(l)) { /* tableau */
134 + var rows = [];
135 + while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
136 + var cells = lines[i].trim().replace(/^\||\|$/g, "").split("|").map(function (c) { return c.trim(); });
137 + if (!/^[:\-\s|]+$/.test(lines[i].replace(/\|/g, ""))) rows.push(cells);
138 + i++;
139 + }
140 + if (rows.length) {
141 + var t = "<table>";
142 + rows.forEach(function (r, ri) {
143 + var tag = ri === 0 ? "th" : "td";
144 + t += "<tr>" + r.map(function (c) { return "<" + tag + ">" + inline(c) + "</" + tag + ">"; }).join("") + "</tr>";
145 + });
146 + out.push(t + "</table>");
147 + }
148 + } else if (/^\s*[-*+]\s+/.test(l)) { /* liste à puces */
149 + var ul = [];
150 + while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i]))
151 + ul.push("<li>" + inline(lines[i++].replace(/^\s*[-*+]\s+/, "")) + "</li>");
152 + out.push("<ul>" + ul.join("") + "</ul>");
153 + } else if (/^\s*\d+[.)]\s+/.test(l)) { /* liste numérotée */
154 + var ol = [];
155 + while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i]))
156 + ol.push("<li>" + inline(lines[i++].replace(/^\s*\d+[.)]\s+/, "")) + "</li>");
157 + out.push("<ol>" + ol.join("") + "</ol>");
158 + } else if (!l.trim()) { i++; } /* ligne vide */
159 + else { /* paragraphe */
160 + var buf = [];
161 + while (i < lines.length && lines[i].trim() &&
162 + !/^\s*(```|#{1,4}\s|[-*+]\s|\d+[.)]\s|>|\||---+\s*$)/.test(lines[i]))
163 + buf.push(lines[i++]);
164 + if (!buf.length) buf.push(lines[i++] || "");
165 + para(buf);
166 + }
167 + }
168 + return out.join("");
78 169 }
79 170
80 171 /* ---------- état ---------- */
@@ -98,10 +189,10 @@
98 189 panel.setAttribute("aria-label", "KA Agent");
99 190 panel.innerHTML =
100 191 '<div class="kaa-head"><b>KA<span class="kaa-ka">Agent</span></b><small>Groupe KA · IA</small><span class="kaa-sp"></span>' +
101 − '<button class="kaa-hbtn kaa-x1" title="Plein écran" aria-label="Plein écran">⛶</button>' +
192 + '<button class="kaa-hbtn kaa-x1" title="Réduire / agrandir" aria-label="Réduire ou agrandir">▭</button>' +
102 193 '<button class="kaa-hbtn kaa-x2" title="Fermer" aria-label="Fermer">✕</button></div>' +
103 194 '<div class="kaa-log"></div>' +
104 − '<div class="kaa-in"><textarea rows="1" placeholder="Posez votre question…" aria-label="Votre question"></textarea>' +
195 + '<div class="kaa-in"><textarea rows="1" enterkeyhint="send" autocapitalize="sentences" autocomplete="off" placeholder="Posez votre question…" aria-label="Votre question"></textarea>' +
105 196 "<button aria-label=\"Envoyer\">➤</button></div>";
106 197 document.body.appendChild(btn);
107 198 document.body.appendChild(panel);
@@ -132,45 +223,83 @@
132 223 if (log.length) return;
133 224 var d = document.createElement("div");
134 225 d.className = "kaa-hello";
135 − d.textContent = "👋 Je suis KA Agent, l'assistant de l'écosystème Groupe KA. Posez-moi une question sur ce site, ses données (prix, annonces, stats…) ou sur le groupe.";
226 + d.textContent = "👋 Je suis KA Agent, l'assistant de l'écosystème Groupe KA. Posez-moi une question sur ce site, ses données (prix, annonces, fiches, stats…) ou sur le groupe.";
136 227 logEl.appendChild(d);
137 228 }
138 229 function render() {
139 230 logEl.innerHTML = "";
140 231 hello();
141 − log.forEach(function (m) { bubble(m.role === "user" ? "kaa-u" : "kaa-a", md(m.content)); });
232 + log.forEach(function (m) {
233 + m.role === "user" ? bubble("kaa-u", esc(m.content)) : bubble("kaa-a", md(m.content));
234 + });
142 235 scroll();
143 236 }
144 237
145 − /* ---------- ouverture / plein écran ---------- */
146 − /* Sur téléphone : panneau plein écran d'office + scroll d'arrière-plan
147 − verrouillé tant qu'il est ouvert (html.kaa-lock). Pas de focus clavier
148 − forcé au tap (le clavier ne s'ouvre que si l'utilisateur touche le champ). */
149 − var mobileMq = window.matchMedia("(max-width: 640px)");
238 + /* ---------- plein écran + clavier mobile ----------
239 + Ouverture = PLEIN ÉCRAN direct (desktop et mobile). ▭ réduit en fenêtre
240 + (desktop seulement — caché sur mobile). Tant que le plein écran est ouvert,
241 + l'arrière-plan est GELÉ (body position:fixed, scroll restauré à la
242 + fermeture) et le panneau se cale sur window.visualViewport : quand le
243 + clavier s'ouvre, le panneau rétrécit à la place de laisser iOS
244 + zoomer/pousser la page. Pas de focus clavier forcé au tap sur mobile. */
150 245 var fineInput = window.matchMedia("(hover: hover) and (pointer: fine)");
151 − function syncLock() {
152 − var open = panel.classList.contains("kaa-open");
153 − if (open && mobileMq.matches) panel.classList.add("kaa-full");
154 − document.documentElement.classList.toggle(
155 − "kaa-lock", open && panel.classList.contains("kaa-full"));
246 + var vv = window.visualViewport;
247 + var savedY = 0;
248 +
249 + function isOpen() { return panel.classList.contains("kaa-open"); }
250 + function isFull() { return panel.classList.contains("kaa-full"); }
251 +
252 + function fitVV() {
253 + if (!vv || !isOpen() || !isFull()) { panel.style.height = ""; panel.style.top = ""; return; }
254 + panel.style.top = Math.round(vv.offsetTop) + "px";
255 + panel.style.height = Math.round(vv.height) + "px";
256 + scroll();
156 257 }
157 − btn.addEventListener("click", function () {
158 − panel.classList.toggle("kaa-open");
159 − if (panel.classList.contains("kaa-open")) {
160 − render();
161 − if (fineInput.matches) ta.focus();
258 + if (vv) {
259 + vv.addEventListener("resize", fitVV);
260 + vv.addEventListener("scroll", fitVV);
261 + }
262 +
263 + function lockBg(on) {
264 + var b = document.body, h = document.documentElement;
265 + if (on && !h.classList.contains("kaa-lock")) {
266 + savedY = window.scrollY || 0;
267 + h.classList.add("kaa-lock");
268 + b.style.position = "fixed";
269 + b.style.top = -savedY + "px";
270 + b.style.left = "0"; b.style.right = "0"; b.style.width = "100%";
271 + } else if (!on && h.classList.contains("kaa-lock")) {
272 + h.classList.remove("kaa-lock");
273 + b.style.position = ""; b.style.top = ""; b.style.left = ""; b.style.right = ""; b.style.width = "";
274 + window.scrollTo(0, savedY);
162 275 }
163 − syncLock();
276 + }
277 + function sync() {
278 + lockBg(isOpen() && isFull());
279 + fitVV();
280 + }
281 +
282 + btn.addEventListener("click", function () {
283 + panel.classList.add("kaa-open", "kaa-full"); // plein écran d'office
284 + render();
285 + if (fineInput.matches) ta.focus();
286 + sync();
164 287 });
165 288 panel.querySelector(".kaa-x2").addEventListener("click", function () {
166 289 panel.classList.remove("kaa-open", "kaa-full");
167 − syncLock();
290 + sync();
168 291 });
169 292 panel.querySelector(".kaa-x1").addEventListener("click", function () {
170 293 panel.classList.toggle("kaa-full");
171 − syncLock();
294 + sync();
172 295 scroll();
173 296 });
297 + document.addEventListener("keydown", function (e) {
298 + if (e.key === "Escape" && isOpen()) {
299 + panel.classList.remove("kaa-open", "kaa-full");
300 + sync();
301 + }
302 + });
174 303
175 304 /* ---------- envoi + flux SSE ---------- */
176 305 var busy = false;
@@ -182,7 +311,7 @@
182 311 ta.value = "";
183 312 log.push({ role: "user", content: q });
184 313 save();
185 − bubble("kaa-u", md(q));
314 + bubble("kaa-u", esc(q));
186 315 var out = bubble("kaa-a", '<span class="kaa-dots"></span>');
187 316 var acc = "";
188 317 var chips = [];
@@ -211,7 +340,7 @@
211 340 try { data = JSON.parse(dm); } catch (e) {}
212 341 if (ev === "delta" && data.text) {
213 342 acc += data.text;
214 − out.innerHTML = md(acc);
343 + out.innerHTML = md(acc); // markdown re-rendu à chaque delta
215 344 scroll();
216 345 } else if (ev === "tool") {
217 346 chips.push(toolChip(data.name || "recherche"));
modified frontend/src/App.tsx +3 −0
@@ -214,6 +214,9 @@ export default function App() {
214 214 <Routes>
215 215 <Route path="/" element={<Home />} />
216 216 <Route path="/propriete/:uid" element={<ListingPage />} />
217 + {/* URL canonique SEO : /propriete/{uid}/{slug} (301 du serveur) —
218 + sans cette route, tout lien direct/partagé tombait sur la page 404 */}
219 + <Route path="/propriete/:uid/:slug" element={<ListingPage />} />
217 220 <Route path="/stats" element={<StatsPage />} />
218 221 <Route path="/agences" element={<SourcesPage />} />
219 222 <Route path="/contact" element={<ContactPage />} />
modified frontend/src/api.ts +14 −0
@@ -93,6 +93,20 @@ export interface Stats {
93 93 ensemble: VpBanniere | null;
94 94 bannieres: VpBanniere[];
95 95 };
96 + qualite?: Qualite;
97 +}
98 +
99 +/** Qualité des données (complétude, quarantaine, anomalies) — immoka/quality.py */
100 +export interface Qualite {
101 + actives: number;
102 + publiees: number;
103 + quarantaine: number;
104 + completude_moyenne: number | null;
105 + anomalies: Record<string, number>;
106 + par_source: {
107 + source: string; n: number; publiees: number;
108 + completude: number | null; anomalies: number;
109 + }[];
96 110 }
97 111
98 112 /** Écart prix demandé vs estimation Vrai-Prix, agrégé par bannière. */
modified frontend/src/components/ListingCard.tsx +11 −7
@@ -6,9 +6,14 @@
6 6 import { Link } from "react-router-dom";
7 7 import { Listing, fmtArea, fmtPrice, sourceName } from "../api";
8 8 import { Ico } from "./Icons";
9 +import PropertyImg from "./PropertyImg";
9 10
10 11 export default function ListingCard({ l }: { l: Listing }) {
11 − const img = l.images && l.images.length > 0 ? l.images[0] : null;
12 + // miniature légère si le connecteur en fournit une (mobile/cellulaire),
13 + // sinon la photo de couverture pleine taille
14 + const img = (typeof l.details?.cover_thumb === "string" && l.details.cover_thumb)
15 + || (l.images && l.images.length > 0 ? l.images[0] : null);
16 + const location = l.details?.transaction === "location";
12 17 const meta: { ico: string; txt: string }[] = [];
13 18 if (l.bedrooms != null) meta.push({ ico: "bed", txt: `${l.bedrooms} ch.` });
14 19 if (l.bathrooms != null) meta.push({ ico: "bath", txt: `${l.bathrooms} sdb` });
@@ -18,18 +23,17 @@ export default function ListingCard({ l }: { l: Listing }) {
18 23 return (
19 24 <Link to={`/propriete/${encodeURIComponent(l.uid)}`} className="card">
20 25 <div className="card-img">
21 − {img ? (
22 − <img src={img} alt={l.title || l.address} loading="lazy" />
23 − ) : (
24 − <div className="noimg"><Ico name="home" size={34} /></div>
25 − )}
26 + <PropertyImg src={img} alt={l.title || l.address} type={l.property_type} />
26 27 {l.property_type && <span className="badge type">{l.property_type}</span>}
27 28 {l.images.length > 1 && (
28 29 <span className="badge right"><Ico name="camera" size={12} /> {l.images.length}</span>
29 30 )}
30 31 </div>
31 32 <div className="card-body">
32 − <div className="card-price">{fmtPrice(l.price, l.price_label)}</div>
33 + <div className="card-price">
34 + {fmtPrice(l.price, l.price_label)}
35 + {location && <span className="per-month"> /mois</span>}
36 + </div>
33 37 <div className="card-title">{l.address || l.title}</div>
34 38 <div className="card-meta">
35 39 {l.sector && <span>{l.sector}</span>}
modified frontend/src/components/MapView.tsx +0 −0

Binary file not shown.

added frontend/src/components/PropertyImg.tsx +45 −0
@@ -0,0 +1,45 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/PropertyImg.tsx : image de propriété robuste
5 +// · <TypeFallback/> : image de secours élégante PAR TYPE DE BIEN aux couleurs
6 +// Immo-Ka — jamais d'icône d'image cassée ni de vide gris.
7 +// · <PropertyImg/> : <img> avec repli automatique si l'URL ne charge pas.
8 +// -----------------------------------------------------------------------------
9 +import { useState } from "react";
10 +import { Ico } from "./Icons";
11 +
12 +const TYPE_ICONS: Record<string, string> = {
13 + "Maison": "home", "Maison mobile": "home", "Maison de ville": "home",
14 + "Jumelé": "home", "Chalet": "tree", "Condo": "building",
15 + "Duplex": "building", "Triplex": "building", "Multiplex": "building",
16 + "Terrain": "land", "Fermette/Agricole": "leaf", "Commercial": "cart",
17 +};
18 +
19 +/** Image de secours par type de bien (gradient cerise + icône + libellé). */
20 +export function TypeFallback({ type, label = true }: { type?: string; label?: boolean }) {
21 + const ico = TYPE_ICONS[type ?? ""] ?? "home";
22 + return (
23 + <div className="type-fallback" aria-label={type || "Propriété"}>
24 + <Ico name={ico} size={40} />
25 + {label && <span>{type || "Photos à venir"}</span>}
26 + </div>
27 + );
28 +}
29 +
30 +/** <img> qui bascule sur l'image de secours du type si le chargement échoue. */
31 +export default function PropertyImg({
32 + src, alt, type, eager = false,
33 +}: { src?: string | null; alt: string; type?: string; eager?: boolean }) {
34 + const [broken, setBroken] = useState(false);
35 + if (!src || broken) return <TypeFallback type={type} />;
36 + return (
37 + <img
38 + src={src}
39 + alt={alt}
40 + loading={eager ? "eager" : "lazy"}
41 + decoding="async"
42 + onError={() => setBroken(true)}
43 + />
44 + );
45 +}
modified frontend/src/components/PropertyMap.tsx +55 −78
@@ -1,92 +1,69 @@
1 1 // -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/PropertyMap.tsx : carte d'emplacement de la fiche propriété
5 −// · centrée et zoomée sur LA propriété (fond « Groupe Ka » maison)
6 −// · marqueur pulsant + pastille de prix, rouge si sous l'estimation Vrai-Prix
7 −// · anneaux « 5 min / 15 min à pied » — signature visuelle Immo-Ka
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Immo-Ka integration)
5 +// components/PropertyMap.tsx : mini-carte 3D de la fiche — MÊME composant que
6 +// Lou-Ka (KaSpotlightMap) : caméra serrée sur l'adresse, fond Mapbox Standard
7 +// réaliste, bâtiment de l'annonce surligné en CERISE Immo-Ka. Chargée
8 +// paresseusement depuis Listing.tsx.
8 9 // -----------------------------------------------------------------------------
9 −import { useEffect, useRef } from "react";
10 −import maplibregl from "maplibre-gl";
11 −import "maplibre-gl/dist/maplibre-gl.css";
12 −import immokaStyle from "../map/immokaStyle";
13 −import { shortPrice } from "../map/pills";
10 +import { useMemo } from "react";
11 +import "mapbox-gl/dist/mapbox-gl.css";
12 +import "@groupe-ka/ka-maps/styles.css";
13 +import type { MapProperty } from "@groupe-ka/ka-maps";
14 +import { KaBrandBadge, KaSpotlightMap } from "@groupe-ka/ka-maps/react";
15 +import { immoKaMapTheme } from "../kamaps/theme";
16 +import { MAPBOX_TOKEN } from "../kamaps/config";
14 17
15 −/** Cercle géodésique approx. (polygone 72 côtés) autour d'un point. */
16 −function circle(lng: number, lat: number, radiusM: number): GeoJSON.Feature {
17 − const pts: [number, number][] = [];
18 − const dLat = radiusM / 111_000;
19 − const dLng = radiusM / (111_000 * Math.cos((lat * Math.PI) / 180));
20 − for (let i = 0; i <= 72; i++) {
21 − const a = (i / 72) * 2 * Math.PI;
22 − pts.push([lng + dLng * Math.cos(a), lat + dLat * Math.sin(a)]);
23 − }
24 − return { type: "Feature", properties: {},
25 − geometry: { type: "Polygon", coordinates: [pts] } };
26 −}
18 +/** Cerise signal Immo-Ka — même langage que l'accent de marque. */
19 +export const BUILDING_RED = "#e23744";
27 20
28 −interface Props {
21 +export interface PropertyMapProps {
22 + uid: string;
29 23 lat: number;
30 24 lng: number;
31 25 price?: number | null;
32 − deal?: boolean; // prix sous l'estimation Vrai-Prix
26 + propertyType?: string;
27 + address?: string;
28 + city?: string;
29 + image?: string | null;
30 + deal?: boolean;
33 31 }
34 32
35 −export default function PropertyMap({ lat, lng, price, deal }: Props) {
36 − const div = useRef<HTMLDivElement>(null);
37 −
38 − useEffect(() => {
39 − if (!div.current) return;
40 − const map = new maplibregl.Map({
41 − container: div.current,
42 − style: immokaStyle,
43 − center: [lng, lat],
44 − zoom: 14.6,
45 − attributionControl: false,
46 − scrollZoom: false, // ne pas piéger le défilement de la page
47 − dragRotate: false,
48 − });
49 − map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
50 − map.touchZoomRotate.disableRotation();
51 −
52 − map.on("load", () => {
53 − // anneaux piéton — 400 m ≈ 5 min, 1,2 km ≈ 15 min
54 − map.addSource("rings", {
55 − type: "geojson",
56 − data: { type: "FeatureCollection",
57 − features: [circle(lng, lat, 400), circle(lng, lat, 1200)] },
58 − });
59 − map.addLayer({
60 − id: "rings-fill", type: "fill", source: "rings",
61 − paint: { "fill-color": "#e23744", "fill-opacity": 0.035 },
62 − });
63 − map.addLayer({
64 − id: "rings-line", type: "line", source: "rings",
65 − paint: { "line-color": "#b3202b", "line-opacity": 0.55,
66 − "line-width": 1.4, "line-dasharray": [3, 3] },
67 − });
68 − });
69 −
70 − // marqueur maison : halo pulsant + pastille de prix
71 − const el = document.createElement("div");
72 − el.className = "pm-marker";
73 − el.innerHTML = `
74 − <div class="pm-pill${deal ? " pm-pill-deal" : ""}">${shortPrice(price)}${deal ? " ↓" : ""}</div>
75 − <div class="pm-pulse"></div>
76 − <div class="pm-dot"></div>`;
77 − const marker = new maplibregl.Marker({ element: el, anchor: "bottom" })
78 − .setLngLat([lng, lat]).addTo(map);
79 −
80 − return () => { marker.remove(); map.remove(); };
81 − }, [lat, lng, price, deal]);
33 +export default function PropertyMap({
34 + uid, lat, lng, price, propertyType, address, city, image, deal,
35 +}: PropertyMapProps) {
36 + const property = useMemo<MapProperty>(() => ({
37 + id: uid,
38 + appSource: "immo-ka",
39 + latitude: lat,
40 + longitude: lng,
41 + kind: "listing",
42 + listingType: "sale",
43 + price: price ?? undefined,
44 + propertyType: propertyType || undefined,
45 + address: address || undefined,
46 + city: city || undefined,
47 + thumbnailUrl: image ?? undefined,
48 + highlight: deal ?? false,
49 + }), [uid, lat, lng, price, propertyType, address, city, image, deal]);
82 50
83 51 return (
84 − <div className="pm-wrap">
85 − <div ref={div} className="mapview" role="img" aria-label="Emplacement de la propriété" />
86 − <div className="pm-rings-legend" aria-hidden="true">
87 − <span>◌ 5 min à pied</span><span>◌ 15 min à pied</span>
88 − </div>
89 − <div className="mv-credit" aria-hidden="true">Cartographie <b>Groupe&nbsp;Ka</b></div>
52 + <div
53 + className="lmap3d" role="img"
54 + aria-label={`Carte 3D — ${address || "propriété"}, bâtiment de l'annonce en rouge`}
55 + >
56 + <KaSpotlightMap
57 + theme={immoKaMapTheme}
58 + mapboxToken={MAPBOX_TOKEN}
59 + property={property}
60 + buildingColor={BUILDING_RED}
61 + >
62 + <KaBrandBadge />
63 + </KaSpotlightMap>
64 + <span className="lmap3d-legende" aria-hidden="true">
65 + <i /> Bâtiment de l'annonce
66 + </span>
90 67 </div>
91 68 );
92 69 }
modified frontend/src/ka/stats/SPEC.md +128 −66
@@ -1,53 +1,88 @@
1 −# ka-stats — module Stats commun Groupe KA (spec v1)
1 +# ka-stats — module Stats commun Groupe KA (spec v2)
2 2
3 −Contrat partagé par les 12 plateformes pour leurs pages **/stats** (tableau de
4 −bord analytique) et l'**export PDF** estampillé Groupe-KA. Le visuel suit le
5 −design system ka-ui (tokens.css) avec l'accent de la marque.
3 +Contrat partagé par les plateformes pour leurs pages **/stats** (tableau de
4 +bord analytique) et les **exports PDF** estampillés Groupe-KA. Le visuel suit
5 +le design system ka-ui (tokens.css) avec l'accent de la marque.
6 +
7 +**v2 (2026-08-19)** : sparklines dans les KPI, jauges, multi-courbes,
8 +barres empilées, distributions (histogrammes), heatmap horaire 7×24, deltas
9 +sur les répartitions, statistiques de séries (min/max/moy/méd/σ), et **5
10 +rapports PDF** au lieu de 2. Tous les nouveaux champs sont **optionnels** :
11 +un dashboard v1 reste valide et se rend tel quel.
6 12
7 13 ## 1. Page /stats — structure obligatoire (dans cet ordre)
8 14
9 −1. **Bandeau KPI** : 4–6 grandes cartes (`KpiCard`) — valeur, libellé,
10 − variation vs période précédente (▲/▼ + %, vert `--green` / rouge `--danger`).
15 +1. **Bandeau KPI** : 6–10 grandes cartes (`KpiCard`) — valeur, libellé,
16 + variation vs période précédente (▲/▼ + %, vert `--green` / rouge
17 + `--danger`), **sparkline** de tendance quand une série existe.
11 18 2. **Sélecteur de période global** (`PeriodSelector`) : `aujourd'hui · 7 j ·
12 19 30 j · 3 m · 6 m · 12 m · année en cours · tout` + plage personnalisée
13 20 (2 champs date). Toute la page se recalcule (state → refetch dashboard).
14 −3. **Graphiques** : courbes d'évolution (`LineChart`, survol = infobulle,
15 − légende cliquable pour masquer une série, comparaison N vs N-1 en
16 − pointillé), barres (`BarChart`), anneaux (`Donut`), calendrier de chaleur
17 − (`CalendarHeatmap`) quand pertinent.
18 −4. **Répartition géographique** (par ville/région) quand pertinent — barres
19 − horizontales triées (pas besoin de vraie carte).
20 −5. **Tableaux détaillés** (`DataTable`) : tri par colonne, recherche interne,
21 +3. **Jauges** (`GaugeCard`) quand pertinent : taux, couvertures, complétude.
22 +4. **Graphiques d'évolution** : courbes/aires (`LineChart`, survol =
23 + infobulle, légende cliquable, comparaison N vs N-1 en pointillé), barres
24 + verticales (`VBarChart` — volumes quotidiens), **multi-courbes ≤ 4 séries**
25 + (`MultiLineChart` — motifs de trait distincts, jamais la couleur seule),
26 + **barres empilées** (`StackedBarChart` — composition dans le temps).
27 + Sous les courbes clés : `StatSummary` (min/max/moyenne/médiane/écart-type).
28 +5. **Répartitions** : barres horizontales (`BarChart`, deltas optionnels),
29 + anneaux (`Donut`), **distributions/histogrammes** (`Histogram`).
30 +6. **Répartition géographique** (par ville/région) — barres horizontales
31 + triées (pas besoin de vraie carte).
32 +7. **Calendriers** : `CalendarHeatmap` (26 semaines) et, quand l'activité
33 + horaire est journalisée, `HourHeatmap` (7 jours × 24 h).
34 +8. **Tableaux détaillés** (`DataTable`) : tri par colonne, recherche interne,
21 35 pagination (25/pg), débordement horizontal propre sur mobile (.tbl-wrap).
22 −6. **Records & faits marquants** : générés depuis les données (jour record,
23 − plus forte croissance, meilleure entrée…) — cartes compactes.
24 −7. **Fraîcheur** : « Mis à jour le {date heure} » + bouton Rafraîchir.
25 −8. **Bouton PDF** bien visible en haut : « Télécharger le rapport PDF » avec
26 − deux choix (Rapport complet / Synthèse 2 pages). Indicateur de progression
27 − si > 2 s.
36 + Viser 3 à 5 tableaux par plateforme.
37 +9. **Records & faits marquants** : générés depuis les données (jour record,
38 + plus forte croissance, meilleure entrée…) — cartes compactes, 6–12.
39 +10. **Fraîcheur** : « Mis à jour le {date heure} » + bouton Rafraîchir.
40 +11. **Bouton PDF** bien visible en haut (`PdfButton`) : bouton principal
41 + « Rapport PDF complet » + menu « Autres rapports ▾ » listant les
42 + **5 rapports** (voir §3). Indicateur de progression si > 2 s.
28 43
29 44 Responsive : KPI empilés < 768 px, graphiques pleine largeur redimensionnés
30 45 (SVG viewBox), tableaux en défilement horizontal contenu, tactile ≥ 44 px.
31 46 AUCUNE donnée inventée : une stat indisponible = bloc « Pas encore mesuré »
32 47 (carte grise propre), jamais un faux chiffre.
33 48
49 +Accessibilité (règles fermes) : un seul axe Y par graphique (jamais de
50 +double échelle) ; ≥ 2 séries ⇒ légende obligatoire ; l'identité d'une série
51 +multi-courbes passe par le **motif de trait** en plus de la couleur ; le
52 +texte reste en encre (jamais coloré à la couleur de série) ; chaque
53 +graphique a son infobulle de survol et un équivalent tableau existe.
54 +
34 55 ## 2. API — contrat commun
35 56
36 57 `GET /api/stats/dashboard?period=7j|30j|3m|6m|12m|annee|tout|auj&from=YYYY-MM-DD&to=YYYY-MM-DD`
37 58
38 59 ```jsonc
39 60 {
40 − "updated": "2026-08-17T21:04:00-04:00",
41 − "period": { "from": "2026-07-18", "to": "2026-08-17", "label": "30 jours" },
61 + "updated": "2026-08-19T01:00:00-04:00",
62 + "period": { "from": "2026-07-20", "to": "2026-08-19", "label": "30 jours" },
42 63 "kpis": [ { "id": "total", "label": "Annonces actives", "value": 33744,
43 − "unit": "", "delta_pct": 4.2, "direction": "up" } ],
64 + "unit": "", "delta_pct": 4.2, "direction": "up",
65 + "spark": [{ "t": "2026-08-01", "v": 31200 }] } ], // spark optionnel
66 + "gauges": [ { "id": "geo", "label": "Fiches géolocalisées", "value": 92,
67 + "max": 100, "unit": "%" } ], // optionnel
44 68 "series": [ { "id": "vol", "title": "Annonces actives par jour", "unit": "annonces",
45 − "kind": "line", "points": [{ "t": "2026-07-18", "v": 31200 }],
46 − "compare": [{ "t": "2025-07-18", "v": 24100 }] } ],
47 − "breakdowns": [ { "id": "types", "title": "Par type", "kind": "donut",
48 − "items": [{ "label": "4½", "value": 9120 }] } ],
69 + "kind": "line", // line | bar | area
70 + "points": [{ "t": "2026-07-20", "v": 31200 }],
71 + "compare": [{ "t": "2025-07-20", "v": 24100 }] } ],
72 + "multiseries": [ { "id": "seg", "title": "Prix médian par taille", "unit": "$",
73 + "series": [ { "label": "3½", "points": [/* … */] },
74 + { "label": "4½", "points": [/* … */] } ] } ], // ≤ 4
75 + "stacked": [ { "id": "src", "title": "Ajouts par source", "unit": "ajouts",
76 + "keys": ["Kijiji", "Centris", "Autres"],
77 + "points": [{ "t": "2026-08-01", "values": [120, 80, 30] }] } ],
78 + "breakdowns": [ { "id": "types", "title": "Par type", "kind": "donut", // donut | bars
79 + "items": [{ "label": "4½", "value": 9120, "delta_pct": 2.1 }] } ],
80 + "distributions": [ { "id": "prix", "title": "Distribution des loyers", "unit": "annonces",
81 + "bins": [{ "label": "800-1000$", "value": 3120 }] } ],
49 82 "geo": { "title": "Par région", "items": [{ "label": "Montréal", "value": 15680 }] },
50 83 "heatmap": { "title": "Activité", "cells": [{ "date": "2026-08-01", "value": 210 }] },
84 + "hourly": { "title": "Activité par heure",
85 + "cells": [{ "dow": 0, "hour": 9, "value": 40 }] }, // dow 0=lun … 6=dim
51 86 "tables": [ { "id": "top", "title": "Top villes", "columns": ["Ville", "Annonces", "Δ 30 j"],
52 87 "rows": [["Montréal", 15680, "+3,1 %"]] } ],
53 88 "records": [ { "label": "Jour record d'ajouts", "value": "412 annonces", "date": "2026-08-09" } ]
@@ -56,23 +91,40 @@ AUCUNE donnée inventée : une stat indisponible = bloc « Pas encore mesuré »
56 91
57 92 Champs absents = section masquée. Cache serveur recommandé (≥ 5 min par
58 93 période). Les valeurs proviennent des données réelles (DB de la plateforme,
59 −journaux de sync des connecteurs, /api/v1/runs d'API-KA…).
94 +journaux de sync des connecteurs, /api/v1/runs d'API-KA…). Arrondir les
95 +`delta_pct` à 1 décimale côté serveur.
60 96
61 −`GET /api/stats/report?period=…&from=&to=&mode=complet|synthese`
97 +## 3. Rapports PDF — 5 modes
98 +
99 +`GET /api/stats/report?period=…&from=&to=&mode=complet|synthese|tendances|repartitions|donnees`
62 100 → `application/pdf`, en-tête `Content-Disposition: attachment; filename=
63 −groupe-ka_<plateforme>_stats_<periode>_<YYYY-MM-DD>.pdf`.
101 +groupe-ka_<plateforme>_stats_<periode>[_<mode>]_<YYYY-MM-DD>.pdf`
102 +(pas de suffixe pour `complet` — rétrocompatible v1 ; `kapdf.filename()`
103 +accepte maintenant `mode` en 3e argument).
104 +
105 +| Mode | Contenu |
106 +|---|---|
107 +| `complet` | tout : sommaire, KPI, jauges, séries + stats de séries, multi-séries, empilées, répartitions, distributions, géo, heatmap horaire, tableaux (200 lignes), records |
108 +| `synthese` | couverture + KPI + jauges + records (2–3 pages) |
109 +| `tendances` | KPI + toutes les séries temporelles + min/max/moy/méd/σ + records |
110 +| `repartitions` | breakdowns, distributions, géo, activité horaire |
111 +| `donnees` | tous les tableaux en version longue (400 lignes) |
64 112
65 −## 3. PDF — gabarit Groupe-KA (implémentations : `kapdf.py` fpdf2 pour les
66 −apps Python ; les apps Next portent le même gabarit en pdfkit)
113 +Un `mode` inconnu retombe sur `complet`. Le gabarit (implémentations :
114 +`kapdf.py` fpdf2 pour les apps Python ; les apps Next portent le même
115 +gabarit en pdfkit) :
67 116
68 117 - **Couverture** : cadre encre, kicker « GROUPE KA · RAPPORT STATISTIQUE »,
69 − wordmark de la plateforme (boîte encre + accent), sous-titre, période
70 − couverte, date/heure de génération, bande encre au pied avec
118 + wordmark de la plateforme (boîte encre + accent), **type de rapport**,
119 + période couverte, date/heure de génération, bande encre au pied avec
71 120 « par Groupe KA — groupe-ka.com ».
72 −- **Sommaire** avec numéros de pages (mode complet).
73 −- **KPI** : grille de cartes (bordure encre, valeur en gros, delta coloré).
121 +- **Sommaire** avec numéros de pages (modes complet et donnees).
122 +- **KPI** : grille de cartes (bordure encre, valeur en gros, delta coloré
123 + arrondi à 1 décimale). **Jauges** : demi-arcs accent.
74 124 - **Graphiques VECTORIELS** (dessinés en primitives, jamais de capture) :
75 − courbes, barres, anneaux — accent de la plateforme, axes/graduations encre.
125 + courbes/aires, barres verticales, multi-courbes (motifs distincts),
126 + empilées (nuances d'accent), anneaux, heatmap horaire — accent de la
127 + plateforme, axes/graduations encre.
76 128 - **Tableaux** paginés proprement (lignes zébrées `--surface-2`, jamais
77 129 coupés en deux à cheval sur une ligne).
78 130 - **Records** puis **page de fin** : coordonnées Groupe KA (3 courriels +
@@ -81,43 +133,53 @@ apps Python ; les apps Next portent le même gabarit en pdfkit)
81 133 - **Chaque page** : en-tête discret (« Groupe KA · {Plateforme} », filet
82 134 encre) + pied (« © Groupe-KA — {année} — groupe-ka.com · {période} · p. X/Y »).
83 135 - A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du
84 − DS si présentes. Mode « synthese » = couverture + 1 page KPI/records.
136 + DS si présentes.
85 137
86 138 ## 4. Spécifique par plateforme (sections métier attendues)
87 139
88 −- **groupe-ka** : tableau de bord maître — consolidation des 12 (volume total,
89 − croissance), classement des plateformes, bloc résumé par plateforme + lien
140 +- **groupe-ka** : tableau de bord maître — consolidation des plateformes
141 + (volume total, croissance), classement, bloc résumé par plateforme + lien
90 142 vers sa page /stats ; « Rapport écosystème complet » = PDF consolidé.
91 143 - **lou-ka** : annonces actives/nouvelles/retirées, loyers moyens/médians par
92 − ville & taille, évolution, répartition par type, top villes.
93 −- **immo-ka** : annonces actives/nouvelles/vendues-retirées, prix moyen/médian
94 − par ville/région/type, délai de présence, top villes, tension du marché.
144 + ville & taille (multiseries), distribution des loyers, évolution,
145 + répartition par type, top villes, sources (stacked).
146 +- **immo-ka** : annonces actives/nouvelles/vendues-retirées, prix
147 + moyen/médian par ville/région/type (multiseries), distribution des prix,
148 + délai de présence, top villes, tension du marché.
95 149 - **vrai-prix** : couverture du rôle (unités, valeur totale), estimations
96 − servies si journalisées, répartitions par municipalité/type, indices marché.
97 −- **auto-ka** : volume par marque/modèle/année/carburant/boîte, prix moyens et
98 − km moyens par segment, top marques/modèles.
99 −- **fabri-ka** : produits par catégorie/région/boutique, fourchettes de prix,
100 − nouveautés par période, top catégories.
101 −- **food-ka** : produits suivis, relevés de prix, soldes détectés (baisses/
102 − hausses, amplitude), top produits en solde, prix moyens par catégorie.
103 −- **resto-ka** : restos par cuisine/ville/gamme, menus & plats, nouveautés/
104 − fermetures détectées, top établissements.
150 + servies si journalisées, répartitions par municipalité/type, distribution
151 + des valeurs, indices marché.
152 +- **auto-ka** : volume par marque/modèle/année/carburant/boîte, prix moyens
153 + et km moyens par segment (multiseries), distributions prix/km/année,
154 + top marques/modèles.
155 +- **fabri-ka** : produits par catégorie/région/boutique, fourchettes de prix
156 + (distribution), nouveautés par période, top catégories.
157 +- **food-ka** : produits suivis, relevés de prix, soldes détectés
158 + (baisses/hausses, amplitude — stacked), top produits en solde, prix moyens
159 + par catégorie, distribution des rabais.
160 +- **resto-ka** : restos par cuisine/ville/gamme, menus & plats, distribution
161 + des prix de plats, nouveautés/fermetures détectées, top établissements.
105 162 - **sorti-ka** : événements à venir/passés par catégorie/ville, gratuits vs
106 − payants, heatmap calendrier, top lieux.
107 −- **crea-ka** : créateurs par plateforme/niche/tier, comptes reliés, top
108 − créateurs, croissance du répertoire.
109 −- **trouve-ka** : pages indexées, domaines, rythme de crawl (indexées/h),
110 − erreurs, file frontier, tendances si les requêtes sont journalisées.
111 −- **api-ka** : appels par endpoint/jour/heure, latences moyennes + p95, taux
112 − d'erreur, top endpoints, uptime (données des middlewares de logging + runs).
113 −- **Transverse (tous)** : volume total agrégé + croissance, connecteurs actifs
114 − et éléments ajoutés/mis à jour par période (journaux de sync), complétude/
115 − fraîcheur moyenne des fiches quand mesurable. Trafic web : seulement si des
116 − journaux d'accès existent — sinon état vide propre.
163 + payants (stacked), heatmap calendrier + horaire, top lieux.
164 +- **crea-ka** : créateurs par plateforme/niche/tier, comptes reliés
165 + (stacked par plateforme), distribution des audiences, top créateurs,
166 + croissance du répertoire.
167 +- **trouve-ka** : pages indexées, domaines, rythme de crawl (indexées/h —
168 + hourly), erreurs, file frontier, tendances des requêtes journalisées.
169 +- **api-ka** : appels par endpoint/jour/heure (hourly), latences moyennes +
170 + p95 (multiseries), taux d'erreur, top endpoints/clés, uptime.
171 +- **job-ka** : offres actives/nouvelles/expirées par catégorie/ville/
172 + entreprise, distribution des salaires affichés, top employeurs.
173 +- **Transverse (tous)** : volume total agrégé + croissance, connecteurs
174 + actifs et éléments ajoutés/mis à jour par période (journaux de sync —
175 + stacked par source quand possible), complétude/fraîcheur moyenne des
176 + fiches quand mesurable (jauges). Trafic web : seulement si des journaux
177 + d'accès existent — sinon état vide propre.
117 178
118 179 ## 5. Ajouter une métrique / un graphique / une plateforme
119 180
120 −1 métrique = 1 entrée `kpis[]` ou `series[]` côté API (requête SQL agrégée +
121 −cache) — le front la rend automatiquement. 1 plateforme = implémenter les 2
122 −endpoints du contrat + une page /stats montée sur les composants du kit +
123 −`kapdf.py` (ou gabarit pdfkit) branché sur le même JSON de dashboard.
181 +1 métrique = 1 entrée `kpis[]`, `series[]`, `multiseries[]`, `stacked[]`,
182 +`distributions[]` ou `gauges[]` côté API (requête SQL agrégée + cache) — le
183 +front la rend automatiquement. 1 plateforme = implémenter les 2 endpoints du
184 +contrat + une page /stats montée sur les composants du kit + `kapdf.py` (ou
185 +gabarit pdfkit) branché sur le même JSON de dashboard.
modified frontend/src/ka/stats/kacharts.tsx +393 −30
@@ -2,22 +2,39 @@
2 2 // ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun
3 3 // Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui
4 4 // (bordures encre, accent de la plateforme via var(--accent)).
5 −// Composants : KpiCard, PeriodSelector, LineChart (infobulle + légende
6 −// cliquable + comparaison N-1), BarChart, Donut, CalendarHeatmap, DataTable
7 −// (tri/recherche/pagination), RecordCard, PdfButton, EmptyBlock, Fraicheur.
8 −import { useMemo, useState } from "react";
5 +// v2 — composants : KpiCard (+sparkline), PeriodSelector, LineChart (ligne/
6 +// aire, infobulle + légende cliquable + comparaison N-1), MultiLineChart
7 +// (≤4 séries, pointillés distincts = jamais la couleur seule), VBarChart
8 +// (barres verticales / histogrammes), StackedBarChart, BarChart (horizontal,
9 +// deltas), Donut, GaugeCard, CalendarHeatmap, HourHeatmap (7×24),
10 +// StatSummary (min/max/moy/méd/σ), DataTable (tri/recherche/pagination),
11 +// RecordCard, PdfButton (menu de rapports), EmptyBlock, Fraicheur.
12 +import { useEffect, useMemo, useRef, useState } from "react";
9 13
10 −/* ---------- types (contrat SPEC.md) ---------- */
14 +/* ---------- types (contrat SPEC.md v2) ---------- */
11 15 export type Kpi = {
12 16 id: string; label: string; value: number | string; unit?: string;
13 17 delta_pct?: number | null; direction?: "up" | "down";
18 + spark?: Point[]; help?: string;
14 19 };
15 20 export type Point = { t: string; v: number };
16 21 export type Serie = {
17 − id: string; title: string; unit?: string; kind?: "line" | "bar";
22 + id: string; title: string; unit?: string;
23 + kind?: "line" | "bar" | "area";
18 24 points: Point[]; compare?: Point[];
19 25 };
20 −export type BreakItem = { label: string; value: number };
26 +export type MultiSerie = {
27 + id: string; title: string; unit?: string;
28 + series: { label: string; points: Point[] }[]; // ≤ 4 séries
29 +};
30 +export type StackedSerie = {
31 + id: string; title: string; unit?: string;
32 + keys: string[]; points: { t: string; values: number[] }[];
33 +};
34 +export type BreakItem = { label: string; value: number; delta_pct?: number | null };
35 +export type Distribution = { id: string; title: string; unit?: string; bins: { label: string; value: number }[] };
36 +export type Gauge = { id: string; label: string; value: number; max: number; unit?: string; help?: string };
37 +export type HourCell = { dow: number; hour: number; value: number }; // dow 0=lun … 6=dim
21 38 export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] };
22 39 export type RecordFact = { label: string; value: string; date?: string };
23 40
@@ -32,25 +49,63 @@ export const PERIODS: { id: string; label: string }[] = [
32 49 { id: "tout", label: "Tout" },
33 50 ];
34 51
52 +export const REPORT_MODES: { id: string; label: string; desc: string }[] = [
53 + { id: "complet", label: "Rapport complet", desc: "Toutes les sections — KPI, tendances, répartitions, tableaux, records" },
54 + { id: "synthese", label: "Synthèse exécutive", desc: "2 pages — indicateurs clés et faits marquants" },
55 + { id: "tendances", label: "Tendances & évolution", desc: "Courbes, comparaisons N-1 et statistiques de séries" },
56 + { id: "repartitions", label: "Répartitions & géographie", desc: "Catégories, distributions, régions et activité" },
57 + { id: "donnees", label: "Données détaillées", desc: "Tous les tableaux, en version longue" },
58 +];
59 +
35 60 export const fmtInt = (n: number) => n.toLocaleString("fr-CA");
36 61 export const fmtNum = (n: number) =>
37 62 Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 });
63 +const fmtPct = (n: number) => `${n >= 0 ? "+" : ""}${fmtNum(n)} %`;
38 64
39 −/* ---------- KPI ---------- */
65 +/* Styles des séries multiples : couleur + motif de trait (l'identité n'est
66 + jamais portée par la couleur seule — règle d'accessibilité). */
67 +const MULTI_STYLES = [
68 + { stroke: "var(--accent)", dash: undefined, width: 2.4 },
69 + { stroke: "var(--ink)", dash: undefined, width: 1.6 },
70 + { stroke: "var(--accent-deep, var(--accent))", dash: "6 3", width: 2 },
71 + { stroke: "var(--ink-3)", dash: "2 3", width: 2 },
72 +];
73 +
74 +/* ---------- KPI (+ sparkline) ---------- */
40 75 export function KpiCard({ k }: { k: Kpi }) {
41 76 const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up";
77 + const sp = (k.spark ?? []).filter((p) => typeof p.v === "number");
78 + const spark = useMemo(() => {
79 + if (sp.length < 2) return null;
80 + const w = 120, h = 30;
81 + const vmax = Math.max(...sp.map((p) => p.v));
82 + const vmin = Math.min(...sp.map((p) => p.v));
83 + const rng = vmax - vmin || 1;
84 + const X = (i: number) => (w * i) / (sp.length - 1);
85 + const Y = (v: number) => 2 + (h - 4) * (1 - (v - vmin) / rng);
86 + const d = sp.map((p, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");
87 + return { w, h, d, area: `${d}L${w},${h}L0,${h}Z` };
88 + }, [k.spark]);
42 89 return (
43 − <article className="card" style={{ padding: "14px 16px", minWidth: 0 }}>
90 + <article className="card" style={{ padding: "14px 16px", minWidth: 0 }} title={k.help}>
44 91 <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}>
45 92 {typeof k.value === "number" ? fmtNum(k.value) : k.value}
46 93 {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null}
47 94 </p>
48 95 <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p>
49 − {k.delta_pct !== undefined && k.delta_pct !== null && (
50 − <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}>
51 − {up ? "▲" : "▼"} {k.delta_pct >= 0 ? "+" : ""}{fmtNum(k.delta_pct)} % <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span>
52 − </p>
53 − )}
96 + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: 8 }}>
97 + {k.delta_pct !== undefined && k.delta_pct !== null ? (
98 + <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}>
99 + {up ? "▲" : "▼"} {fmtPct(k.delta_pct)} <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span>
100 + </p>
101 + ) : <span />}
102 + {spark && (
103 + <svg viewBox={`0 0 ${spark.w} ${spark.h}`} style={{ width: 96, height: 24, flex: "none" }} aria-hidden="true">
104 + <path d={spark.area} fill="var(--accent)" opacity={0.14} />
105 + <path d={spark.d} fill="none" stroke="var(--accent)" strokeWidth={1.8} />
106 + </svg>
107 + )}
108 + </div>
54 109 </article>
55 110 );
56 111 }
@@ -84,12 +139,13 @@ export function PeriodSelector({
84 139 );
85 140 }
86 141
87 −/* ---------- Courbe ---------- */
142 +/* ---------- Courbe / aire ---------- */
88 143 export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
89 144 const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false });
90 145 const [hover, setHover] = useState<number | null>(null);
91 146 const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
92 147 const pts = serie.points ?? [];
148 + if (serie.kind === "bar") return <VBarChart serie={serie} height={height} />;
93 149 if (pts.length < 2) return <EmptyBlock title={serie.title} />;
94 150 const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])];
95 151 const vmax = Math.max(...all.map((p) => p.v), 1);
@@ -127,6 +183,9 @@ export function LineChart({ serie, height = 240 }: { serie: Serie; height?: numb
127 183 {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => (
128 184 <text key={i} x={X(i, pts.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
129 185 ))}
186 + {serie.kind === "area" && !hide.cur && (
187 + <path d={`${path(pts)}L${X(pts.length - 1, pts.length)},${Y(0)}L${X(0, pts.length)},${Y(0)}Z`} fill="var(--accent)" opacity={0.13} />
188 + )}
130 189 {!hide.cmp && serie.compare && serie.compare.length > 1 && (
131 190 <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" />
132 191 )}
@@ -148,17 +207,211 @@ export function LineChart({ serie, height = 240 }: { serie: Serie; height?: numb
148 207 );
149 208 }
150 209
151 −function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off: boolean; dashed?: boolean; onClick: () => void }) {
210 +function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off?: boolean; dashed?: boolean; onClick?: () => void }) {
152 211 return (
153 − <button type="button" onClick={onClick} aria-pressed={!off}
154 − style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: "pointer", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}>
212 + <button type="button" onClick={onClick} aria-pressed={!off} disabled={!onClick}
213 + style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: onClick ? "pointer" : "default", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}>
155 214 <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} />
156 215 {label}
157 216 </button>
158 217 );
159 218 }
160 219
161 −/* ---------- Barres horizontales (répartitions, géo) ---------- */
220 +/* ---------- Multi-courbes (≤ 4 séries, motifs distincts) ---------- */
221 +export function MultiLineChart({ ms, height = 260 }: { ms: MultiSerie; height?: number }) {
222 + const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4);
223 + const [off, setOff] = useState<Record<string, boolean>>({});
224 + const [hover, setHover] = useState<number | null>(null);
225 + if (!series.length) return <EmptyBlock title={ms.title} />;
226 + const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
227 + const n = Math.max(...series.map((s) => s.points.length));
228 + const shown = series.filter((s) => !off[s.label]);
229 + const all = shown.flatMap((s) => s.points.map((p) => p.v));
230 + const vmax = Math.max(...(all.length ? all : [1]), 1);
231 + const vmin = Math.min(0, ...(all.length ? all : [0]));
232 + const X = (i: number, len: number) => PL + ((W - PL - PR) * i) / (len - 1);
233 + const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));
234 + const ref = series[0].points;
235 + const hi = hover !== null ? Math.min(n - 1, Math.max(0, hover)) : null;
236 + return (
237 + <figure className="card" style={{ margin: 0, padding: 16 }}>
238 + <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
239 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{ms.title}</b>
240 + <span style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
241 + {series.map((s, i) => (
242 + <LegendChip key={s.label} label={s.label} color={MULTI_STYLES[i].stroke}
243 + dashed={!!MULTI_STYLES[i].dash} off={!!off[s.label]}
244 + onClick={() => setOff((o) => ({ ...o, [s.label]: !o[s.label] }))} />
245 + ))}
246 + </span>
247 + </figcaption>
248 + <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={ms.title}
249 + onMouseMove={(e) => {
250 + const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
251 + const fx = ((e.clientX - r.left) / r.width) * W;
252 + setHover(Math.round(((fx - PL) / (W - PL - PR)) * (n - 1)));
253 + }}
254 + onMouseLeave={() => setHover(null)}>
255 + {[0, 1, 2, 3, 4].map((g) => {
256 + const y = PT + ((H - PT - PB) * g) / 4;
257 + const v = vmax - ((vmax - vmin) * g) / 4;
258 + return (
259 + <g key={g}>
260 + <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
261 + <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
262 + </g>
263 + );
264 + })}
265 + {[0, Math.floor(ref.length / 2), ref.length - 1].map((i) => (
266 + <text key={i} x={X(i, ref.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{ref[i]?.t}</text>
267 + ))}
268 + {series.map((s, i) => off[s.label] ? null : (
269 + <path key={s.label}
270 + d={s.points.map((p, j) => `${j ? "L" : "M"}${X(j, s.points.length)},${Y(p.v)}`).join("")}
271 + fill="none" stroke={MULTI_STYLES[i].stroke} strokeWidth={MULTI_STYLES[i].width}
272 + strokeDasharray={MULTI_STYLES[i].dash} />
273 + ))}
274 + {hi !== null && (
275 + <line x1={X(hi, n)} x2={X(hi, n)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />
276 + )}
277 + </svg>
278 + {hi !== null && (
279 + <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}>
280 + <b>{ref[hi]?.t}</b>
281 + {shown.map((s, i) => (
282 + <span key={s.label}>{s.label} : <b>{s.points[hi] ? fmtNum(s.points[hi].v) : "—"}{ms.unit ? ` ${ms.unit}` : ""}</b></span>
283 + ))}
284 + </p>
285 + )}
286 + </figure>
287 + );
288 +}
289 +
290 +/* ---------- Barres verticales (volumes quotidiens, histogrammes) ---------- */
291 +export function VBarChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
292 + const [hover, setHover] = useState<number | null>(null);
293 + const pts = serie.points ?? [];
294 + if (!pts.length) return <EmptyBlock title={serie.title} />;
295 + const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
296 + const vmax = Math.max(...pts.map((p) => p.v), 1);
297 + const bw = Math.max(2, (W - PL - PR) / pts.length - 2);
298 + return (
299 + <figure className="card" style={{ margin: 0, padding: 16 }}>
300 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b></figcaption>
301 + <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10 }} role="img" aria-label={serie.title}
302 + onMouseLeave={() => setHover(null)}>
303 + {[0, 1, 2, 3, 4].map((g) => {
304 + const y = PT + ((H - PT - PB) * g) / 4;
305 + const v = vmax - (vmax * g) / 4;
306 + return (
307 + <g key={g}>
308 + <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
309 + <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
310 + </g>
311 + );
312 + })}
313 + {pts.map((p, i) => {
314 + const x = PL + ((W - PL - PR) * i) / pts.length;
315 + const h = (H - PT - PB) * (p.v / vmax);
316 + return (
317 + <rect key={i} x={x + 1} y={H - PB - h} width={bw} height={Math.max(h, p.v > 0 ? 1.5 : 0)} rx={2}
318 + fill="var(--accent)" opacity={hover === null || hover === i ? 1 : 0.45}
319 + stroke="var(--ink)" strokeWidth={0.5}
320 + onMouseEnter={() => setHover(i)}>
321 + <title>{`${p.t} — ${fmtNum(p.v)}${serie.unit ? ` ${serie.unit}` : ""}`}</title>
322 + </rect>
323 + );
324 + })}
325 + {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => (
326 + <text key={i} x={PL + ((W - PL - PR) * (i + 0.5)) / pts.length} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
327 + ))}
328 + </svg>
329 + {hover !== null && (
330 + <p className="chip" style={{ marginTop: 8 }}>{pts[hover].t} — <b>{fmtNum(pts[hover].v)}{serie.unit ? ` ${serie.unit}` : ""}</b></p>
331 + )}
332 + </figure>
333 + );
334 +}
335 +
336 +/* ---------- Histogramme (distribution) ---------- */
337 +export function Histogram({ dist }: { dist: Distribution }) {
338 + const serie: Serie = {
339 + id: dist.id, title: dist.title, unit: dist.unit, kind: "bar",
340 + points: (dist.bins ?? []).map((b) => ({ t: b.label, v: b.value })),
341 + };
342 + return <VBarChart serie={serie} height={220} />;
343 +}
344 +
345 +/* ---------- Barres empilées (composition dans le temps) ---------- */
346 +export function StackedBarChart({ st, height = 260 }: { st: StackedSerie; height?: number }) {
347 + const [hover, setHover] = useState<number | null>(null);
348 + const keys = (st.keys ?? []).slice(0, 6);
349 + const pts = st.points ?? [];
350 + if (!keys.length || !pts.length) return <EmptyBlock title={st.title} />;
351 + const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
352 + const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0));
353 + const vmax = Math.max(...totals, 1);
354 + const bw = Math.max(2, (W - PL - PR) / pts.length - 2);
355 + const shades = [1, 0.72, 0.5, 0.34, 0.22, 0.13];
356 + return (
357 + <figure className="card" style={{ margin: 0, padding: 16 }}>
358 + <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
359 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{st.title}</b>
360 + <span style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
361 + {keys.map((k, i) => (
362 + <span key={k} style={{ display: "inline-flex", alignItems: "center", gap: 5, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" }}>
363 + <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i] }} />
364 + {k}
365 + </span>
366 + ))}
367 + </span>
368 + </figcaption>
369 + <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10 }} role="img" aria-label={st.title}
370 + onMouseLeave={() => setHover(null)}>
371 + {[0, 1, 2, 3, 4].map((g) => {
372 + const y = PT + ((H - PT - PB) * g) / 4;
373 + return (
374 + <g key={g}>
375 + <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
376 + <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(vmax - (vmax * g) / 4))}</text>
377 + </g>
378 + );
379 + })}
380 + {pts.map((p, i) => {
381 + const x = PL + ((W - PL - PR) * i) / pts.length;
382 + let yAcc = H - PB;
383 + return (
384 + <g key={i} onMouseEnter={() => setHover(i)} opacity={hover === null || hover === i ? 1 : 0.5}>
385 + {keys.map((k, j) => {
386 + const v = p.values[j] || 0;
387 + const h = (H - PT - PB) * (v / vmax);
388 + yAcc -= h;
389 + return v > 0 ? (
390 + <rect key={k} x={x + 1} y={yAcc} width={bw} height={Math.max(h - 1, 0.8)} rx={1.5}
391 + fill="var(--accent)" opacity={shades[j]} stroke="var(--ink)" strokeWidth={0.4}>
392 + <title>{`${p.t} · ${k} — ${fmtNum(v)}${st.unit ? ` ${st.unit}` : ""}`}</title>
393 + </rect>
394 + ) : null;
395 + })}
396 + </g>
397 + );
398 + })}
399 + {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => (
400 + <text key={i} x={PL + ((W - PL - PR) * (i + 0.5)) / pts.length} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
401 + ))}
402 + </svg>
403 + {hover !== null && (
404 + <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}>
405 + <b>{pts[hover].t}</b>
406 + {keys.map((k, j) => <span key={k}>{k} : <b>{fmtNum(pts[hover].values[j] || 0)}</b></span>)}
407 + <span style={{ color: "var(--ink-3)" }}>total {fmtNum(totals[hover])}</span>
408 + </p>
409 + )}
410 + </figure>
411 + );
412 +}
413 +
414 +/* ---------- Barres horizontales (répartitions, géo) — deltas optionnels ---------- */
162 415 export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) {
163 416 const rows = (items ?? []).slice(0, 14);
164 417 if (!rows.length) return <EmptyBlock title={title} />;
@@ -169,9 +422,16 @@ export function BarChart({ title, items, unit }: { title: string; items: BreakIt
169 422 <div style={{ marginTop: 12, display: "grid", gap: 9 }}>
170 423 {rows.map((r) => (
171 424 <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}>
172 − <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12.5 }}>
425 + <div style={{ display: "flex", justifyContent: "space-between", gap: 8, fontSize: 12.5 }}>
173 426 <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
174 − <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b>
427 + <span style={{ display: "inline-flex", gap: 8, alignItems: "baseline", flex: "none" }}>
428 + {r.delta_pct !== undefined && r.delta_pct !== null && (
429 + <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 700, color: r.delta_pct >= 0 ? "var(--green)" : "var(--danger)" }}>
430 + {r.delta_pct >= 0 ? "▲" : "▼"} {fmtPct(r.delta_pct)}
431 + </span>
432 + )}
433 + <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b>
434 + </span>
175 435 </div>
176 436 <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}>
177 437 <div style={{ height: "100%", width: `${Math.max((r.value / max) * 100, 1)}%`, background: "var(--accent)", border: "1px solid var(--ink)", borderRadius: "0 3px 3px 0", boxSizing: "border-box" }} />
@@ -224,6 +484,26 @@ export function Donut({ title, items }: { title: string; items: BreakItem[] }) {
224 484 );
225 485 }
226 486
487 +/* ---------- Jauge (taux, complétude, couverture) ---------- */
488 +export function GaugeCard({ g }: { g: Gauge }) {
489 + const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0));
490 + const R = 60, C = Math.PI * R;
491 + return (
492 + <article className="card" style={{ padding: "14px 16px", minWidth: 0 }} title={g.help}>
493 + <svg viewBox="0 0 150 84" style={{ width: "100%", maxWidth: 190, display: "block", margin: "0 auto" }} role="img" aria-label={`${g.label} : ${fmtNum(g.value)}${g.unit ?? ""} sur ${fmtNum(g.max)}`}>
494 + <path d={`M15,75 A${R},${R} 0 0 1 135,75`} fill="none" stroke="rgba(20,24,20,0.08)" strokeWidth={13} strokeLinecap="round" />
495 + <path d={`M15,75 A${R},${R} 0 0 1 135,75`} fill="none" stroke="var(--accent)" strokeWidth={13} strokeLinecap="round"
496 + strokeDasharray={`${frac * C} ${C}`} />
497 + <text x={75} y={66} textAnchor="middle" fontFamily="var(--font-display)" fontWeight={700} fontSize={22} fill="var(--ink)">
498 + {fmtNum(g.value)}{g.unit ? <tspan fontSize={12} fill="var(--ink-2)"> {g.unit}</tspan> : null}
499 + </text>
500 + <text x={75} y={80} textAnchor="middle" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{(frac * 100).toFixed(0)} % de {fmtNum(g.max)}{g.unit ? ` ${g.unit}` : ""}</text>
501 + </svg>
502 + <p className="klabel" style={{ margin: "8px 0 0", textAlign: "center" }}>{g.label}</p>
503 + </article>
504 + );
505 +}
506 +
227 507 /* ---------- Calendrier de chaleur ---------- */
228 508 export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) {
229 509 if (!cells?.length) return <EmptyBlock title={title} />;
@@ -261,6 +541,64 @@ export function CalendarHeatmap({ title, cells }: { title: string; cells: { date
261 541 );
262 542 }
263 543
544 +/* ---------- Heatmap horaire 7 × 24 (activité par heure) ---------- */
545 +const DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
546 +export function HourHeatmap({ title, cells }: { title: string; cells: HourCell[] }) {
547 + if (!cells?.length) return <EmptyBlock title={title} />;
548 + const grid = new Map(cells.map((c) => [`${c.dow}-${c.hour}`, c.value]));
549 + const max = Math.max(...cells.map((c) => c.value), 1);
550 + const CW = 24, CH = 20, LX = 34, LY = 16;
551 + return (
552 + <figure className="card" style={{ margin: 0, padding: 16 }}>
553 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">jour × heure</span></figcaption>
554 + <div className="tbl-wrap" style={{ marginTop: 12 }}>
555 + <svg viewBox={`0 0 ${LX + 24 * CW} ${LY + 7 * CH}`} style={{ minWidth: 520, width: "100%", height: "auto" }} role="img" aria-label={title}>
556 + {[0, 6, 12, 18, 23].map((h) => (
557 + <text key={h} x={LX + h * CW + CW / 2} y={11} textAnchor="middle" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{h} h</text>
558 + ))}
559 + {DOW.map((d, i) => (
560 + <text key={d} x={LX - 6} y={LY + i * CH + CH / 2 + 3} textAnchor="end" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{d}</text>
561 + ))}
562 + {Array.from({ length: 7 }, (_, d) => Array.from({ length: 24 }, (_, h) => {
563 + const v = grid.get(`${d}-${h}`) ?? 0;
564 + return (
565 + <rect key={`${d}-${h}`} x={LX + h * CW} y={LY + d * CH} width={CW - 2} height={CH - 2} rx={2.5}
566 + fill={v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={v ? 0.22 + 0.78 * (v / max) : 1}
567 + stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>
568 + <title>{`${DOW[d]} ${h} h — ${fmtNum(v)}`}</title>
569 + </rect>
570 + );
571 + }))}
572 + </svg>
573 + </div>
574 + </figure>
575 + );
576 +}
577 +
578 +/* ---------- Statistiques de série (min/max/moy/méd/σ) ---------- */
579 +export function StatSummary({ serie }: { serie: Serie }) {
580 + const vs = (serie.points ?? []).map((p) => p.v).filter((v) => typeof v === "number");
581 + if (vs.length < 2) return null;
582 + const sorted = [...vs].sort((a, b) => a - b);
583 + const mean = vs.reduce((s, v) => s + v, 0) / vs.length;
584 + const med = sorted[Math.floor(sorted.length / 2)];
585 + const sd = Math.sqrt(vs.reduce((s, v) => s + (v - mean) ** 2, 0) / vs.length);
586 + const items: [string, number][] = [
587 + ["Min", sorted[0]], ["Max", sorted[sorted.length - 1]],
588 + ["Moyenne", Math.round(mean * 100) / 100], ["Médiane", med],
589 + ["Écart-type", Math.round(sd * 100) / 100],
590 + ];
591 + return (
592 + <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
593 + {items.map(([l, v]) => (
594 + <span key={l} className="chip" style={{ fontSize: 11 }}>
595 + <span style={{ color: "var(--ink-3)" }}>{l}</span> <b style={{ fontFamily: "var(--font-mono)" }}>{fmtNum(v)}</b>
596 + </span>
597 + ))}
598 + </div>
599 + );
600 +}
601 +
264 602 /* ---------- Tableau : tri, recherche, pagination ---------- */
265 603 export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) {
266 604 const [q, setQ] = useState("");
@@ -338,9 +676,19 @@ export function RecordCard({ r }: { r: RecordFact }) {
338 676 );
339 677 }
340 678
341 −/* ---------- Bouton PDF ---------- */
679 +/* ---------- Menu de rapports PDF (5 rapports) ---------- */
342 680 export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
343 − const [busy, setBusy] = useState(false);
681 + const [open, setOpen] = useState(false);
682 + const [busy, setBusy] = useState<string | null>(null);
683 + const box = useRef<HTMLSpanElement>(null);
684 + useEffect(() => {
685 + if (!open) return;
686 + const close = (e: MouseEvent) => {
687 + if (box.current && !box.current.contains(e.target as Node)) setOpen(false);
688 + };
689 + document.addEventListener("mousedown", close);
690 + return () => document.removeEventListener("mousedown", close);
691 + }, [open]);
344 692 const url = (mode: string) => {
345 693 const p = new URLSearchParams({ period, mode });
346 694 if (from) p.set("from", from);
@@ -348,23 +696,38 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }:
348 696 return `${endpoint}?${p}`;
349 697 };
350 698 const dl = (mode: string) => {
351 − setBusy(true);
699 + setBusy(mode);
700 + setOpen(false);
352 701 const a = document.createElement("a");
353 702 a.href = url(mode);
354 703 a.download = "";
355 704 document.body.appendChild(a);
356 705 a.click();
357 706 a.remove();
358 − setTimeout(() => setBusy(false), 2500);
707 + setTimeout(() => setBusy(null), 3000);
359 708 };
360 709 return (
361 − <span style={{ display: "inline-flex", gap: 8, flexWrap: "wrap" }}>
362 − <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={busy}>
363 − {busy ? "Génération…" : "⬇ Télécharger le rapport PDF"}
710 + <span ref={box} style={{ position: "relative", display: "inline-flex", gap: 8, flexWrap: "wrap" }}>
711 + <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={!!busy}>
712 + {busy ? "Génération…" : "⬇ Rapport PDF complet"}
364 713 </button>
365 − <button type="button" className="btn btn-ghost" onClick={() => dl("synthese")} disabled={busy}>
366 − Synthèse (2 p.)
714 + <button type="button" className="btn btn-ghost" onClick={() => setOpen((o) => !o)} disabled={!!busy}
715 + aria-haspopup="menu" aria-expanded={open}>
716 + Autres rapports ▾
367 717 </button>
718 + {open && (
719 + <div role="menu" className="card" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50, minWidth: 300, padding: 6, background: "var(--surface)", boxShadow: "0 10px 28px rgba(20,24,20,0.18)" }}>
720 + {REPORT_MODES.map((m) => (
721 + <button key={m.id} type="button" role="menuitem" onClick={() => dl(m.id)}
722 + style={{ display: "block", width: "100%", textAlign: "left", border: 0, background: "none", cursor: "pointer", padding: "9px 10px", borderRadius: 6 }}
723 + onMouseEnter={(e) => (e.currentTarget.style.background = "var(--surface-2)")}
724 + onMouseLeave={(e) => (e.currentTarget.style.background = "none")}>
725 + <b style={{ display: "block", fontSize: 13, fontFamily: "var(--font-display)" }}>{m.label}</b>
726 + <span className="klabel" style={{ fontSize: 11 }}>{m.desc}</span>
727 + </button>
728 + ))}
729 + </div>
730 + )}
368 731 </span>
369 732 );
370 733 }
modified frontend/src/ka/stats/kapdf.py +390 −71
@@ -1,10 +1,17 @@
1 1 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
2 +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2
3 3 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4 −# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5 −# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
4 +# les rapports estampillés Groupe-KA. 5 modes :
5 +# complet — toutes les sections (KPI, jauges, séries + stats, multi-
6 +# séries, empilées, distributions, répartitions, géo,
7 +# heatmap horaire, tableaux, records)
8 +# synthese — couverture + KPI + records (2-3 pages)
9 +# tendances — KPI + toutes les séries temporelles + stats de séries
10 +# repartitions — breakdowns, distributions, géo, activité horaire
11 +# donnees — tous les tableaux en version longue (400 lignes max)
12 +# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
6 13 # Usage :
7 −# from kapdf import GroupeKAReport
14 +# from kapdf import GroupeKAReport, REPORT_MODES, filename
8 15 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9 16 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10 17 # mode="complet").build()
@@ -26,6 +33,14 @@ GREEN = (28, 92, 65)
26 33 DANGER = (179, 66, 58)
27 34 WHITE = (255, 255, 255)
28 35
36 +REPORT_MODES = {
37 + "complet": "Rapport complet",
38 + "synthese": "Synthèse exécutive",
39 + "tendances": "Tendances & évolution",
40 + "repartitions": "Répartitions & géographie",
41 + "donnees": "Données détaillées",
42 +}
43 +
29 44 EMAILS = [
30 45 ("contact@groupe-ka.com", "Projets, partenariats & données"),
31 46 ("info@groupe-ka.com", "Médias & questions générales"),
@@ -52,7 +67,8 @@ def _fr(n) -> str:
52 67 _SUBST = {
53 68 "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54 69 "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55 − "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
70 + "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
71 + "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",
56 72 }
57 73
58 74
@@ -79,7 +95,7 @@ class _PDF(FPDF):
79 95 self.set_auto_page_break(True, margin=22)
80 96
81 97 def header(self):
82 − if self.cover_mode:
98 + if self.cover_mode or self.page_no() == 1:
83 99 return
84 100 self.set_font("helvetica", "B", 8.5)
85 101 self.set_text_color(*INK)
@@ -95,7 +111,9 @@ class _PDF(FPDF):
95 111 self.set_y(20)
96 112
97 113 def footer(self):
98 − if self.cover_mode:
114 + # page 1 = couverture (le flag cover_mode est déjà retombé quand
115 + # add_page() clôt la page 1 → tester aussi le numéro de page)
116 + if self.cover_mode or self.page_no() == 1:
99 117 return
100 118 self.set_y(-15)
101 119 self.set_draw_color(*INK3)
@@ -112,7 +130,7 @@ class GroupeKAReport:
112 130 def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
113 131 self.site = site
114 132 self.d = dashboard
115 − self.mode = mode
133 + self.mode = mode if mode in REPORT_MODES else "complet"
116 134 self.accent = _hex(site.get("accent", "#d9f26b"))
117 135 period = dashboard.get("period", {}) or {}
118 136 self.period_label = period.get("label") or "toute la période"
@@ -127,6 +145,11 @@ class GroupeKAReport:
127 145 p.set_fill_color(*fill)
128 146 p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
129 147
148 + def _shade(self, i, n=8):
149 + shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
150 + f = shades[i % len(shades)]
151 + return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
152 +
130 153 def _kicker(self, text):
131 154 p = self.pdf
132 155 p.set_font("helvetica", "B", 8)
@@ -150,6 +173,14 @@ class GroupeKAReport:
150 173 self.toc.append((title, self.pdf.page_no()))
151 174 self.pdf.ln(11)
152 175
176 + def _chart_title(self, title):
177 + p = self.pdf
178 + p.set_font("helvetica", "B", 10)
179 + p.set_text_color(*INK)
180 + p.set_x(p.l_margin)
181 + p.cell(0, 6, title)
182 + p.ln(7)
183 +
153 184 # ---------- pages ----------
154 185 def _cover(self):
155 186 p = self.pdf
@@ -161,12 +192,10 @@ class GroupeKAReport:
161 192 p.set_draw_color(*INK)
162 193 p.set_line_width(1.0)
163 194 p.rect(10, 10, 190, 277)
164 − # kicker
165 195 p.set_font("helvetica", "B", 10)
166 196 p.set_text_color(*GREEN)
167 197 p.set_xy(24, 34)
168 198 p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
169 − # wordmark : partie gauche + boîte encre/accent
170 199 wm = self.site.get("wordmark", "")
171 200 left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
172 201 p.set_xy(24, 70)
@@ -184,7 +213,7 @@ class GroupeKAReport:
184 213 p.set_xy(24, 100)
185 214 p.set_font("helvetica", "", 13)
186 215 p.set_text_color(*INK2)
187 − p.multi_cell(150, 7, f"Rapport statistique — {wm}")
216 + p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}")
188 217 now = datetime.now(ZoneInfo("America/Toronto"))
189 218 per = self.d.get("period", {}) or {}
190 219 p.set_xy(24, 125)
@@ -193,7 +222,7 @@ class GroupeKAReport:
193 222 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
194 223 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
195 224 ("Plateforme", "https://" + self.site.get("domain", "")),
196 − ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
225 + ("Type de rapport", REPORT_MODES[self.mode]),
197 226 ]
198 227 y = 128
199 228 for k, v in rows:
@@ -205,7 +234,6 @@ class GroupeKAReport:
205 234 p.cell(0, 6, str(v))
206 235 p.set_font("helvetica", "", 10.5)
207 236 y += 8
208 − # bande encre au pied
209 237 p.set_fill_color(*INK)
210 238 p.rect(10, 262, 190, 25, style="F")
211 239 p.set_xy(24, 270)
@@ -230,7 +258,7 @@ class GroupeKAReport:
230 258 p = self.pdf
231 259 cols, gw, gh, gap = 3, 56, 26, 3
232 260 x0, y = p.l_margin, p.get_y()
233 − for i, k in enumerate(kpis[:9]):
261 + for i, k in enumerate(kpis[:12]):
234 262 x = x0 + (i % cols) * (gw + gap)
235 263 if i and i % cols == 0:
236 264 y += gh + gap
@@ -252,20 +280,81 @@ class GroupeKAReport:
252 280 p.set_font("helvetica", "B", 8)
253 281 p.set_text_color(*(GREEN if up else DANGER))
254 282 arrow = "+" if k["delta_pct"] >= 0 else ""
255 − p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
283 + dv = round(float(k["delta_pct"]), 1)
284 + dv = int(dv) if float(dv).is_integer() else dv
285 + p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")
256 286 p.set_y(y + gh + 8)
257 287
258 − def _line_chart(self, s):
288 + def _gauges(self):
289 + gs = self.d.get("gauges") or []
290 + gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]
291 + if not gs:
292 + return
293 + self._section_title("Taux & couvertures")
294 + p = self.pdf
295 + cols, gw, gh, gap = 3, 56, 34, 3
296 + x0, y = p.l_margin, p.get_y()
297 + for i, g in enumerate(gs[:9]):
298 + x = x0 + (i % cols) * (gw + gap)
299 + if i and i % cols == 0:
300 + y += gh + gap
301 + if y > 240:
302 + p.add_page(); y = p.get_y()
303 + self._card(x, y, gw, gh)
304 + frac = max(0.0, min(1.0, g["value"] / g["max"]))
305 + cx, cy, r = x + gw / 2, y + 20, 14
306 + # arc de fond + arc de valeur (demi-cercle en petits segments)
307 + for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):
308 + p.set_draw_color(*pass_col)
309 + p.set_line_width(lw)
310 + steps = max(2, int(60 * pass_frac))
311 + last = None
312 + for st in range(steps + 1):
313 + a = math.pi + math.pi * pass_frac * st / steps
314 + pt = (cx + r * math.cos(a), cy + r * math.sin(a))
315 + if last:
316 + p.line(last[0], last[1], pt[0], pt[1])
317 + last = pt
318 + p.set_font("helvetica", "B", 11)
319 + p.set_text_color(*INK)
320 + p.set_xy(x + 4, cy - 5)
321 + p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")
322 + p.set_font("helvetica", "", 6.6)
323 + p.set_text_color(*INK3)
324 + p.set_xy(x + 4, cy + 1.5)
325 + p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")
326 + p.set_xy(x + 3, y + gh - 7)
327 + p.set_font("helvetica", "", 7)
328 + p.set_text_color(*INK2)
329 + p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")
330 + p.set_y(y + gh + 8)
331 +
332 + def _serie_stats_row(self, s):
333 + """Ligne min/max/moyenne/médiane sous un graphique de série."""
334 + p = self.pdf
335 + vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]
336 + if len(vs) < 2:
337 + return
338 + sv = sorted(vs)
339 + mean = sum(vs) / len(vs)
340 + med = sv[len(sv) // 2]
341 + sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))
342 + p.set_font("helvetica", "", 6.8)
343 + p.set_text_color(*INK3)
344 + p.cell(0, 4, f"min {_fr(sv[0])} · max {_fr(sv[-1])} · moyenne {_fr(round(mean, 2))} · médiane {_fr(med)} · écart-type {_fr(round(sd, 2))}")
345 + p.ln(5.5)
346 +
347 + def _line_chart(self, s, with_stats=False):
259 348 p = self.pdf
260 349 pts = s.get("points") or []
261 350 if len(pts) < 2:
262 351 return
352 + if s.get("kind") == "bar":
353 + self._vbars(s)
354 + return
263 355 if p.get_y() > 200:
264 356 p.add_page()
265 − p.set_font("helvetica", "B", 10)
266 − p.set_text_color(*INK)
267 − p.cell(0, 6, s.get("title", ""))
268 − p.ln(7)
357 + self._chart_title(s.get("title", ""))
269 358 x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
270 359 self._card(x0, y0, w, h, fill=WHITE)
271 360 cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
@@ -273,7 +362,6 @@ class GroupeKAReport:
273 362 vmax = max(vals) or 1
274 363 vmin = min(0, min(vals))
275 364 rng = (vmax - vmin) or 1
276 − # grille + graduations
277 365 p.set_font("helvetica", "", 6.3)
278 366 p.set_text_color(*INK3)
279 367 p.set_draw_color(200, 200, 195)
@@ -284,6 +372,20 @@ class GroupeKAReport:
284 372 p.set_xy(x0 + 1, gy - 1.6)
285 373 p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
286 374
375 + def xy(i, n, v):
376 + return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))
377 +
378 + # aire sous la courbe (kind=area) : petits trapèzes accent pâle
379 + if s.get("kind") == "area":
380 + fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))
381 + p.set_fill_color(*fill)
382 + p.set_draw_color(*fill)
383 + n = len(pts)
384 + for i in range(n - 1):
385 + x1, y1 = xy(i, n, pts[i]["v"])
386 + x2, y2 = xy(i + 1, n, pts[i + 1]["v"])
387 + p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")
388 +
287 389 def draw(series, color, width, dash=None):
288 390 n = len(series)
289 391 p.set_draw_color(*color)
@@ -292,8 +394,7 @@ class GroupeKAReport:
292 394 p.set_dash_pattern(dash=1.2, gap=1.2)
293 395 last = None
294 396 for i, pt in enumerate(series):
295 − px = cx + cw * (i / (n - 1))
296 − py = cy + ch - ch * ((pt["v"] - vmin) / rng)
397 + px, py = xy(i, n, pt["v"])
297 398 if last:
298 399 p.line(last[0], last[1], px, py)
299 400 last = (px, py)
@@ -302,7 +403,6 @@ class GroupeKAReport:
302 403 if s.get("compare"):
303 404 draw(s["compare"], INK3, 0.35, dash=True)
304 405 draw(pts, self.accent, 0.7)
305 − # libellés d'axe X (premier / milieu / dernier)
306 406 p.set_text_color(*INK3)
307 407 for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
308 408 p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
@@ -312,9 +412,166 @@ class GroupeKAReport:
312 412 p.set_font("helvetica", "", 6.8)
313 413 p.set_text_color(*INK3)
314 414 p.cell(0, 4, "— période courante (accent) · ---- période comparée")
315 − p.ln(6)
316 − else:
317 − p.ln(2)
415 + p.ln(5.5)
416 + if with_stats:
417 + self._serie_stats_row(s)
418 + p.ln(1.5)
419 +
420 + def _vbars(self, s):
421 + """Barres verticales : série kind=bar ou distribution (bins)."""
422 + p = self.pdf
423 + pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]
424 + pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]
425 + if not pts:
426 + return
427 + if p.get_y() > 205:
428 + p.add_page()
429 + self._chart_title(s.get("title", ""))
430 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 48
431 + self._card(x0, y0, w, h, fill=WHITE)
432 + cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14
433 + vmax = max(pt["v"] for pt in pts) or 1
434 + p.set_font("helvetica", "", 6.3)
435 + p.set_text_color(*INK3)
436 + p.set_draw_color(200, 200, 195)
437 + p.set_line_width(0.15)
438 + for g in range(5):
439 + gy = cy + ch - ch * g / 4
440 + p.line(cx, gy, cx + cw, gy)
441 + p.set_xy(x0 + 1, gy - 1.6)
442 + p.cell(10, 3, _fr(vmax * g / 4), align="R")
443 + n = len(pts)
444 + bw = max(0.8, cw / n - 0.6)
445 + p.set_fill_color(*self.accent)
446 + p.set_draw_color(*INK)
447 + p.set_line_width(0.15)
448 + for i, pt in enumerate(pts):
449 + bh = ch * (pt["v"] / vmax)
450 + p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")
451 + p.set_text_color(*INK3)
452 + for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):
453 + p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)
454 + p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")
455 + p.set_y(y0 + h + 5)
456 +
457 + def _multiline(self, ms):
458 + """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /
459 + gris pointillé — l'identité passe par le motif, pas la couleur seule."""
460 + p = self.pdf
461 + series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]
462 + if not series:
463 + return
464 + if p.get_y() > 195:
465 + p.add_page()
466 + self._chart_title(ms.get("title", ""))
467 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
468 + self._card(x0, y0, w, h, fill=WHITE)
469 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
470 + vals = [pt["v"] for s in series for pt in s["points"]]
471 + vmax = max(vals) or 1
472 + vmin = min(0, min(vals))
473 + rng = (vmax - vmin) or 1
474 + p.set_font("helvetica", "", 6.3)
475 + p.set_text_color(*INK3)
476 + p.set_draw_color(200, 200, 195)
477 + p.set_line_width(0.15)
478 + for g in range(5):
479 + gy = cy + ch - ch * g / 4
480 + p.line(cx, gy, cx + cw, gy)
481 + p.set_xy(x0 + 1, gy - 1.6)
482 + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
483 + styles = [
484 + (self.accent, 0.7, None),
485 + (INK, 0.45, None),
486 + (self.accent, 0.55, True),
487 + (INK3, 0.5, True),
488 + ]
489 + for si, s in enumerate(series):
490 + col, lw, dash = styles[si]
491 + p.set_draw_color(*col)
492 + p.set_line_width(lw)
493 + if dash:
494 + p.set_dash_pattern(dash=1.4, gap=1.2)
495 + n = len(s["points"])
496 + last = None
497 + for i, pt in enumerate(s["points"]):
498 + px = cx + cw * (i / (n - 1))
499 + py = cy + ch - ch * ((pt["v"] - vmin) / rng)
500 + if last:
501 + p.line(last[0], last[1], px, py)
502 + last = (px, py)
503 + p.set_dash_pattern()
504 + ref = series[0]["points"]
505 + p.set_text_color(*INK3)
506 + for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):
507 + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
508 + p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")
509 + p.set_y(y0 + h + 4)
510 + p.set_font("helvetica", "", 6.8)
511 + p.set_text_color(*INK3)
512 + marks = ["—", "—", "----", "----"]
513 + leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))
514 + p.cell(0, 4, leg[:120])
515 + p.ln(6)
516 +
517 + def _stacked(self, st):
518 + p = self.pdf
519 + keys = (st.get("keys") or [])[:6]
520 + pts = st.get("points") or []
521 + if not keys or not pts:
522 + return
523 + if p.get_y() > 195:
524 + p.add_page()
525 + self._chart_title(st.get("title", ""))
526 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
527 + self._card(x0, y0, w, h, fill=WHITE)
528 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
529 + totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]
530 + vmax = max(totals) or 1
531 + p.set_font("helvetica", "", 6.3)
532 + p.set_text_color(*INK3)
533 + p.set_draw_color(200, 200, 195)
534 + p.set_line_width(0.15)
535 + for g in range(5):
536 + gy = cy + ch - ch * g / 4
537 + p.line(cx, gy, cx + cw, gy)
538 + p.set_xy(x0 + 1, gy - 1.6)
539 + p.cell(10, 3, _fr(vmax * g / 4), align="R")
540 + n = len(pts)
541 + bw = max(0.8, cw / n - 0.6)
542 + p.set_draw_color(*WHITE)
543 + p.set_line_width(0.12)
544 + for i, pt in enumerate(pts):
545 + yacc = cy + ch
546 + for j, k in enumerate(keys):
547 + v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0
548 + if not v:
549 + continue
550 + bh = ch * (v / vmax)
551 + yacc -= bh
552 + p.set_fill_color(*self._shade(j))
553 + p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")
554 + p.set_text_color(*INK3)
555 + for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):
556 + p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)
557 + p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")
558 + p.set_y(y0 + h + 4)
559 + # légende
560 + p.set_font("helvetica", "", 6.8)
561 + lx = p.l_margin
562 + for j, k in enumerate(keys):
563 + p.set_fill_color(*self._shade(j))
564 + p.set_draw_color(*INK)
565 + p.set_line_width(0.2)
566 + p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")
567 + p.set_xy(lx + 4, p.get_y())
568 + p.set_text_color(*INK2)
569 + txt = str(k)[:22]
570 + p.cell(p.get_string_width(txt) + 3, 4, txt)
571 + lx = p.get_x() + 3
572 + if lx > 165:
573 + break
574 + p.ln(7)
318 575
319 576 def _bars(self, title, items, unit=""):
320 577 p = self.pdf
@@ -324,10 +581,8 @@ class GroupeKAReport:
324 581 need = 10 + len(items) * 7
325 582 if p.get_y() + need > 265:
326 583 p.add_page()
327 − p.set_font("helvetica", "B", 10)
328 − p.set_text_color(*INK)
329 − p.cell(0, 6, title)
330 − p.ln(8)
584 + self._chart_title(title)
585 + p.ln(1)
331 586 vmax = max(it["value"] for it in items) or 1
332 587 for it in items:
333 588 y = p.get_y()
@@ -335,19 +590,23 @@ class GroupeKAReport:
335 590 p.set_text_color(*INK)
336 591 p.set_x(p.l_margin)
337 592 p.cell(46, 5, str(it["label"])[:34])
338 − bw = 96 * (it["value"] / vmax)
593 + bw = 86 * (it["value"] / vmax)
339 594 p.set_fill_color(*self.accent)
340 595 p.set_draw_color(*INK)
341 596 p.set_line_width(0.25)
342 597 p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
343 − p.set_xy(p.l_margin + 148, y)
598 + p.set_xy(p.l_margin + 136, y)
344 599 p.set_font("helvetica", "B", 7.6)
345 − p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
600 + p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
601 + if it.get("delta_pct") is not None:
602 + up = it["delta_pct"] >= 0
603 + p.set_font("helvetica", "B", 6.6)
604 + p.set_text_color(*(GREEN if up else DANGER))
605 + p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")
346 606 p.ln(6.4)
347 607 p.ln(3)
348 608
349 609 def _donut(self, b):
350 − # anneau vectoriel simple (arcs) + légende
351 610 p = self.pdf
352 611 items = [it for it in (b.get("items") or []) if it.get("value")][:8]
353 612 total = sum(it["value"] for it in items)
@@ -355,17 +614,13 @@ class GroupeKAReport:
355 614 return
356 615 if p.get_y() > 210:
357 616 p.add_page()
358 − p.set_font("helvetica", "B", 10)
359 − p.set_text_color(*INK)
360 − p.cell(0, 6, b.get("title", ""))
361 − p.ln(8)
617 + self._chart_title(b.get("title", ""))
618 + p.ln(1)
362 619 cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
363 − shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
364 620 start = -90.0
365 621 for i, it in enumerate(items):
366 622 frac = it["value"] / total
367 − f = shades[i % len(shades)]
368 − col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
623 + col = self._shade(i)
369 624 steps = max(2, int(72 * frac))
370 625 p.set_fill_color(*col)
371 626 p.set_draw_color(*col)
@@ -384,11 +639,9 @@ class GroupeKAReport:
384 639 p.set_line_width(0.4)
385 640 p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
386 641 p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
387 − # légende
388 642 ly = cy - 22
389 643 for i, it in enumerate(items):
390 − f = shades[i % len(shades)]
391 − col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
644 + col = self._shade(i)
392 645 p.set_fill_color(*col)
393 646 p.set_draw_color(*INK)
394 647 p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
@@ -400,7 +653,39 @@ class GroupeKAReport:
400 653 ly += 5.6
401 654 p.set_y(max(cy + r, ly) + 6)
402 655
403 − def _table(self, t):
656 + def _hourly(self):
657 + hh = self.d.get("hourly") or {}
658 + cells = hh.get("cells") or []
659 + if not cells:
660 + return
661 + p = self.pdf
662 + if p.get_y() > 190:
663 + p.add_page()
664 + self._chart_title(hh.get("title", "Activité par jour et heure"))
665 + x0, y0 = p.l_margin, p.get_y()
666 + cw, chh, lx, ly = 6.4, 6.4, 12, 5
667 + vmax = max((c.get("value") or 0) for c in cells) or 1
668 + grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}
669 + dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
670 + p.set_font("helvetica", "", 5.8)
671 + p.set_text_color(*INK3)
672 + for h in (0, 6, 12, 18, 23):
673 + p.set_xy(x0 + lx + h * cw, y0)
674 + p.cell(cw, 3, f"{h}h", align="C")
675 + for d in range(7):
676 + p.set_xy(x0, y0 + ly + d * chh + 1.5)
677 + p.cell(lx - 1, 3, dows[d], align="R")
678 + for h in range(24):
679 + v = grid.get((d, h), 0)
680 + f = 0.1 + 0.9 * (v / vmax) if v else 0.0
681 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)
682 + p.set_fill_color(*col)
683 + p.set_draw_color(215, 213, 207)
684 + p.set_line_width(0.1)
685 + p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
686 + p.set_y(y0 + ly + 7 * chh + 5)
687 +
688 + def _table(self, t, max_rows=200):
404 689 p = self.pdf
405 690 cols = t.get("columns") or []
406 691 rows = t.get("rows") or []
@@ -417,7 +702,7 @@ class GroupeKAReport:
417 702 p.ln(6)
418 703 head()
419 704 p.set_text_color(*INK)
420 − for i, row in enumerate(rows[:200]):
705 + for i, row in enumerate(rows[:max_rows]):
421 706 if p.get_y() > 262:
422 707 p.add_page()
423 708 head()
@@ -428,10 +713,10 @@ class GroupeKAReport:
428 713 txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
429 714 p.cell(w, 5.4, " " + txt[:34], fill=True)
430 715 p.ln(5.4)
431 − if len(rows) > 200:
716 + if len(rows) > max_rows:
432 717 p.set_font("helvetica", "", 7)
433 718 p.set_text_color(*INK3)
434 − p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
719 + p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")
435 720 p.ln(6)
436 721
437 722 def _records(self):
@@ -440,7 +725,7 @@ class GroupeKAReport:
440 725 return
441 726 self._section_title("Records & faits marquants")
442 727 p = self.pdf
443 − for r in recs[:10]:
728 + for r in recs[:14]:
444 729 if p.get_y() > 258:
445 730 p.add_page()
446 731 y = p.get_y()
@@ -498,43 +783,76 @@ class GroupeKAReport:
498 783 "groupe-ka.com/conditions · /confidentialite · /loi-25.",
499 784 )
500 785
501 − def _toc_page(self):
502 − # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
503 − # page 2 en réservant la page lors du build (voir build()).
504 − pass
786 + # ---------- groupes de sections ----------
787 + def _all_series(self, with_stats=True):
788 + for s in self.d.get("series") or []:
789 + self._line_chart(s, with_stats=with_stats)
790 + for ms in self.d.get("multiseries") or []:
791 + self._multiline(ms)
792 + for st in self.d.get("stacked") or []:
793 + self._stacked(st)
794 +
795 + def _all_breakdowns(self):
796 + for b in self.d.get("breakdowns") or []:
797 + if b.get("kind") == "donut":
798 + self._donut(b)
799 + else:
800 + self._bars(b.get("title", ""), b.get("items"))
801 + for dist in self.d.get("distributions") or []:
802 + self._vbars(dist)
803 + geo = self.d.get("geo")
804 + if geo:
805 + self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
806 + self._hourly()
505 807
506 808 def build(self) -> bytes:
507 809 p = self.pdf
508 810 p.alias_nb_pages()
509 811 self._cover()
812 + with_toc = self.mode in ("complet", "donnees")
813 + toc_page_no = None
510 814 if self.mode == "synthese":
511 815 p.add_page()
512 816 self._kpis()
817 + self._gauges()
818 + self._records()
819 + self._final_page()
820 + elif self.mode == "tendances":
821 + p.add_page()
822 + self._kpis()
823 + self._section_title("Évolution & tendances")
824 + self._all_series(with_stats=True)
513 825 self._records()
514 826 self._final_page()
515 − else:
827 + elif self.mode == "repartitions":
828 + p.add_page()
829 + self._section_title("Répartitions, distributions & géographie")
830 + self._all_breakdowns()
831 + self._final_page()
832 + elif self.mode == "donnees":
833 + p.add_page()
834 + toc_page_no = p.page_no()
835 + for t in self.d.get("tables") or []:
836 + self._table(t, max_rows=400)
837 + self._final_page()
838 + else: # complet
516 839 p.add_page()
517 840 toc_page_no = p.page_no()
518 841 p.add_page()
519 842 self._kpis()
520 − for s in self.d.get("series") or []:
521 − if s.get("kind") == "bar":
522 − self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
523 − else:
524 − self._line_chart(s)
525 − for b in self.d.get("breakdowns") or []:
526 − if b.get("kind") == "donut":
527 − self._donut(b)
528 − else:
529 − self._bars(b.get("title", ""), b.get("items"))
530 − geo = self.d.get("geo")
531 − if geo:
532 − self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
843 + self._gauges()
844 + if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):
845 + self._section_title("Évolution & tendances")
846 + self._all_series(with_stats=True)
847 + if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):
848 + self._section_title("Répartitions, distributions & géographie")
849 + self._all_breakdowns()
533 850 for t in self.d.get("tables") or []:
534 851 self._table(t)
535 852 self._records()
536 853 self._final_page()
537 − # sommaire écrit sur la page réservée (page 2)
854 + # sommaire écrit sur la page réservée
855 + if toc_page_no is not None:
538 856 last_page = p.page
539 857 p.page = toc_page_no
540 858 p.set_y(22)
@@ -553,6 +871,7 @@ class GroupeKAReport:
553 871 return bytes(p.output())
554 872
555 873
556 −def filename(platform_id: str, period: str) -> str:
874 +def filename(platform_id: str, period: str, mode: str = "complet") -> str:
557 875 today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
558 − return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
876 + suffix = "" if mode in ("", "complet") else f"_{mode}"
877 + return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"
deleted frontend/src/map/immokaStyle.ts +0 −235
@@ -1,235 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// map/immokaStyle.ts : style cartographique « Groupe Ka », écrit FROM SCRATCH
5 −// sur le schéma OpenMapTiles (tuiles vectorielles libres OpenFreeMap).
6 −// Direction « éditoriale contrastée » : crème chaud, eau bleue franche,
7 −// forêts vertes, autoroutes rouges (la marque), primaires jaune Michelin,
8 −// relief au zoom province — une carte de magazine, pas un fond gris.
9 −// -----------------------------------------------------------------------------
10 −import type { StyleSpecification } from "maplibre-gl";
11 −
12 −// ---- palette éditoriale -----------------------------------------------------
13 −const PAPER = "#f6efe6"; // crème chaud
14 −const WATER = "#a6c7dd"; // eau bleue franche
15 −const WATER_DEEP = "#97bcd6"; // océan / grands plans d'eau
16 −const GRASS = "#dce8c6"; // prés / cultures
17 −const WOOD = "#c4dcae"; // forêts — vert affirmé
18 −const PARK = "#cbe3b3"; // parcs
19 −const RESID = "#efe2d2"; // trame urbaine sable
20 −const INDUS = "#e9e0e2"; // industriel mauve-gris
21 −const SAND = "#f3e8c9";
22 −const BUILD = "#e2cdbd"; // bâtiments terracotta pâle
23 −const BUILD_LINE = "#d2b7a4";
24 −const MOTOR = "#e0584c"; // autoroutes — ROUGE Immo-Ka
25 −const MOTOR_CASE = "#ad342d";
26 −const PRIM = "#f6d17c"; // primaires — jaune éditorial
27 −const PRIM_CASE = "#cfa14a";
28 −const ROAD = "#ffffff";
29 −const ROAD_CASE = "#cfbcab";
30 −const MINOR_CASE = "#dccbba";
31 −const RAIL = "#a29191";
32 −const BOUND = "#a77f8b";
33 −const INK = "#2a2024"; // étiquettes principales
34 −const INK_SOFT = "#6d5d60";
35 −const WATER_INK = "#3f6f92";
36 −const GREEN_INK = "#5c7a45";
37 −
38 −const style: StyleSpecification = {
39 − version: 8,
40 − name: "Immo-Ka · Groupe Ka (éditorial)",
41 − glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
42 − sources: {
43 − omt: { type: "vector", url: "https://tiles.openfreemap.org/planet" },
44 − relief: {
45 − type: "raster", maxzoom: 6, tileSize: 256,
46 − tiles: ["https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png"],
47 − },
48 − },
49 − layers: [
50 − { id: "bg", type: "background", paint: { "background-color": PAPER } },
51 −
52 − // relief léger au zoom province — donne du corps à la vue d'ensemble
53 − { id: "relief", type: "raster", source: "relief", maxzoom: 7,
54 − paint: { "raster-opacity": ["interpolate", ["linear"], ["zoom"], 0, 0.55, 5, 0.45, 6.5, 0] } },
55 −
56 − // --- occupation du sol -------------------------------------------------
57 − { id: "landcover-grass", type: "fill", source: "omt", "source-layer": "landcover",
58 − filter: ["in", ["get", "class"], ["literal", ["grass", "farmland"]]],
59 − paint: { "fill-color": GRASS, "fill-opacity": 0.85 } },
60 − { id: "landcover-wetland", type: "fill", source: "omt", "source-layer": "landcover",
61 − filter: ["==", ["get", "class"], "wetland"],
62 − paint: { "fill-color": "#cfe0cf", "fill-opacity": 0.7 } },
63 − { id: "landcover-wood", type: "fill", source: "omt", "source-layer": "landcover",
64 − filter: ["==", ["get", "class"], "wood"],
65 − paint: { "fill-color": WOOD,
66 − "fill-opacity": ["interpolate", ["linear"], ["zoom"], 5, 0.7, 11, 0.9] } },
67 − { id: "landcover-sand", type: "fill", source: "omt", "source-layer": "landcover",
68 − filter: ["==", ["get", "class"], "sand"], paint: { "fill-color": SAND } },
69 − { id: "landcover-ice", type: "fill", source: "omt", "source-layer": "landcover",
70 − filter: ["==", ["get", "class"], "ice"], paint: { "fill-color": "#eef4f6" } },
71 − { id: "landuse-residential", type: "fill", source: "omt", "source-layer": "landuse",
72 − filter: ["in", ["get", "class"], ["literal", ["residential", "suburb", "neighbourhood"]]],
73 − paint: { "fill-color": RESID,
74 − "fill-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 12, 0.9] } },
75 − { id: "landuse-indus", type: "fill", source: "omt", "source-layer": "landuse",
76 − filter: ["in", ["get", "class"], ["literal", ["industrial", "commercial", "quarry", "railway"]]],
77 − paint: { "fill-color": INDUS, "fill-opacity": 0.7 } },
78 − { id: "landuse-green", type: "fill", source: "omt", "source-layer": "landuse",
79 − filter: ["in", ["get", "class"], ["literal", ["cemetery", "stadium", "pitch", "playground"]]],
80 − paint: { "fill-color": PARK, "fill-opacity": 0.6 } },
81 − { id: "park", type: "fill", source: "omt", "source-layer": "park",
82 − paint: { "fill-color": PARK, "fill-opacity": 0.75 } },
83 −
84 − // --- eau ---------------------------------------------------------------
85 − { id: "waterway", type: "line", source: "omt", "source-layer": "waterway",
86 − paint: { "line-color": WATER,
87 − "line-width": ["interpolate", ["exponential", 1.4], ["zoom"], 8, 0.8, 14, 3, 18, 8] } },
88 − { id: "water", type: "fill", source: "omt", "source-layer": "water",
89 − paint: { "fill-color": ["case", ["==", ["get", "class"], "ocean"], WATER_DEEP, WATER] } },
90 −
91 − // --- aéroports ----------------------------------------------------------
92 − { id: "aeroway", type: "line", source: "omt", "source-layer": "aeroway",
93 − minzoom: 11, filter: ["in", ["get", "class"], ["literal", ["runway", "taxiway"]]],
94 − paint: { "line-color": "#e6d9d2",
95 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 1, 16, 26] } },
96 −
97 − // --- tunnels --------------------------------------------------------------
98 − { id: "tunnel", type: "line", source: "omt", "source-layer": "transportation",
99 − filter: ["==", ["get", "brunnel"], "tunnel"],
100 − paint: { "line-color": ROAD_CASE, "line-dasharray": [2, 2],
101 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 8, 0.8, 14, 3.5, 18, 12] } },
102 −
103 − // --- voirie : liserés (du bas vers le haut de la hiérarchie) ---------------
104 − { id: "road-minor-case", type: "line", source: "omt", "source-layer": "transportation",
105 − minzoom: 11,
106 − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
107 − ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]]],
108 − layout: { "line-cap": "round", "line-join": "round" },
109 − paint: { "line-color": MINOR_CASE,
110 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 1.2, 15, 5, 18, 16] } },
111 − { id: "road-mid-case", type: "line", source: "omt", "source-layer": "transportation",
112 − minzoom: 9,
113 − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
114 − ["in", ["get", "class"], ["literal", ["secondary", "tertiary"]]]],
115 − layout: { "line-cap": "round", "line-join": "round" },
116 − paint: { "line-color": ROAD_CASE,
117 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 9, 2, 14, 7, 18, 22] } },
118 − { id: "road-prim-case", type: "line", source: "omt", "source-layer": "transportation",
119 − minzoom: 7,
120 − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
121 − ["in", ["get", "class"], ["literal", ["primary", "trunk"]]]],
122 − layout: { "line-cap": "round", "line-join": "round" },
123 − paint: { "line-color": PRIM_CASE,
124 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 7, 1.8, 14, 8, 18, 26] } },
125 − { id: "motorway-case", type: "line", source: "omt", "source-layer": "transportation",
126 − minzoom: 5, filter: ["==", ["get", "class"], "motorway"],
127 − layout: { "line-cap": "round", "line-join": "round" },
128 − paint: { "line-color": MOTOR_CASE,
129 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 1.4, 14, 9, 18, 28] } },
130 −
131 − // --- voirie : remplissages ---------------------------------------------------
132 − { id: "road-path", type: "line", source: "omt", "source-layer": "transportation",
133 − minzoom: 14, filter: ["==", ["get", "class"], "path"],
134 − paint: { "line-color": "#b8a394", "line-dasharray": [2.5, 2],
135 − "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0.8, 18, 2.5] } },
136 − { id: "road-minor", type: "line", source: "omt", "source-layer": "transportation",
137 − minzoom: 11,
138 − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
139 − ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]]],
140 − layout: { "line-cap": "round", "line-join": "round" },
141 − paint: { "line-color": ROAD,
142 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 0.7, 15, 3.8, 18, 13] } },
143 − { id: "road-mid", type: "line", source: "omt", "source-layer": "transportation",
144 − minzoom: 9,
145 − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
146 − ["in", ["get", "class"], ["literal", ["secondary", "tertiary"]]]],
147 − layout: { "line-cap": "round", "line-join": "round" },
148 − paint: { "line-color": ROAD,
149 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 9, 1.3, 14, 5.5, 18, 18] } },
150 − { id: "road-prim", type: "line", source: "omt", "source-layer": "transportation",
151 − minzoom: 7,
152 − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
153 − ["in", ["get", "class"], ["literal", ["primary", "trunk"]]]],
154 − layout: { "line-cap": "round", "line-join": "round" },
155 − paint: { "line-color": PRIM,
156 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 7, 1.1, 14, 6.5, 18, 22] } },
157 − { id: "motorway", type: "line", source: "omt", "source-layer": "transportation",
158 − minzoom: 5, filter: ["==", ["get", "class"], "motorway"],
159 − layout: { "line-cap": "round", "line-join": "round" },
160 − paint: { "line-color": MOTOR,
161 − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 0.9, 14, 7, 18, 24] } },
162 − { id: "rail", type: "line", source: "omt", "source-layer": "transportation",
163 − minzoom: 10, filter: ["==", ["get", "class"], "rail"],
164 − paint: { "line-color": RAIL, "line-dasharray": [3, 3],
165 − "line-width": ["interpolate", ["linear"], ["zoom"], 10, 0.7, 16, 2.2] } },
166 −
167 − // --- ponts (léger liseré pour lecture) -----------------------------------------
168 − { id: "bridge-case", type: "line", source: "omt", "source-layer": "transportation",
169 − minzoom: 12, filter: ["==", ["get", "brunnel"], "bridge"],
170 − paint: { "line-color": "#b7a08e", "line-gap-width":
171 − ["interpolate", ["exponential", 1.5], ["zoom"], 12, 3, 18, 20], "line-width": 1.2 } },
172 −
173 − // --- bâtiments -------------------------------------------------------------------
174 − { id: "building", type: "fill", source: "omt", "source-layer": "building",
175 − minzoom: 13,
176 − paint: { "fill-color": BUILD, "fill-outline-color": BUILD_LINE,
177 − "fill-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0.55, 16, 0.95] } },
178 −
179 − // --- frontières -----------------------------------------------------------------
180 − { id: "boundary-prov", type: "line", source: "omt", "source-layer": "boundary",
181 − filter: ["all", ["==", ["get", "admin_level"], 4], ["!=", ["get", "maritime"], 1]],
182 − paint: { "line-color": BOUND, "line-dasharray": [4, 3],
183 − "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.6] } },
184 − { id: "boundary-country", type: "line", source: "omt", "source-layer": "boundary",
185 − filter: ["all", ["==", ["get", "admin_level"], 2], ["!=", ["get", "maritime"], 1]],
186 − paint: { "line-color": BOUND,
187 − "line-width": ["interpolate", ["linear"], ["zoom"], 3, 1.2, 10, 2.6] } },
188 −
189 − // --- étiquettes --------------------------------------------------------------------
190 − { id: "park-label", type: "symbol", source: "omt", "source-layer": "park",
191 − minzoom: 11, filter: ["==", ["geometry-type"], "Point"],
192 − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
193 − "text-font": ["Noto Sans Italic"], "text-size": 11.5 },
194 − paint: { "text-color": GREEN_INK, "text-halo-color": PAPER, "text-halo-width": 1.4 } },
195 − { id: "water-label", type: "symbol", source: "omt", "source-layer": "water_name",
196 − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
197 − "text-font": ["Noto Sans Italic"],
198 − "text-size": ["interpolate", ["linear"], ["zoom"], 6, 11, 12, 14] },
199 − paint: { "text-color": WATER_INK, "text-halo-color": "rgba(246,239,230,0.75)",
200 − "text-halo-width": 1.2 } },
201 − { id: "road-label", type: "symbol", source: "omt", "source-layer": "transportation_name",
202 − minzoom: 12.5,
203 − layout: { "symbol-placement": "line",
204 − "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
205 − "text-font": ["Noto Sans Regular"], "text-size": 11.5 },
206 − paint: { "text-color": INK_SOFT, "text-halo-color": PAPER, "text-halo-width": 1.6 } },
207 − { id: "place-village", type: "symbol", source: "omt", "source-layer": "place",
208 − minzoom: 9,
209 − filter: ["in", ["get", "class"], ["literal", ["village", "suburb", "neighbourhood", "hamlet"]]],
210 − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
211 − "text-font": ["Noto Sans Regular"],
212 − "text-size": ["interpolate", ["linear"], ["zoom"], 9, 11, 14, 13.5] },
213 − paint: { "text-color": INK_SOFT, "text-halo-color": PAPER, "text-halo-width": 1.7 } },
214 − { id: "place-town", type: "symbol", source: "omt", "source-layer": "place",
215 − filter: ["==", ["get", "class"], "town"],
216 − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
217 − "text-font": ["Noto Sans Bold"],
218 − "text-size": ["interpolate", ["linear"], ["zoom"], 6, 12, 12, 16] },
219 − paint: { "text-color": INK, "text-halo-color": PAPER, "text-halo-width": 1.8 } },
220 − { id: "place-city", type: "symbol", source: "omt", "source-layer": "place",
221 − filter: ["==", ["get", "class"], "city"],
222 − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
223 − "text-font": ["Noto Sans Bold"],
224 − "text-size": ["interpolate", ["linear"], ["zoom"], 4, 13, 10, 19] },
225 − paint: { "text-color": INK, "text-halo-color": PAPER, "text-halo-width": 2 } },
226 − { id: "place-state", type: "symbol", source: "omt", "source-layer": "place",
227 − maxzoom: 7, filter: ["==", ["get", "class"], "state"],
228 − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
229 − "text-font": ["Noto Sans Regular"], "text-size": 11.5,
230 − "text-transform": "uppercase", "text-letter-spacing": 0.18 },
231 − paint: { "text-color": BOUND, "text-halo-color": PAPER, "text-halo-width": 1.5 } },
232 − ],
233 −};
234 −
235 −export default style;
deleted frontend/src/map/pills.ts +0 −89
@@ -1,89 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// map/pills.ts : pastilles de prix pour la carte — images « étirables »
5 −// (stretchable images MapLibre) générées au canvas : la pastille s'adapte
6 −// à la largeur du texte (icon-text-fit) sans aucun sprite externe.
7 −// -----------------------------------------------------------------------------
8 −import type { Map as MLMap, StyleImageMetadata } from "maplibre-gl";
9 −
10 −const R = 13; // rayon des coins (px logiques)
11 −const H = 30; // hauteur de la pastille
12 −const W = 64; // largeur de base (étirée au besoin)
13 −const DPR = 2; // netteté écrans Retina
14 −
15 −/** roundRect avec repli manuel (Safari < 16 ne l'a pas). */
16 −function rr(g: CanvasRenderingContext2D, x: number, y: number,
17 − w: number, h: number, r: number): void {
18 − if (typeof (g as any).roundRect === "function") {
19 − (g as any).roundRect(x, y, w, h, r);
20 − return;
21 − }
22 − g.moveTo(x + r, y);
23 − g.arcTo(x + w, y, x + w, y + h, r);
24 − g.arcTo(x + w, y + h, x, y + h, r);
25 − g.arcTo(x, y + h, x, y, r);
26 − g.arcTo(x, y, x + w, y, r);
27 − g.closePath();
28 −}
29 −
30 −function drawPill(fill: string, stroke: string, shadow = true): ImageData {
31 − const cv = document.createElement("canvas");
32 − cv.width = W * DPR;
33 − cv.height = (H + 6) * DPR; // marge pour l'ombre + la pointe
34 − const g = cv.getContext("2d")!;
35 − g.scale(DPR, DPR);
36 − if (shadow) {
37 − g.shadowColor = "rgba(26,18,20,0.28)";
38 − g.shadowBlur = 5;
39 − g.shadowOffsetY = 2;
40 − }
41 − // corps arrondi
42 − g.beginPath();
43 − rr(g, 1, 1, W - 2, H - 2, R);
44 − g.fillStyle = fill;
45 − g.fill();
46 − g.shadowColor = "transparent";
47 − // petite pointe sous la pastille (ancrage visuel sur la propriété)
48 − g.beginPath();
49 − g.moveTo(W / 2 - 5, H - 1.5);
50 − g.lineTo(W / 2, H + 4);
51 − g.lineTo(W / 2 + 5, H - 1.5);
52 − g.closePath();
53 − g.fillStyle = fill;
54 − g.fill();
55 − g.strokeStyle = stroke;
56 − g.lineWidth = 1.4;
57 − g.beginPath();
58 − rr(g, 1, 1, W - 2, H - 2, R);
59 − g.stroke();
60 − return g.getImageData(0, 0, cv.width, cv.height);
61 −}
62 −
63 −/** Enregistre les variantes de pastille dans la carte. */
64 −export function addPills(map: MLMap): void {
65 − const meta: Partial<StyleImageMetadata> = {
66 − pixelRatio: DPR,
67 − // zones étirables entre les coins arrondis — coordonnées PHYSIQUES (× DPR)
68 − // et non dégénérées (x1 < x2), sinon MapLibre rejette l'image en silence
69 − stretchX: [[(R + 1) * DPR, (W - R - 1) * DPR]],
70 − stretchY: [[(R + 1) * DPR, (H - R - 1) * DPR]],
71 − // zone où le texte peut vivre
72 − content: [8 * DPR, 5 * DPR, (W - 8) * DPR, (H - 5) * DPR],
73 − };
74 − if (!map.hasImage("pill"))
75 − map.addImage("pill", drawPill("#ffffff", "rgba(26,18,20,0.35)"), meta);
76 − if (!map.hasImage("pill-deal"))
77 − map.addImage("pill-deal", drawPill("#e23744", "#b3202b"), meta);
78 − if (!map.hasImage("pill-active"))
79 − map.addImage("pill-active", drawPill("#1a1214", "#1a1214"), meta);
80 −}
81 −
82 −/** « 425 k$ », « 1,25 M$ » — format compact des pastilles. */
83 −export function shortPrice(p: number | null | undefined): string {
84 − if (p == null) return "•";
85 − if (p >= 1_000_000)
86 − return `${(p / 1_000_000).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} M$`;
87 − if (p >= 10_000) return `${Math.round(p / 1000)} k$`;
88 − return `${p.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`;
89 −}
modified frontend/src/pages/Listing.tsx +154 −34
@@ -15,13 +15,98 @@ import {
15 15 const PropertyMap = lazy(() => import("../components/PropertyMap"));
16 16 import QuartierBlock from "../components/QuartierBlock";
17 17 import { Ico } from "../components/Icons";
18 +import { TypeFallback } from "../components/PropertyImg";
19 +
20 +// --- Lightbox : pincement pour zoomer + panoramique + balayage entre photos ---
21 +function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) {
22 + const [t, setT] = useState({ scale: 1, x: 0, y: 0 });
23 + const pointers = useRef(new Map<number, { x: number; y: number }>());
24 + const start = useRef({ scale: 1, x: 0, y: 0, dist: 0, cx: 0, cy: 0, t: 0 });
25 + const lastTap = useRef(0);
26 +
27 + // repartir à zéro quand on change de photo
28 + useEffect(() => { setT({ scale: 1, x: 0, y: 0 }); }, [src]);
29 +
30 + const dist = () => {
31 + const p = [...pointers.current.values()];
32 + return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
33 + };
34 + const center = () => {
35 + const p = [...pointers.current.values()];
36 + return p.length < 2
37 + ? p[0] ?? { x: 0, y: 0 }
38 + : { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 };
39 + };
40 +
41 + const onDown = (e: React.PointerEvent) => {
42 + (e.target as HTMLElement).setPointerCapture(e.pointerId);
43 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
44 + const c = center();
45 + start.current = { scale: t.scale, x: t.x, y: t.y, dist: dist(), cx: c.x, cy: c.y, t: Date.now() };
46 + };
47 + const onMove = (e: React.PointerEvent) => {
48 + if (!pointers.current.has(e.pointerId)) return;
49 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
50 + const s = start.current;
51 + if (pointers.current.size >= 2 && s.dist > 0) {
52 + // pincement : zoom autour du centre des deux doigts
53 + const scale = Math.min(4, Math.max(1, (dist() / s.dist) * s.scale));
54 + const c = center();
55 + setT({ scale, x: s.x + (c.x - s.cx), y: s.y + (c.y - s.cy) });
56 + } else if (pointers.current.size === 1 && t.scale > 1) {
57 + // panoramique une fois zoomé
58 + const p = pointers.current.get(e.pointerId)!;
59 + setT({ scale: t.scale, x: s.x + (p.x - s.cx), y: s.y + (p.y - s.cy) });
60 + }
61 + };
62 + const onUp = (e: React.PointerEvent) => {
63 + const p = pointers.current.get(e.pointerId);
64 + pointers.current.delete(e.pointerId);
65 + const s = start.current;
66 + if (pointers.current.size === 0 && p) {
67 + const dx = p.x - s.cx, dy = p.y - s.cy, dt = Date.now() - s.t;
68 + if (t.scale <= 1.05 && Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) {
69 + onSwipe(dx < 0 ? 1 : -1); // balayage → photo suivante
70 + } else if (dt < 260 && Math.abs(dx) < 8 && Math.abs(dy) < 8) {
71 + const now = Date.now();
72 + if (now - lastTap.current < 320) // double-tap : zoom ×2.4
73 + setT(t.scale > 1 ? { scale: 1, x: 0, y: 0 } : { scale: 2.4, x: 0, y: 0 });
74 + lastTap.current = now;
75 + }
76 + if (t.scale <= 1.02) setT({ scale: 1, x: 0, y: 0 });
77 + }
78 + };
79 +
80 + return (
81 + <img
82 + src={src} alt="" draggable={false}
83 + style={{
84 + transform: `translate(${t.x}px, ${t.y}px) scale(${t.scale})`,
85 + transition: pointers.current.size ? "none" : "transform 0.15s ease",
86 + touchAction: "none", cursor: t.scale > 1 ? "grab" : "zoom-out",
87 + }}
88 + onClick={(e) => e.stopPropagation()}
89 + onPointerDown={onDown} onPointerMove={onMove}
90 + onPointerUp={onUp} onPointerCancel={onUp}
91 + />
92 + );
93 +}
18 94
19 95 // --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------
20 −function Galerie({ images, titre }: { images: string[]; titre: string }) {
96 +function Galerie({ images, captions, titre, type }:
97 + { images: string[]; captions?: string[]; titre: string; type?: string }) {
21 98 const [idx, setIdx] = useState(0);
22 99 const [zoom, setZoom] = useState(false);
100 + const [dead, setDead] = useState<Set<string>>(new Set());
23 101 const track = useRef<HTMLDivElement>(null);
24 102
103 + // images qui ne chargent pas : retirées de la galerie à la volée (jamais
104 + // d'icône d'image cassée) ; légendes gardées alignées
105 + const alive = images
106 + .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" }))
107 + .filter(({ u }) => !dead.has(u));
108 + const markDead = (u: string) => setDead((d) => new Set(d).add(u));
109 +
25 110 const onScroll = () => {
26 111 const el = track.current;
27 112 if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
@@ -34,33 +119,44 @@ function Galerie({ images, titre }: { images: string[]; titre: string }) {
34 119 const onKey = (e: KeyboardEvent) => {
35 120 if (e.key === "Escape") setZoom(false);
36 121 if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));
37 − if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1));
122 + if (e.key === "ArrowRight") setIdx((i) => Math.min(alive.length - 1, i + 1));
38 123 };
39 124 window.addEventListener("keydown", onKey);
40 − return () => window.removeEventListener("keydown", onKey);
41 − }, [zoom, images.length]);
125 + // figer l'arrière-plan pendant le plein écran (mobile)
126 + document.body.style.overflow = "hidden";
127 + return () => {
128 + window.removeEventListener("keydown", onKey);
129 + document.body.style.overflow = "";
130 + };
131 + }, [zoom, alive.length]);
42 132
43 − if (images.length === 0)
44 − return <div className="carousel"><div className="noimg carousel-empty"><Ico name="home" size={44} /></div></div>;
133 + if (alive.length === 0)
134 + return <div className="carousel"><div className="carousel-empty"><TypeFallback type={type} /></div></div>;
135 +
136 + const cur = Math.min(idx, alive.length - 1);
137 + const swipe = (dir: 1 | -1) =>
138 + setIdx((i) => Math.min(alive.length - 1, Math.max(0, i + dir)));
45 139
46 140 return (
47 141 <>
48 142 <div className="carousel">
49 143 <div className="carousel-track" ref={track} onScroll={onScroll}>
50 − {images.map((u, i) => (
51 − <img key={u} src={u} loading={i === 0 ? "eager" : "lazy"}
52 − alt={`${titre} — photo ${i + 1} de ${images.length}`} onClick={() => setZoom(true)} />
144 + {alive.map(({ u }, i) => (
145 + <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async"
146 + alt={`${titre} — photo ${i + 1} de ${alive.length}`}
147 + onError={() => markDead(u)} onClick={() => setZoom(true)} />
53 148 ))}
54 149 </div>
55 − <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>
56 − {idx > 0 && <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>}
57 − {idx < images.length - 1 && <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>}
150 + {alive[cur]?.cap && <span className="carousel-caption">{alive[cur].cap}</span>}
151 + <span className="carousel-count" aria-live="polite">{cur + 1}/{alive.length}</span>
152 + {cur > 0 && <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(cur - 1)}>‹</button>}
153 + {cur < alive.length - 1 && <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(cur + 1)}>›</button>}
58 154 </div>
59 − {images.length > 1 && (
155 + {alive.length > 1 && (
60 156 <div className="thumbs">
61 − {images.map((u, i) => (
62 − <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>
63 − <img src={u} alt="" loading="lazy" />
157 + {alive.map(({ u }, i) => (
158 + <button key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>
159 + <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} />
64 160 </button>
65 161 ))}
66 162 </div>
@@ -68,17 +164,24 @@ function Galerie({ images, titre }: { images: string[]; titre: string }) {
68 164 {zoom && (
69 165 <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">
70 166 <button className="lb-close" aria-label="Fermer" onClick={() => setZoom(false)}>✕</button>
71 − {idx > 0 && <button className="lb-nav prev" aria-label="Précédente" onClick={(e) => { e.stopPropagation(); setIdx(idx - 1); }}>‹</button>}
72 − <img src={images[idx]} alt="" onClick={(e) => e.stopPropagation()} />
73 − {idx < images.length - 1 && <button className="lb-nav next" aria-label="Suivante" onClick={(e) => { e.stopPropagation(); setIdx(idx + 1); }}>›</button>}
74 − <span className="lb-count">{idx + 1} / {images.length}</span>
167 + {cur > 0 && <button className="lb-nav prev" aria-label="Précédente" onClick={(e) => { e.stopPropagation(); setIdx(cur - 1); }}>‹</button>}
168 + <ZoomImg src={alive[cur].u} onSwipe={swipe} />
169 + {cur < alive.length - 1 && <button className="lb-nav next" aria-label="Suivante" onClick={(e) => { e.stopPropagation(); setIdx(cur + 1); }}>›</button>}
170 + <span className="lb-count">
171 + {alive[cur]?.cap ? `${alive[cur].cap} · ` : ""}{cur + 1} / {alive.length}
172 + </span>
75 173 </div>
76 174 )}
77 175 </>
78 176 );
79 177 }
80 178
81 −const DETAIL_HIDDEN = new Set(["pieces", "price_from"]);
179 +// clés techniques de `details` jamais montrées dans « Caractéristiques »
180 +const DETAIL_HIDDEN = new Set([
181 + "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited",
182 + "needs_image_review", "postal_code", "region", "transaction",
183 + "prix_pi2", "prix_m2", "listing_origin_url",
184 +]);
82 185
83 186 // icône de chaque tuile « spec » (Icons.tsx)
84 187 const SPEC_ICONS: Record<string, string> = {
@@ -151,7 +254,13 @@ export default function ListingPage() {
151 254 {/* -------- colonne gauche : galerie, description, caractéristiques ---- */}
152 255 <div className="f-col">
153 256 <section className="f-bloc f-galerie" aria-label="Photos">
154 − <Galerie images={l.images ?? []} titre={l.address || l.title} />
257 + <Galerie
258 + images={l.images ?? []}
259 + captions={Array.isArray(l.details?.photo_captions)
260 + ? (l.details!.photo_captions as string[]) : undefined}
261 + titre={l.address || l.title}
262 + type={l.property_type}
263 + />
155 264 </section>
156 265
157 266 {l.description && (
@@ -166,7 +275,12 @@ export default function ListingPage() {
166 275 <h2>Caractéristiques</h2>
167 276 <div className="dtable">
168 277 {detEntries.map(([k, v]) => (
169 − <div className="drow" key={k}><span>{k}</span><b>{String(v)}</b></div>
278 + <div className="drow" key={k}>
279 + <span>{k}</span>
280 + {/^https?:\/\//.test(String(v))
281 + ? <b><a href={String(v)} target="_blank" rel="noopener noreferrer">Ouvrir ↗</a></b>
282 + : <b>{String(v)}</b>}
283 + </div>
170 284 ))}
171 285 </div>
172 286 </section>
@@ -203,15 +317,15 @@ export default function ListingPage() {
203 317 {l.lat != null && l.lng != null && (
204 318 <section className="f-bloc" id="carte">
205 319 <h2>Emplacement</h2>
206 − <div className="mini-map">
207 − <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
208 − <PropertyMap
209 − lat={l.lat} lng={l.lng} price={l.price}
210 − deal={l.price != null && l.vraiprix?.value != null
211 − && l.price <= l.vraiprix.value * 0.95}
212 − />
213 − </Suspense>
214 − </div>
320 + <Suspense fallback={<div className="lmap3d lmap3d-skel map-loading">Chargement de la carte…</div>}>
321 + <PropertyMap
322 + uid={l.uid} lat={l.lat} lng={l.lng} price={l.price}
323 + propertyType={l.property_type} address={l.address || l.title}
324 + city={l.city} image={l.images?.[0]}
325 + deal={l.price != null && l.vraiprix?.value != null
326 + && l.price <= l.vraiprix.value * 0.95}
327 + />
328 + </Suspense>
215 329 </section>
216 330 )}
217 331
@@ -220,12 +334,18 @@ export default function ListingPage() {
220 334 {/* -------- colonne droite : synthèse, specs, courtier ---------------- */}
221 335 <div className="f-col">
222 336 <section className="f-bloc f-hero">
223 − <div className="price-kicker">Prix demandé</div>
337 + <div className="price-kicker">
338 + {l.details?.transaction === "location" ? "Loyer mensuel" : "Prix demandé"}
339 + </div>
224 340 <div className="price-row">
225 − <div className="price">{fmtPrice(l.price, l.price_label)}</div>
341 + <div className="price">
342 + {fmtPrice(l.price, l.price_label)}
343 + {l.details?.transaction === "location" && <span className="per-month"> /mois</span>}
344 + </div>
226 345 {l.property_type && (
227 346 <span className="type-chip">
228 347 <Ico name={SPEC_ICONS["Type"]} size={13} /> {l.property_type}
348 + {l.details?.transaction === "location" ? " · location" : ""}
229 349 {l.details?.price_from ? " · à partir de" : ""}
230 350 </span>
231 351 )}
modified frontend/src/pages/Stats.tsx +146 −13
@@ -2,17 +2,23 @@
2 2 // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 // pages/Stats.tsx : tableau de bord analytique du module Stats commun Groupe KA
5 −// (voir ka/stats/SPEC.md). Consomme /api/stats/dashboard (période → refetch)
6 −// et propose l'export PDF /api/stats/report via le kit ka/stats/kacharts.
5 +// v2 (voir ka/stats/SPEC.md). Consomme /api/stats/dashboard (période →
6 +// refetch) et propose les 5 rapports PDF /api/stats/report via le kit
7 +// ka/stats/kacharts (KPI+sparklines, jauges, multi-courbes, empilées,
8 +// distributions, heatmaps, tableaux, records).
7 9 // Section spécifique Immo-Ka conservée : écart prix demandé vs Vrai-Prix.
8 10 // -----------------------------------------------------------------------------
9 11 import { useCallback, useEffect, useState } from "react";
10 12 import type { CSSProperties } from "react";
11 13 import {
12 14 BarChart, CalendarHeatmap, DataTable, Donut, EmptyBlock, Fraicheur,
13 − KpiCard, LineChart, PdfButton, PeriodSelector, RecordCard,
15 + GaugeCard, Histogram, HourHeatmap, KpiCard, LineChart, MultiLineChart,
16 + PdfButton, PeriodSelector, RecordCard, StackedBarChart, StatSummary,
17 +} from "../ka/stats/kacharts";
18 +import type {
19 + BreakItem, Distribution, Gauge, HourCell, Kpi, MultiSerie, RecordFact,
20 + Serie, StackedSerie, TableSpec,
14 21 } from "../ka/stats/kacharts";
15 −import type { BreakItem, Kpi, RecordFact, Serie, TableSpec } from "../ka/stats/kacharts";
16 22 import { fetchStats } from "../api";
17 23 import type { Stats as LegacyStats, VpBanniere } from "../api";
18 24
@@ -21,10 +27,15 @@ interface Dashboard {
21 27 updated: string;
22 28 period: { from: string; to: string; label: string; observed_from?: string };
23 29 kpis: Kpi[];
30 + gauges?: Gauge[];
24 31 series: Serie[];
32 + multiseries?: MultiSerie[];
33 + stacked?: StackedSerie[];
25 34 breakdowns: Breakdown[];
35 + distributions?: Distribution[];
26 36 geo?: { title: string; items: BreakItem[] };
27 37 heatmap?: { title: string; cells: { date: string; value: number }[] };
38 + hourly?: { title: string; cells: HourCell[] };
28 39 tables: TableSpec[];
29 40 records: RecordFact[];
30 41 }
@@ -93,6 +104,92 @@ function VraiPrixBlock({ legacy }: { legacy: LegacyStats | null }) {
93 104 );
94 105 }
95 106
107 +// --- Qualité des données : complétude, quarantaine, anomalies ------------------
108 +const ANOMALIE_LABELS: Record<string, string> = {
109 + quarantaine: "En quarantaine (sous le seuil)",
110 + sans_image: "Sans image (secours affiché)",
111 + prix_hors_bornes: "Prix hors bornes",
112 + superficie_improbable: "Superficie improbable",
113 + terrain_improbable: "Terrain improbable",
114 + chambres_improbables: "Chambres improbables",
115 + sdb_improbables: "Salles de bain improbables",
116 + chambres_vs_type: "Chambres vs type incohérents",
117 + annee_invalide: "Année de construction invalide",
118 + prix_pi2_extreme: "Prix au pi² extrême",
119 +};
120 +
121 +function QualiteBlock({ legacy }: { legacy: LegacyStats | null }) {
122 + const q = legacy?.qualite;
123 + if (!q) return null;
124 + const pct = q.actives ? Math.round((q.publiees / q.actives) * 100) : 0;
125 + const rows = [...q.par_source].sort((a, b) => b.n - a.n).slice(0, 20);
126 + return (
127 + <div className="viz-card">
128 + <h2>Qualité des données</h2>
129 + <div className="viz-sub">
130 + Chaque annonce reçoit un score de complétude (0–100) et passe des contrôles de
131 + cohérence immobiliers ; sous le seuil de publication, elle part en quarantaine
132 + jusqu'à complétion.
133 + </div>
134 + <div className="tiles" style={{ marginBottom: 18 }}>
135 + <div className="tile hero-tile">
136 + <div className="tile-v">{q.completude_moyenne ?? "—"}</div>
137 + <div className="tile-k">Complétude moyenne / 100</div>
138 + </div>
139 + <div className="tile">
140 + <div className="tile-v">{q.publiees.toLocaleString("fr-CA")}</div>
141 + <div className="tile-k">Annonces publiées ({pct} %)</div>
142 + </div>
143 + <div className="tile">
144 + <div className="tile-v">{q.quarantaine.toLocaleString("fr-CA")}</div>
145 + <div className="tile-k">En quarantaine</div>
146 + </div>
147 + <div className="tile">
148 + <div className="tile-v">
149 + {Object.entries(q.anomalies).filter(([k]) => k !== "quarantaine")
150 + .reduce((s, [, v]) => s + v, 0).toLocaleString("fr-CA")}
151 + </div>
152 + <div className="tile-k">Anomalies signalées</div>
153 + </div>
154 + </div>
155 + {Object.keys(q.anomalies).length > 0 && (
156 + <div className="hbars" style={{ marginBottom: 20 }}>
157 + {Object.entries(q.anomalies).slice(0, 8).map(([k, v]) => (
158 + <div className="hbar-row" key={k}>
159 + <span className="hbar-label">{ANOMALIE_LABELS[k] ?? k}</span>
160 + <span className="hbar-track">
161 + <span className="hbar-fill" style={{ width: `${Math.min(100, (v / Math.max(...Object.values(q.anomalies))) * 100)}%` }} />
162 + </span>
163 + <span className="hbar-value">{v.toLocaleString("fr-CA")}</span>
164 + </div>
165 + ))}
166 + </div>
167 + )}
168 + <div className="q-table-wrap">
169 + <table className="q-table">
170 + <thead>
171 + <tr><th>Connecteur</th><th>Annonces</th><th>Publiées</th><th>Complétude</th><th>Anomalies</th></tr>
172 + </thead>
173 + <tbody>
174 + {rows.map((r) => (
175 + <tr key={r.source}>
176 + <td>{r.source}</td>
177 + <td className="num">{r.n.toLocaleString("fr-CA")}</td>
178 + <td className="num">{r.publiees?.toLocaleString("fr-CA") ?? "—"}</td>
179 + <td className="num">
180 + <span className="q-meter"><i style={{ width: `${r.completude ?? 0}%` }} /></span>
181 + {r.completude ?? "—"}
182 + </td>
183 + <td className="num">{r.anomalies.toLocaleString("fr-CA")}</td>
184 + </tr>
185 + ))}
186 + </tbody>
187 + </table>
188 + </div>
189 + </div>
190 + );
191 +}
192 +
96 193 // --- Page ---------------------------------------------------------------------
97 194 export default function StatsPage() {
98 195 const [period, setPeriod] = useState("30j");
@@ -124,9 +221,10 @@ export default function StatsPage() {
124 221 useEffect(() => { load(); }, [load]);
125 222 useEffect(() => { fetchStats().then(setLegacy).catch(() => {}); }, []);
126 223
127 − const lines = dash?.series?.filter((s) => (s.kind ?? "line") === "line") ?? [];
224 + const evol = dash?.series ?? []; // line | bar | area — LineChart délègue
128 225 const donuts = dash?.breakdowns?.filter((b) => b.kind === "donut") ?? [];
129 226 const barBds = dash?.breakdowns?.filter((b) => b.kind !== "donut") ?? [];
227 + const SUMMARY_IDS = new Set(["actives", "prix_median_nouvelles"]);
130 228
131 229 return (
132 230 <div className="container stats-page" style={{ display: "grid", gap: 22, minWidth: 0 }}>
@@ -178,36 +276,71 @@ export default function StatsPage() {
178 276 />
179 277 </section>
180 278
181 − {/* --- 3. courbes d'évolution ----------------------------------------------- */}
279 + {/* --- 3. jauges : couvertures & qualité publiable ---------------------------- */}
280 + {dash?.gauges?.length ? (
281 + <section aria-label="Couvertures et qualité" style={gridAutoFit(190)}>
282 + {dash.gauges.map((g) => <GaugeCard g={g} key={g.id} />)}
283 + </section>
284 + ) : null}
285 +
286 + {/* --- 4. graphiques d'évolution (courbes, barres, aires + stats) ------------- */}
182 287 {dash && (
183 288 <section aria-label="Évolution" style={gridAutoFit(360)}>
184 − {lines.length
185 − ? lines.map((s) => <LineChart serie={s} key={s.id} />)
289 + {evol.length
290 + ? evol.map((s) => (
291 + <div key={s.id} style={{ minWidth: 0 }}>
292 + <LineChart serie={s} />
293 + {SUMMARY_IDS.has(s.id) && <StatSummary serie={s} />}
294 + </div>
295 + ))
186 296 : <EmptyBlock title="Évolution quotidienne" />}
187 297 </section>
188 298 )}
189 299
190 − {/* --- répartitions : anneau + barres ---------------------------------------- */}
300 + {/* --- multi-courbes : prix médian par type / grande ville --------------------- */}
301 + {dash?.multiseries?.length ? (
302 + <section aria-label="Comparaisons multi-séries" style={gridAutoFit(360)}>
303 + {dash.multiseries.map((ms) => <MultiLineChart ms={ms} key={ms.id} />)}
304 + </section>
305 + ) : null}
306 +
307 + {/* --- barres empilées : composition des ajouts par bannière -------------------- */}
308 + {dash?.stacked?.length ? (
309 + <section aria-label="Composition dans le temps" style={{ display: "grid", gap: 14 }}>
310 + {dash.stacked.map((st) => <StackedBarChart st={st} key={st.id} />)}
311 + </section>
312 + ) : null}
313 +
314 + {/* --- 5. répartitions : anneaux + barres + distributions ----------------------- */}
191 315 {dash && (
192 316 <section aria-label="Répartitions" style={gridAutoFit(340)}>
193 317 {donuts.map((b) => <Donut title={b.title} items={b.items} key={b.id} />)}
194 318 {barBds.map((b) => <BarChart title={b.title} items={b.items} key={b.id} />)}
195 319 </section>
196 320 )}
321 + {dash?.distributions?.length ? (
322 + <section aria-label="Distributions" style={gridAutoFit(340)}>
323 + {dash.distributions.map((d) => <Histogram dist={d} key={d.id} />)}
324 + </section>
325 + ) : null}
197 326
198 − {/* --- 4. répartition géographique ------------------------------------------- */}
327 + {/* --- 6. répartition géographique ---------------------------------------------- */}
199 328 {dash?.geo && <BarChart title={dash.geo.title} items={dash.geo.items} unit="annonces" />}
200 329
201 − {/* --- calendrier de chaleur --------------------------------------------------- */}
330 + {/* --- 7. calendriers : chaleur quotidienne + activité horaire 7×24 -------------- */}
202 331 {dash?.heatmap && <CalendarHeatmap title={dash.heatmap.title} cells={dash.heatmap.cells} />}
332 + {dash?.hourly && <HourHeatmap title={dash.hourly.title} cells={dash.hourly.cells} />}
203 333
204 334 {/* --- spécifique Immo-Ka : croisement Vrai-Prix ------------------------------- */}
205 335 <VraiPrixBlock legacy={legacy} />
206 336
207 − {/* --- 5. tableaux détaillés ----------------------------------------------------- */}
337 + {/* --- qualité des données : complétude, quarantaine, anomalies ---------------- */}
338 + <QualiteBlock legacy={legacy} />
339 +
340 + {/* --- 8. tableaux détaillés ----------------------------------------------------- */}
208 341 {dash?.tables?.map((t) => <DataTable spec={t} key={t.id} />)}
209 342
210 − {/* --- 6. records & faits marquants ----------------------------------------------- */}
343 + {/* --- 9. records & faits marquants ----------------------------------------------- */}
211 344 {dash?.records?.length ? (
212 345 <section aria-label="Records et faits marquants">
213 346 <p className="klabel" style={{ margin: "0 0 8px" }}>Records & faits marquants</p>
modified immoka/connectors/_detailutil.py +2 −1
@@ -25,7 +25,8 @@ def ld_nodes(html: str):
25 25 """Itère les objets JSON-LD (aplatis depuis @graph)."""
26 26 for block in _LD_RE.findall(html):
27 27 try:
28 − data = json.loads(block)
28 + # strict=False : tolère les \n/\t bruts dans les chaînes (Yoamo…)
29 + data = json.loads(block, strict=False)
29 30 except ValueError:
30 31 continue
31 32 graph = data.get("@graph", [data]) if isinstance(data, dict) else data
modified immoka/connectors/agenceimage.py +140 −2
@@ -8,21 +8,34 @@
8 8 # type + ville (dans l'URL et l'alt), coordonnées (data-geo-lat/lng), prix et
9 9 # photo (yoamo.immo/ALSPicture.axd?propId={Centris}).
10 10 #
11 +# Page détail (rel="/proprietes/a-vendre/{type}/{ville}/{centris}/") rendue
12 +# serveur elle aussi : galerie complète (ALSPicture.axd, une URL par seq=N,
13 +# pleine taille sans &w=), description JSON-LD (Product), sections
14 +# <sup>libellé</sup><label>valeur</label> (#building/#land/#more-carac),
15 +# pièces (#rooms), « En complément » (taxes, évaluation, année, terrain),
16 +# inclusions/exclusions, courtier (#broker_list, schema.org/RealEstateAgent)
17 +# et lien Google Maps (adresse + GPS). ⚠ la section #related (« Aussi
18 +# disponibles ») liste d'AUTRES propriétés — tout est parsé AVANT elle.
19 +#
11 20 # source_id « image_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —
12 21 # masque les fiches déjà portées par une bannière couverte au Saguenay–LSJ.
13 22 # -----------------------------------------------------------------------------
14 23 from __future__ import annotations
15 24
16 25 import html as _html
26 +import os
17 27 import re
18 28
19 29 from .base import BaseConnector
20 −from ..normalize import parse_price
30 +from . import _detailutil as du
31 +from ..normalize import parse_lot_sqft
21 32 from ..schema import PropertyListing
22 33
23 34 SITE = "https://agenceimage.ca"
24 35 LISTING = SITE + "/mes-proprietes/"
25 36 AGENCY = "Image, l'agence immobilière"
37 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_IMAGE_DETAIL_LIMIT",
38 + os.environ.get("IMMOKA_DETAIL_LIMIT", "100")))
26 39
27 40 # chaque fiche = un <article ... lid="{Centris}"> dont les data-filter-* portent
28 41 # tout (prix, ville, genre, chambres, sdb, coords) — plus fiable que le HTML visible.
@@ -31,6 +44,11 @@ _LID_RE = re.compile(r'l(?:id|s)="(\d{6,9})"', re.I)
31 44 _REL_RE = re.compile(r'rel="(/proprietes/a-vendre/[a-z0-9-]+/[a-z0-9-]+/\d{6,9}/)"', re.I)
32 45 _IMG_RE = re.compile(r'(https://yoamo\.immo/ALSPicture\.axd\?propId=\d+[^"\']*)', re.I)
33 46
47 +# --- page détail -------------------------------------------------------------
48 +_DET_URL_RE = re.compile(r'https://yoamo\.immo/ALSPicture\.axd\?[^"\'\s<>]+', re.I)
49 +_SUP_LABEL_RE = re.compile(r'<sup>([^<]+)</sup>\s*<label>\s*([^<]*?)\s*</label>', re.I)
50 +_MAPS_RE = re.compile(r'google\.[^"\']*?[?&]q=([^@"\']+)@(-?\d{1,2}\.\d+),(-?\d{2,3}\.\d+)')
51 +
34 52
35 53 def _attr(blk: str, name: str) -> str:
36 54 m = re.search(name + r'="([^"]*)"', blk, re.I)
@@ -41,6 +59,121 @@ def _deslug(s: str) -> str:
41 59 return _html.unescape(s.replace("-", " ")).strip().title()
42 60
43 61
62 +def _section(html: str, sid: str) -> str:
63 + m = re.search(r'<section id="%s".*?</section>' % re.escape(sid), html, re.S | re.I)
64 + return m.group(0) if m else ""
65 +
66 +
67 +def _text(fragment: str) -> str:
68 + return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
69 +
70 +
71 +def parse_image_detail(html: str) -> dict:
72 + """Fiche Yoamo/agenceimage.ca : galerie complète (ordre seq=), description
73 + JSON-LD, caractéristiques <sup>/<label>, pièces, taxes/évaluation/année,
74 + inclusions, GPS + adresse (lien Google Maps), courtier + téléphone."""
75 + out: dict = {}
76 + # tout ce qui suit #related appartient à D'AUTRES propriétés
77 + main = re.split(r'<section id="related"', html, 1)[0]
78 +
79 + # galerie : une URL ALSPicture par seq=N ; on regroupe par propId et on
80 + # garde le groupe majoritaire (la fiche elle-même), trié par seq d'origine
81 + groups: dict[str, dict[int, str]] = {}
82 + for m in _DET_URL_RE.finditer(main):
83 + u = _html.unescape(m.group(0))
84 + pid = re.search(r'propId=(\d+)', u)
85 + seq = re.search(r'[?&]seq=(\d+)', u)
86 + if pid and seq:
87 + groups.setdefault(pid.group(1), {}).setdefault(int(seq.group(1)), u)
88 + if groups:
89 + best = max(groups.values(), key=len)
90 + out["images"] = [best[s] for s in sorted(best)]
91 +
92 + desc = du.ld_description(html)
93 + if desc:
94 + out["description"] = desc[:4000]
95 +
96 + details: dict = {}
97 + # sections « libellé / valeur » (Bâtiment et intérieur, Terrain et
98 + # extérieur, Autres caractéristiques)
99 + for sid in ("building", "land", "more-carac"):
100 + for lab, val in _SUP_LABEL_RE.findall(_section(main, sid)):
101 + lab, val = _html.unescape(lab).strip(), _html.unescape(val).strip()
102 + if lab and val:
103 + details[lab] = val
104 +
105 + # pièces (#rooms) : nom + dimensions + étage + plancher
106 + rooms = []
107 + for chunk in re.split(r'class="row-item"', _section(main, "rooms"))[1:]:
108 + nm = re.search(r'<label>([^<]+)</label>\s*<sub>([^<]*)</sub>', chunk)
109 + if not nm or not nm.group(1).strip():
110 + continue
111 + room = {"nom": _html.unescape(nm.group(1)).strip()[:40]}
112 + dims = _html.unescape(nm.group(2)).strip()
113 + if dims:
114 + room["dimensions"] = dims
115 + for lab, key in (("Étage", "niveau"), ("Plancher", "revetement")):
116 + mv = re.search(r'<sup>%s</sup>\s*<label>([^<]*)</label>' % lab, chunk)
117 + if mv and mv.group(1).strip():
118 + room[key] = _html.unescape(mv.group(1)).strip()
119 + rooms.append(room)
120 + if rooms:
121 + details["pieces"] = rooms[:25]
122 +
123 + # « En complément » : taxes, évaluation municipale, année, terrain
124 + text = _text(main)
125 + for pat, key in ((r"Municipale:\s*([\d\s ]+\$)", "Taxes municipales"),
126 + (r"Scolaire:\s*([\d\s ]+\$)", "Taxes scolaires"),
127 + (r"Évaluation municipale\s*([\d\s ]+\$)", "Évaluation municipale")):
128 + mm = re.search(pat, text)
129 + if mm:
130 + details[key] = re.sub(r"\s+", " ", mm.group(1)).strip()
131 + my = re.search(r"Construit en ((?:1[6-9]|20)\d{2})\b", text)
132 + if my:
133 + out["year_built"] = int(my.group(1))
134 + mt = re.search(r"Terrain de ([\d\s .,]+?)\s*m<sup>2</sup>", main)
135 + if mt:
136 + lot = parse_lot_sqft(mt.group(1).strip() + " m²")
137 + if lot:
138 + out["lot_sqft"] = lot
139 +
140 + # inclusions -> features ; exclusions -> details
141 + inc = _text(_section(main, "inclusive"))
142 + inc = re.sub(r"^Inclusions\s*", "", inc, flags=re.I)
143 + feats = [s.strip() for s in re.split(r"[,;]", inc) if 2 <= len(s.strip()) <= 90]
144 + if feats:
145 + out["features"] = feats[:20]
146 + exc = re.sub(r"^Exclusions\s*", "", _text(_section(main, "exclusive")), flags=re.I)
147 + if exc:
148 + details["Exclusions"] = exc[:300]
149 +
150 + # adresse + GPS depuis le lien « Ouvrir la carte »
151 + mg = _MAPS_RE.search(main)
152 + if mg:
153 + addr = _html.unescape(mg.group(1)).split(",")[0].strip()
154 + if addr:
155 + out["address"] = addr
156 + try:
157 + lat, lng = float(mg.group(2)), float(mg.group(3))
158 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
159 + out["lat"], out["lng"] = lat, lng
160 + except ValueError:
161 + pass
162 +
163 + # courtier inscripteur (« Présenté par », 1er de la liste) + téléphone
164 + broker = _section(main, "broker_list")
165 + mb = re.search(r'itemprop="name" content="([^"]+)"', broker)
166 + if mb:
167 + out["broker_name"] = _html.unescape(mb.group(1)).strip()
168 + mp = re.search(r'href="tel:\+?\d+">\s*([\d\s().-]{10,20})\s*<', broker)
169 + if mp:
170 + out["broker_phone"] = mp.group(1).strip()
171 +
172 + if details:
173 + out["details"] = details
174 + return out
175 +
176 +
44 177 class AgenceImageConnector(BaseConnector):
45 178 source_id = "image_ag_qc"
46 179 request_delay = 0.5
@@ -56,7 +189,12 @@ class AgenceImageConnector(BaseConnector):
56 189 lst = self._card(blk)
57 190 if lst:
58 191 by_id.setdefault(lst.external_id, lst)
59 − return list(by_id.values())
192 + listings = list(by_id.values())
193 + # fiche détail : galerie complète, description, caractéristiques,
194 + # pièces, adresse, courtier — plafonné/cycle, cache BD (voir _detailutil)
195 + du.enrich(self, [l for l in listings if l.external_id in l.url],
196 + DETAIL_LIMIT, parse_image_detail, key="v1")
197 + return listings
60 198
61 199 def _card(self, blk: str) -> PropertyListing | None:
62 200 lm = _LID_RE.search(blk)
modified immoka/connectors/avecuncourtier.py +190 −3
@@ -9,6 +9,14 @@
9 9 # pages). Chaque carte porte déjà : n° MLS (Centris), adresse, ville/secteur,
10 10 # type, aire habitable, prix et photo.
11 11 #
12 +# ENRICHISSEMENT DÉTAIL (v2) : /fr/proprietes/details/{mls}-{slug} est rendue
13 +# serveur : galerie complète (/centris/photos/{mls}/{n}.jpg, ordre d'origine),
14 +# description + Addendas, GPS (data-lat/data-lon), accordéons « Proximité »,
15 +# « Dimension des pièces » (4 col.), « Taxes et dépenses » + « Bâtiment et
16 +# intérieur » (paires td/td : chambres, sdb, aire habitable en m², terrain,
17 +# année, évaluations…), visites libres, courtier(s) inscripteur(s) + agence
18 +# réelle (RE/MAX Vision, Exit…). Cache BD via _detailutil.enrich.
19 +#
12 20 # source_id « cio_ag_outaouais » : l'infixe _ag_ active la dédup Centris
13 21 # (db.refresh_dedup, sources %_ag_%). Comme le portail agrège les inscriptions
14 22 # des bannières déjà couvertes (RE/MAX, Royal LePage, Via Capitale, Proprio
@@ -23,6 +31,7 @@ import os
23 31 import re
24 32
25 33 from .base import BaseConnector
34 +from . import _detailutil as du
26 35 from ..normalize import parse_price
27 36 from ..schema import PropertyListing
28 37
@@ -30,6 +39,10 @@ SITE = "https://avecuncourtier.com"
30 39 LISTING = SITE + "/fr/proprietes"
31 40 MAX_PAGES = int(os.environ.get("IMMOKA_AUC_MAX_PAGES", "400"))
32 41 AGENCY = "Chambre immobilière de l'Outaouais"
42 +# fiches détail (re)lues au plus par cycle — cache BD (key v2). ~1 400 fiches
43 +# publiées → ~5 cycles de rattrapage, puis seules les nouvelles sont lues.
44 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_AUC_DETAIL_LIMIT",
45 + os.environ.get("IMMOKA_DETAIL_LIMIT", "300")))
33 46
34 47 # une carte commence par <div class="photo "> et court jusqu'à la fermeture du
35 48 # bloc listing suivant ; on la borne au <div class="photo "> suivant.
@@ -38,7 +51,7 @@ _HREF_RE = re.compile(r'/fr/proprietes/details/(\d{6,9})-([a-z0-9-]+)', re.I)
38 51 _ALT_RE = re.compile(r'<img[^>]+alt="([^"]*)"', re.I)
39 52 _IMG_RE = re.compile(r'<img[^>]+src="([^"]+)"', re.I)
40 53 _MLS_RE = re.compile(r'MLS[^0-9]{0,3}(\d{6,9})', re.I)
41 −_PRICE_RE = re.compile(r'class="price">\s*([\d\s ,.]+\$[^<]*)<', re.I)
54 +_PRICE_RE = re.compile(r'class="price">\s*([\d\s ,.]+\$[^<]*)<', re.I)
42 55 _ADDR_RE = re.compile(r'<address>\s*<span>([^<]*)</span>\s*(?:<br\s*/?>)?\s*([^<]*)</address>', re.S | re.I)
43 56 _TYPE_RE = re.compile(r'header">\s*Type\s*</div>\s*<div class="column">\s*([^<]+?)\s*</div>', re.S | re.I)
44 57
@@ -67,7 +80,15 @@ class AvecUnCourtierConnector(BaseConnector):
67 80 dry = dry + 1 if len(by_id) == before else 0
68 81 if dry >= 2:
69 82 break
70 − return list(by_id.values())
83 + listings = list(by_id.values())
84 + # fiche détail (server-rendered) : galerie complète, description +
85 + # addendas, GPS, pièces, taxes/évaluations, courtier + agence réelle.
86 + du.enrich(self, listings, DETAIL_LIMIT, _parse_auc_detail, key="v2")
87 + for lst in listings:
88 + office = lst.details.pop("__agency", "")
89 + if office:
90 + lst.agency = office
91 + return listings
71 92
72 93 def _card(self, blk: str) -> PropertyListing | None:
73 94 hm = _HREF_RE.search(blk)
@@ -85,7 +106,7 @@ class AvecUnCourtierConnector(BaseConnector):
85 106 addr = _html.unescape(alt.group(1)).strip() if alt else ""
86 107
87 108 pm = _PRICE_RE.search(blk)
88 − price_label = _html.unescape(pm.group(1)).replace(" ", " ").strip() if pm else ""
109 + price_label = _html.unescape(pm.group(1)).replace(" ", " ").strip() if pm else ""
89 110 tm = _TYPE_RE.search(blk)
90 111 prop_type = _html.unescape(tm.group(1)).strip() if tm else ""
91 112 im = _IMG_RE.search(blk)
@@ -107,3 +128,169 @@ class AvecUnCourtierConnector(BaseConnector):
107 128 images=[img] if img else [],
108 129 agency=AGENCY,
109 130 )
131 +
132 +
133 +# --- fiche détail (Concrete5 / Tonik, server-rendered) ------------------------
134 +_GAL_RE = re.compile(r'data-lazy="(/centris/photos/\d+/[^"]+)"')
135 +_COORD_RE = re.compile(r'data-lat="(-?\d{1,2}\.\d+)"\s+data-lon="(-?\d{2,3}\.\d+)"')
136 +_DESC_RE = re.compile(r'class="property-description">\s*<h2>[^<]*</h2>(.*?)</div>', re.S)
137 +_OPEN_RE = re.compile(r'class="open-houses">\s*<h2>[^<]*</h2>(.*?)</div>', re.S)
138 +_ITEM_TITLE_RE = re.compile(r'>\s*([^<]{3,60}?)\s*<i class="fa fa-angle-down')
139 +_PAIR_RE = re.compile(r'<tr>\s*<td>([^<]{1,70}?)</td>\s*<td>([^<]*?)</td>\s*</tr>', re.S)
140 +_ROOM_RE = re.compile(r'<tr>\s*<td>([^<]*)</td>\s*<td>([^<]*)</td>\s*'
141 + r'<td>([^<]*)</td>\s*<td>([^<]*)</td>\s*</tr>', re.S)
142 +_ADD_RE = re.compile(r'<td>([^<]{40,})</td>', re.S)
143 +_PROX_RE = re.compile(r'<p>(.*?)</p>', re.S)
144 +_AGENT_NAME_RE = re.compile(r'class="name">([^<]+)<')
145 +_AGENT_TEL_RE = re.compile(r'href="tel:[^"]*"[^>]*>([^<]+)<')
146 +# <div class="agency">NOM INC.<div>RE/MAX VISION</div><div>Agence Immobilière</div>
147 +_AGENT_BANNER_RE = re.compile(r'class="agency">[^<]*<div>([^<]+)</div>', re.S)
148 +_TOUR_RE = re.compile(r'https?://(?:my\.matterport\.com/show/[^"\'\s<>]+'
149 + r'|(?:www\.)?youtube\.com/(?:embed/|watch\?v=)[^"\'\s<>]+'
150 + r'|youtu\.be/[^"\'\s<>]+'
151 + r'|(?:player\.)?vimeo\.com/(?:video/)?\d+[^"\'\s<>]*)', re.I)
152 +
153 +# libellés « Bâtiment et intérieur » promus en colonnes du schéma
154 +_INT_FIELDS = {"Chambres": "bedrooms", "Salles de bain": "bathrooms",
155 + "Salles d'eau": "powder_rooms"}
156 +
157 +
158 +def _clean(s: str) -> str:
159 + return re.sub(r'\s+', ' ', _html.unescape(s)).strip()
160 +
161 +
162 +def _to_sqft(val: str) -> float | None:
163 + """« 128.8 Mètres carrés » / « 1 200 Pieds carrés » / « 305.4 » (m² implicite,
164 + unités Centris métriques sur ce portail) -> pi²."""
165 + m = re.search(r'([\d][\d\s ,.]*)', val or "")
166 + if not m:
167 + return None
168 + raw = m.group(1).replace(" ", "").replace(" ", "").replace(" ", "")
169 + if raw.count(",") == 1 and "." not in raw:
170 + raw = raw.replace(",", ".")
171 + else:
172 + raw = raw.replace(",", "")
173 + try:
174 + v = float(raw)
175 + except ValueError:
176 + return None
177 + if v <= 0:
178 + return None
179 + if re.search(r'pied|pi2|pi²|pc\b', val, re.I):
180 + return round(v)
181 + return round(v * 10.7639) # métrique (défaut du portail)
182 +
183 +
184 +def _parse_auc_detail(html: str) -> dict:
185 + """Fiche avecuncourtier.com : galerie, description+addendas, GPS,
186 + accordéons (pièces, taxes, bâtiment), courtiers + agence réelle."""
187 + out: dict = {}
188 + details: dict = {}
189 + feats: list[str] = []
190 +
191 + # galerie : slider principal seulement (avant les vignettes dupliquées)
192 + main = html.split('gallery-slider__thumbnails')[0]
193 + imgs = list(dict.fromkeys(SITE + u for u in _GAL_RE.findall(main)))
194 + if len(imgs) > 1:
195 + out["images"] = imgs[:80]
196 +
197 + mc = _COORD_RE.search(html)
198 + if mc:
199 + lat, lng = float(mc.group(1)), float(mc.group(2))
200 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
201 + out["lat"], out["lng"] = lat, lng
202 +
203 + desc, addendum = "", ""
204 + md = _DESC_RE.search(html)
205 + if md:
206 + desc = _clean(re.sub(r'<[^>]+>', ' ', md.group(1)))
207 + mo = _OPEN_RE.search(html)
208 + if mo:
209 + oh = _clean(re.sub(r'<[^>]+>', ' ', mo.group(1)))
210 + if oh:
211 + details["Visites libres"] = oh[:200]
212 +
213 + # accordéons « Détails »
214 + for item in re.split(r'<div class="accordion-item">', html)[1:]:
215 + tm = _ITEM_TITLE_RE.search(item)
216 + title = _clean(tm.group(1)) if tm else ""
217 + if title == "Proximité":
218 + mp = _PROX_RE.search(item)
219 + if mp:
220 + prox = _clean(re.sub(r'<[^>]+>', ' ', mp.group(1)))
221 + if prox:
222 + details["Proximité"] = prox[:300]
223 + feats.append(f"Proximité : {prox}"[:300])
224 + elif title == "Dimension des pièces":
225 + rooms = _ROOM_RE.findall(item)
226 + for name, lvl, dim, floor in rooms:
227 + name, lvl = _clean(name), _clean(lvl)
228 + dim, floor = _clean(dim), _clean(floor)
229 + line = name + (f" — {dim}" if dim else "") + (f", {lvl}" if lvl else "")
230 + if floor:
231 + line += f" ({floor})"
232 + feats.append(line)
233 + elif title == "Addendas":
234 + ma = _ADD_RE.search(item)
235 + if ma:
236 + # les sauts de ligne du texte Centris sont préservés dans le <td>
237 + addendum = _html.unescape(ma.group(1)).strip()
238 + else: # Taxes et dépenses / Bâtiment…
239 + for label, val in _PAIR_RE.findall(item):
240 + label, val = _clean(label).rstrip(":"), _clean(val)
241 + if not label or not val or val in ("0", "-"):
242 + continue
243 + fld = _INT_FIELDS.get(label)
244 + if fld:
245 + mi = re.search(r'\d+', val)
246 + if mi:
247 + out[fld] = int(mi.group())
248 + elif label == "Aire habitable":
249 + a = _to_sqft(val)
250 + if a:
251 + out["area_sqft"] = a
252 + elif label == "Superficie du terrain":
253 + t = _to_sqft(val)
254 + if t:
255 + out["lot_sqft"] = t
256 + val = f"{val} m²" if re.fullmatch(r'[\d\s,.]+', val) else val
257 + elif label == "Année de construction":
258 + my = re.search(r'\b((?:1[6-9]|20)\d{2})\b', val)
259 + if my:
260 + out["year_built"] = int(my.group(1))
261 + if label in details and val not in details[label]:
262 + details[label] = f"{details[label]}, {val}"[:300]
263 + else:
264 + details[label] = val[:300]
265 +
266 + if desc or addendum:
267 + out["description"] = "\n\n".join(filter(None, (desc, addendum)))[:6000]
268 + if feats:
269 + out["features"] = feats[:40]
270 +
271 + # courtier(s) inscripteur(s) + bannière réelle (encadré agents-wrapper) :
272 + # premier agent = inscripteur principal
273 + i = html.find('class="agents-wrapper"')
274 + if i != -1:
275 + j = html.find('calculator-wrapper', i)
276 + seg = html[i:j if j != -1 else i + 5000]
277 + mn = _AGENT_NAME_RE.search(seg)
278 + if mn:
279 + out["broker_name"] = _clean(mn.group(1))
280 + mt = _AGENT_TEL_RE.search(seg)
281 + if mt:
282 + out["broker_phone"] = _clean(mt.group(1))
283 + mb = _AGENT_BANNER_RE.search(seg)
284 + if mb:
285 + banner = _clean(mb.group(1))
286 + if banner and banner.lower() != "agence immobilière":
287 + details["__agency"] = banner.title() if banner.isupper() else banner
288 +
289 + # visite virtuelle / vidéo (hors liens de pied de page /channel/)
290 + mtou = _TOUR_RE.search(html)
291 + if mtou:
292 + details.setdefault("Visite virtuelle / vidéo", mtou.group(0))
293 +
294 + if details:
295 + out["details"] = details
296 + return out
modified immoka/connectors/barnes_quebec.py +94 −6
@@ -11,6 +11,14 @@
11 11 # RÉSILIENCE : la clé Algolia a déjà été rotée (403 le 2026-08-15) — on
12 12 # l'extrait donc À CHAQUE FOIS du HTML public du site (avec cache process),
13 13 # avec repli sur la dernière clé connue si l'extraction échoue.
14 +# FIABILITÉ (2026-08-18) : l'index Algolia conserve des fiches PÉRIMÉES
15 +# (vendues/retirées de WordPress) dont la page ET les photos renvoient 404
16 +# (~100 fiches sur ~340, soit les « annonces sans images »). Le sitemap
17 +# Yoast (property/rental/new-construction) est la source de vérité de ce
18 +# qui est publié : on filtre les fiches mortes, et on répare les slugs
19 +# renommés via la réf MLS présente dans les noms de fichiers image du
20 +# sitemap. La galerie de la fiche est aussi isolée par sa réf MLS (les
21 +# pages affichent des « propriétés similaires » qui polluaient la galerie).
14 22 # -----------------------------------------------------------------------------
15 23 from __future__ import annotations
16 24
@@ -18,6 +26,8 @@ import os
18 26 import re
19 27 import time
20 28
29 +import requests
30 +
21 31 from .base import BaseConnector
22 32 from . import _detailutil as du
23 33 from ..schema import PropertyListing
@@ -46,6 +56,16 @@ _CREDS_TTL = 6 * 3600 # ré-extraire au plus toutes les 6 h par process
46 56 _creds_cache: tuple[str, str, str] | None = None
47 57 _creds_ts = 0.0
48 58
59 +# Sitemaps Yoast = pages réellement publiées (l'index Algolia, lui, traîne
60 +# des fiches supprimées). Les <image:loc> commencent par la réf MLS.
61 +_SITEMAPS = ("/property-sitemap.xml", "/rental-sitemap.xml",
62 + "/new-construction-sitemap.xml")
63 +_SM_BLOCK_RE = re.compile(r"<url>(.*?)</url>", re.S)
64 +_SM_LOC_RE = re.compile(r"<loc>\s*(https://[^<\s]+)")
65 +_SM_IMG_MLS_RE = re.compile(r"<image:loc>\s*https://[^<\s]*/(\d{6,9})-")
66 +# réf MLS en tête du nom de fichier image (suivie du hash hexadécimal)
67 +_MLS_PFX_RE = re.compile(r"/(\d{6,9})-[0-9a-f]")
68 +
49 69
50 70 class BarnesQuebecConnector(BaseConnector):
51 71 source_id = "barnes_quebec"
@@ -77,9 +97,60 @@ class BarnesQuebecConnector(BaseConnector):
77 97 break
78 98 page += 1
79 99 listings = [lst for _, lst in best.values()]
100 + # Filtre anti-fiches périmées : Algolia garde des enregistrements de
101 + # propriétés retirées du site (page + photos 404). On ne garde que ce
102 + # que le sitemap publie ; slug renommé → réparé via la réf MLS.
103 + live, by_mls = self._live_pages()
104 + if len(live) >= 100: # garde-fou : sitemap indisponible → pas de filtre
105 + kept = []
106 + for lst in listings:
107 + url = lst.url.rstrip("/") + "/"
108 + if url in live:
109 + kept.append(lst)
110 + elif lst.mls and lst.mls in by_mls:
111 + lst.url = by_mls[lst.mls] # republiée sous un nouveau slug
112 + kept.append(lst)
113 + # sinon : fiche morte (vendue/retirée) → non publiée
114 + listings = kept
80 115 # Algolia ne contient pas les photos : on les récupère sur la fiche.
81 − du.enrich(self, listings, DETAIL_LIMIT, parse_barnes_detail, key="v2")
82 − return listings
116 + # Une page peut être 404 même listée au sitemap (retrait très récent,
117 + # cache Yoast) : on l'écarte du résultat de ce cycle.
118 + dead: set[str] = set()
119 +
120 + def _fetch(url: str) -> str:
121 + try:
122 + return self.get(url).text
123 + except requests.HTTPError as e:
124 + if e.response is not None and e.response.status_code in (404, 410):
125 + dead.add(url)
126 + raise
127 +
128 + du.enrich(self, listings, DETAIL_LIMIT, parse_barnes_detail, key="v3",
129 + fetch_html=_fetch)
130 + return [lst for lst in listings if lst.url not in dead]
131 +
132 + # -- pages réellement publiées (sitemaps Yoast) -----------------------------
133 + def _live_pages(self) -> tuple[set[str], dict[str, str]]:
134 + """(URLs vivantes, réf MLS -> URL FR) depuis les sitemaps du site."""
135 + live: set[str] = set()
136 + by_mls: dict[str, str] = {}
137 + for path in _SITEMAPS:
138 + try:
139 + xml = self.get(SITE + path).text
140 + except Exception:
141 + continue
142 + for block in _SM_BLOCK_RE.findall(xml):
143 + m = _SM_LOC_RE.search(block)
144 + if not m:
145 + continue
146 + loc = m.group(1).rstrip("/") + "/"
147 + live.add(loc)
148 + for mls in _SM_IMG_MLS_RE.findall(block):
149 + if "/en/" not in loc:
150 + by_mls[mls] = loc # la version FR gagne
151 + else:
152 + by_mls.setdefault(mls, loc)
153 + return live, by_mls
83 154
84 155 # -- identifiants Algolia (auto-extraits du site, avec cache) --------------
85 156 def _algolia_creds(self, force: bool = False) -> tuple[str, str, str]:
@@ -189,7 +260,10 @@ def _pos(v):
189 260 def parse_barnes_detail(html: str) -> dict:
190 261 """Galerie photo pleine résolution (absente d'Algolia) + description/pièces."""
191 262 out: dict = {}
192 − # regroupe par image de base (sans le suffixe -LxH), garde la plus grande
263 + if re.search(r'class="[^"]*\berror404\b', html):
264 + return out # page « introuvable » : fiche retirée, ne rien capter
265 + # regroupe par image de base (sans le suffixe -LxH), garde la plus grande ;
266 + # l'ordre d'insertion du dict = ordre d'apparition dans la page (= galerie)
193 267 best: dict[str, tuple[int, str]] = {}
194 268 for u in _IMG_RE.findall(html):
195 269 m = _SIZE_RE.search(u)
@@ -197,9 +271,23 @@ def parse_barnes_detail(html: str) -> dict:
197 271 base = _SIZE_RE.sub("", u)
198 272 if base not in best or area > best[base][0]:
199 273 best[base] = (area, u)
200 − imgs = [u for _, u in best.values()]
201 − # ignore les vignettes de courtiers/logos (gardent souvent une réf MLS chiffrée)
202 − imgs = [u for u in imgs if re.search(r"/\d{6,}", u)] or imgs
274 + # la galerie de la fiche = les fichiers préfixés par SA réf MLS ; la page
275 + # affiche aussi des « propriétés similaires » (autres réfs MLS) et des
276 + # visuels éditoriaux → on garde le préfixe numérique dominant (nb de bases
277 + # distinctes), départagé par ordre d'apparition (le héros vient en premier)
278 + counts: dict[str, int] = {}
279 + first: dict[str, int] = {}
280 + for i, base in enumerate(best):
281 + m = _MLS_PFX_RE.search(base)
282 + if m:
283 + counts[m.group(1)] = counts.get(m.group(1), 0) + 1
284 + first.setdefault(m.group(1), i)
285 + if counts:
286 + own = max(counts, key=lambda p: (counts[p], -first[p]))
287 + imgs = [u for base, (_, u) in best.items()
288 + if (m := _MLS_PFX_RE.search(base)) and m.group(1) == own]
289 + else: # aucune image nommée par réf MLS : comportement d'origine
290 + imgs = [u for _, u in best.values()]
203 291 if imgs:
204 292 out["images"] = imgs[:60]
205 293 desc = du.ld_description(html)
modified immoka/connectors/bhhs_quebec.py +127 −1
@@ -8,20 +8,32 @@
8 8 # ~11 pages). Chaque carte porte : n° Centris (URL + image + property-meta),
9 9 # région, adresse, ville, prix ($US), chambres/sdb et photo.
10 10 #
11 +# Page détail : description + addenda (<div class="description block">,
12 +# collapse #showAddendum), bloc « features » (Année de construction, pièces,
13 +# chambres, sdb… en h5/p), tableau « Caractéristiques » (#charateristics —
14 +# sic — th/td), GPS inline (google.maps.LatLng(lat,lng)), visite virtuelle
15 +# (iframe #virtualCollapse), courtier (.agents : nom + tel:) et galerie
16 +# complète property-images/{Centris}/{Centris}-NN.jpg (pleine résolution,
17 +# ordre d'origine ; les miniatures /thumbs-320/ sont ignorées).
18 +# Enrichissement plafonné + cache (du.enrich).
19 +#
11 20 # source_id « bhhs_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup).
12 21 # -----------------------------------------------------------------------------
13 22 from __future__ import annotations
14 23
15 24 import html as _html
25 +import os
16 26 import re
17 27 import urllib.parse
18 28
19 29 from .base import BaseConnector
30 +from . import _detailutil as du
20 31 from ..normalize import parse_price
21 32 from ..schema import PropertyListing
22 33
23 34 SITE = "https://bhhsquebec.ca"
24 35 LISTING = SITE + "/proprietes/"
36 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_BHHS_DETAIL_LIMIT", "150"))
25 37 AGENCY = "Berkshire Hathaway HomeServices Québec"
26 38
27 39 _CARD_SPLIT = re.compile(r'<a href="(https://bhhsquebec\.ca/properties/[^"]*?/(\d{6,9}))"\s+class="list"', re.I)
@@ -31,6 +43,26 @@ _ADDR_RE = re.compile(r'<div class="top-ctn">.*?<p>([^<]+)</p>', re.S | re.I)
31 43 _PRICE_RE = re.compile(r'class="price">\s*(\$[\d,]+|[\d\s ]+\$)', re.I)
32 44 _AMEN_RE = re.compile(r'(?:bed|hotel)[^>]*></i>\s*(\d+).*?(?:bath|shower)[^>]*></i>\s*(\d+)', re.S | re.I)
33 45
46 +# --- page détail ---
47 +_GALLERY_RE = re.compile(
48 + r'https://realestate\.marketingwebsites\.ca/property-images/\d+/\d+-\d+'
49 + r'\.(?:jpg|jpeg|png|webp)', re.I)
50 +_P_RE = re.compile(r'<p[^>]*>(.*?)</p>', re.S | re.I)
51 +_CHAR_ROW_RE = re.compile(r'<th[^>]*>(.*?)</th>\s*<td[^>]*>(.*?)</td>', re.S | re.I)
52 +_FEAT_RE = re.compile(r'<h5 class="title">([^<]+)</h5>\s*<p>([^<]*)</p>', re.I)
53 +_LATLNG_RE = re.compile(r'google\.maps\.LatLng\(\s*(-?\d{1,2}\.\d+)\s*,\s*(-?\d{2,3}\.\d+)\s*\)')
54 +_AGENT_RE = re.compile(r'class="agents">.*?<h3 class="name">([^<]+)</h3>(.*?)</ul>', re.S | re.I)
55 +_TEL_RE = re.compile(r'href="tel:(\+?\d{7,11})"')
56 +_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.I)
57 +
58 +# libellés du bloc « features » promus en colonnes ; le reste va dans details
59 +_FEAT_FIELDS = {
60 + "Année de construction": "year_built",
61 + "Chambre(s) à coucher": "bedrooms",
62 + "Salle(s) de bain": "bathrooms",
63 + "Salle(s) d'eau": "powder_rooms",
64 +}
65 +
34 66
35 67 class BhhsQuebecConnector(BaseConnector):
36 68 source_id = "bhhs_ag_qc"
@@ -57,7 +89,9 @@ class BhhsQuebecConnector(BaseConnector):
57 89 dry = dry + 1 if len(by_id) == before else 0
58 90 if dry >= 2:
59 91 break
60 − return list(by_id.values())
92 + listings = list(by_id.values())
93 + du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1")
94 + return listings
61 95
62 96 def _card(self, url_p: str, mls: str, blk: str) -> PropertyListing | None:
63 97 addr, city = "", ""
@@ -99,3 +133,95 @@ class BhhsQuebecConnector(BaseConnector):
99 133 agency=AGENCY,
100 134 broker_name=AGENCY,
101 135 )
136 +
137 +
138 +def _txt(fragment: str) -> str:
139 + t = _html.unescape(re.sub(r"<br\s*/?>", "\n", fragment, flags=re.I))
140 + t = re.sub(r"<[^>]+>", " ", t).replace("\xa0", " ")
141 + t = re.sub(r"[ \t]+", " ", t)
142 + return re.sub(r"\n{3,}", "\n\n", t).strip()
143 +
144 +
145 +def _parse_detail(html: str) -> dict:
146 + """Fiche détail BHHS (MW Properties) : description+addenda, features,
147 + caractéristiques, GPS, visite virtuelle, courtier, galerie complète."""
148 + out: dict = {}
149 +
150 + # description : <p> du bloc description + addenda (collapse #showAddendum)
151 + i = html.find('class="description block')
152 + if i >= 0:
153 + j = html.find('id="addendum-btn"', i)
154 + if j < 0:
155 + j = html.find('class="char block"', i)
156 + seg = html[i:j if j > 0 else i + 30000]
157 + paras = [_txt(p) for p in _P_RE.findall(seg)]
158 + desc = "\n\n".join(p for p in paras if p)
159 + if desc:
160 + out["description"] = desc
161 +
162 + details: dict = {}
163 + # bloc « features » (année, pièces, chambres, sdb…)
164 + for lab, val in _FEAT_RE.findall(html):
165 + lab, val = _txt(lab), _txt(val)
166 + if not val:
167 + continue
168 + field = _FEAT_FIELDS.get(lab)
169 + if field:
170 + m = re.search(r"\d+", val)
171 + if m:
172 + out[field] = int(m.group())
173 + elif len(val) <= 120:
174 + details[lab] = val
175 +
176 + # tableau « Caractéristiques » (th/td)
177 + i = html.find('id="charateristics"')
178 + if i >= 0:
179 + seg = html[i:html.find("</table>", i)]
180 + for th, td in _CHAR_ROW_RE.findall(seg):
181 + lab, val = _txt(th), _txt(td)
182 + if lab and val and len(lab) <= 60 and len(val) <= 250:
183 + details[lab] = val
184 +
185 + # GPS inline
186 + m = _LATLNG_RE.search(html)
187 + if m:
188 + lat, lng = float(m.group(1)), float(m.group(2))
189 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
190 + out["lat"], out["lng"] = lat, lng
191 +
192 + # visite virtuelle (iframe du collapse dédié)
193 + i = html.find('id="virtualCollapse"')
194 + if i >= 0:
195 + m = _IFRAME_RE.search(html[i:i + 3000])
196 + if m and m.group(1).startswith("http"):
197 + details["Visite virtuelle"] = m.group(1)
198 +
199 + # courtier inscripteur (carte .agents)
200 + m = _AGENT_RE.search(html)
201 + if m:
202 + out["broker_name"] = _txt(m.group(1))
203 + t = _TEL_RE.search(m.group(2))
204 + if t:
205 + tel = t.group(1).lstrip("+")
206 + if len(tel) == 10:
207 + tel = f"({tel[:3]}) {tel[3:6]}-{tel[6:]}"
208 + out["broker_phone"] = tel
209 +
210 + if details:
211 + out["details"] = details
212 +
213 + # galerie pleine résolution (sans /thumbs-320/), ordre d'origine
214 + seen, uniq = set(), []
215 + for u in _GALLERY_RE.findall(html):
216 + if u not in seen:
217 + seen.add(u)
218 + uniq.append(u)
219 + if not uniq:
220 + # toutes les fiches BHHS ont une galerie (flux Centris) ; une page sans
221 + # galerie = rendu transitoirement dégradé (l'API images du plugin MW a
222 + # flanché) → échec de parse : on ne met PAS en cache un payload sans
223 + # photos, la fiche sera réessayée au prochain cycle (du.enrich garde
224 + # l'éventuel payload périmé en attendant)
225 + return {}
226 + out["images"] = uniq
227 + return out
modified immoka/connectors/charisma.py +192 −1
@@ -8,6 +8,15 @@
8 8 # ~84 pages). Chaque carte (box-house) porte : n° Centris (URL + image), prix,
9 9 # ville, chambres/sdb et photo (property-images/{Centris}/).
10 10 #
11 +# ENRICHISSEMENT DÉTAIL (v2) : la page /fr/properties/{adresse}/{centris}/ est
12 +# rendue serveur elle aussi et porte TOUT : galerie fancybox complète (toutes
13 +# les photos property-images pleine taille, ordre d'origine), blocs
14 +# Description / Inclusions / Exclusions / Plus d'information, box-icons
15 +# (sdb, salles d'eau, année, chambres, superficie « 58.9 MC »), tableaux
16 +# th.prop-table/td (Bâtiment + Caractéristiques), Détails de pièce, courtier
17 +# inscripteur (seller-info : nom + tel), coordonnées GPS (google.maps.LatLng).
18 +# Cache BD via _detailutil.enrich (seules les fiches nouvelles sont relues).
19 +#
11 20 # ⚠ DÉDUP : Charisma a fusionné avec L'Expert Immobilier P.M. (déjà couvert)
12 21 # en 2021 → beaucoup de co-inscriptions. source_id « charisma_ag_qc » : l'infixe
13 22 # _ag_ (db.refresh_dedup) masque les fiches dont le n° Centris est déjà porté
@@ -16,10 +25,12 @@
16 25 from __future__ import annotations
17 26
18 27 import html as _html
28 +import os
19 29 import re
20 30 import urllib.parse
21 31
22 32 from .base import BaseConnector
33 +from . import _detailutil as du
23 34 from ..normalize import parse_price
24 35 from ..schema import PropertyListing
25 36
@@ -27,6 +38,10 @@ SITE = "https://charisma.ca"
27 38 LISTING = SITE + "/fr/proprietes/"
28 39 AGENCY = "Les Immeubles Charisma"
29 40 IMG_ROOT = "https://realestate.marketingwebsites.ca/property-images"
41 +# fiches détail (re)lues au plus par cycle — le cache BD (key v2) rend les
42 +# cycles suivants quasi gratuits (~368 fiches publiées → 2 cycles de rattrapage)
43 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_CHARISMA_DETAIL_LIMIT",
44 + os.environ.get("IMMOKA_DETAIL_LIMIT", "200")))
30 45
31 46 _CARD_SPLIT = re.compile(r'<div class="box-house[^"]*">')
32 47 _HREF_RE = re.compile(r'href="(https://charisma\.ca/fr/properties/[^"]*?/(\d{6,9}))"', re.I)
@@ -63,7 +78,11 @@ class CharismaConnector(BaseConnector):
63 78 dry = dry + 1 if len(by_id) == before else 0
64 79 if dry >= 2:
65 80 break
66 − return list(by_id.values())
81 + listings = list(by_id.values())
82 + # fiche détail (server-rendered) : galerie complète, description +
83 + # addendum, caractéristiques, pièces, courtier, GPS — cache BD.
84 + du.enrich(self, listings, DETAIL_LIMIT, _parse_charisma_detail, key="v2")
85 + return listings
67 86
68 87 def _card(self, blk: str) -> PropertyListing | None:
69 88 hm = _HREF_RE.search(blk)
@@ -113,3 +132,175 @@ class CharismaConnector(BaseConnector):
113 132 agency=AGENCY,
114 133 broker_name=AGENCY,
115 134 )
135 +
136 +
137 +# --- fiche détail (MW Properties, server-rendered) ---------------------------
138 +_GAL_RE = re.compile(r'data-fancybox="gallery"\s+href="(https://realestate\.'
139 + r'marketingwebsites\.ca/property-images/[^"]+)"', re.I)
140 +# box-icons du sommaire : « <div class=text-4…>Libellé:</div><div class=text-1…>Valeur</div> »
141 +_FACT_RE = re.compile(r'text-4 text-color-default">\s*([^<]+?)\s*</div>\s*'
142 + r'<div class="text-1 text-color-heading">\s*([^<]+?)\s*</div>', re.S)
143 +# sections texte : « <div class="wg-title …">Description</div> <p>…</p> »
144 +_SEC_RE = re.compile(r'wg-title[^"]*">\s*(Description|Inclusions|Exclusions|'
145 + r'Plus d.information)\s*</div>\s*<p>(.*?)</p>', re.S | re.I)
146 +# tableaux Bâtiment / Caractéristiques : « <tr><th class=prop-table>L</th><td>V</td></tr> »
147 +# (le tableau des pièces a 4 <th> consécutifs → jamais capté par cette forme)
148 +_ROW_RE = re.compile(r'<tr>\s*<th class="prop-table"[^>]*>\s*([^<]+?)\s*</th>\s*'
149 + r'<td[^>]*>\s*(.*?)\s*</td>\s*</tr>', re.S)
150 +_ROOM_RE = re.compile(r'<td data-title="Room">([^<]*)</td>\s*'
151 + r'<td data-title="Dimensions">([^<]*)</td>\s*'
152 + r'<td data-title="Level">([^<]*)</td>\s*'
153 + r'<td data-title="Flooring">([^<]*)</td>', re.S)
154 +_BROKER_RE = re.compile(r'class="seller-info"(.*?)</ul>', re.S)
155 +_NAME_RE = re.compile(r'<h6 class="name">([^<]+)</h6>')
156 +_DESIG_RE = re.compile(r'class="designation[^"]*">([^<]+)<')
157 +_TEL_RE = re.compile(r'href="tel:[^"]*"[^>]*>([^<]+)<')
158 +_COORD_RE = re.compile(r'LatLng\((-?\d{1,2}\.\d+),\s*(-?\d{2,3}\.\d+)\)')
159 +_TOUR_RE = re.compile(r'https?://(?:my\.matterport\.com/show/[^"\'\s<>]+'
160 + r'|(?:www\.)?youtube\.com/(?:embed/|watch\?v=)[^"\'\s<>]+'
161 + r'|youtu\.be/[^"\'\s<>]+'
162 + r'|(?:player\.)?vimeo\.com/(?:video/)?\d+[^"\'\s<>]*)', re.I)
163 +_ZEROS = {"", "0", "0x0", "0 x 0", "n/a", "-"}
164 +
165 +
166 +def _text(fragment: str) -> str:
167 + """Fragment HTML -> texte propre (les <br> deviennent des sauts de ligne)."""
168 + t = re.sub(r'<br\s*/?>', '\n', fragment)
169 + t = re.sub(r'<[^>]+>', ' ', t)
170 + t = _html.unescape(t)
171 + t = re.sub(r'[ \t]+', ' ', t)
172 + return "\n".join(line.strip() for line in t.splitlines()).strip()
173 +
174 +
175 +def _num(raw: str) -> float | None:
176 + v = raw.replace(" ", "").replace(" ", "").replace(" ", "")
177 + if v.count(",") == 1 and "." not in v:
178 + v = v.replace(",", ".") # décimale française « 58,9 »
179 + else:
180 + v = v.replace(",", "") # milliers « 1,200 »
181 + try:
182 + return float(v) or None
183 + except ValueError:
184 + return None
185 +
186 +
187 +def _sqft(val: str) -> float | None:
188 + """« 58.9 MC » / « 1200 PC » -> pi² (unité obligatoire, 0 rejeté)."""
189 + m = re.search(r'([\d][\d\s ,.]*)\s*(MC|M2|M²|PC|PI2|PI²)\b', val or "", re.I)
190 + if not m:
191 + return None
192 + v = _num(m.group(1))
193 + if not v:
194 + return None
195 + return round(v * 10.7639) if m.group(2).upper() in ("MC", "M2", "M²") else round(v)
196 +
197 +
198 +def _parse_charisma_detail(html: str) -> dict:
199 + """Fiche MW Properties : galerie, description+addendum, box-icons,
200 + tableaux th/td, pièces, courtier, GPS, visite virtuelle."""
201 + out: dict = {}
202 + details: dict = {}
203 +
204 + # galerie fancybox pleine taille, ordre du document
205 + imgs = list(dict.fromkeys(_GAL_RE.findall(html)))
206 + if len(imgs) > 1:
207 + out["images"] = imgs[:80]
208 +
209 + # sommaire (box-icons)
210 + for label, val in _FACT_RE.findall(html):
211 + label = _html.unescape(label).strip().rstrip(":").strip()
212 + val = _html.unescape(val).strip()
213 + if val.lower() in _ZEROS:
214 + continue
215 + if label == "Salles de bain" and val.isdigit():
216 + out["bathrooms"] = int(val)
217 + elif label == "Salles d'eau" and val.isdigit():
218 + out["powder_rooms"] = int(val)
219 + elif label == "Chambres à coucher" and val.isdigit():
220 + out["bedrooms"] = int(val)
221 + elif label == "Année de construction" and re.fullmatch(r"(1[6-9]|20)\d{2}", val):
222 + out["year_built"] = int(val)
223 + elif label == "Superficie habitable":
224 + a = _sqft(val)
225 + if a:
226 + out["area_sqft"] = a
227 + details["Superficie habitable"] = val
228 + elif label == "Taille du lot":
229 + t = _sqft(val)
230 + if t:
231 + out["lot_sqft"] = t
232 + details["Superficie du terrain"] = val
233 +
234 + # Description / Inclusions / Exclusions / Plus d'information (addendum)
235 + desc, addendum = "", ""
236 + for name, body in _SEC_RE.findall(html):
237 + txt = _text(body)
238 + if not txt:
239 + continue
240 + low = name.lower()
241 + if low.startswith("description"):
242 + desc = txt
243 + elif low.startswith("inclusion"):
244 + details["Inclusions"] = txt[:600]
245 + elif low.startswith("exclusion"):
246 + details["Exclusions"] = txt[:600]
247 + else: # « Plus d'information »
248 + addendum = txt
249 + if desc or addendum:
250 + out["description"] = "\n\n".join(filter(None, (desc, addendum)))[:6000]
251 +
252 + # tableaux Bâtiment + Caractéristiques (paires th.prop-table / td)
253 + for label, val in _ROW_RE.findall(html):
254 + label = _html.unescape(label).strip().rstrip(":").strip()
255 + val = _text(val)
256 + if not label or val.lower() in _ZEROS:
257 + continue
258 + if label == "La Taille Du Lot":
259 + label = "Superficie du terrain"
260 + if label == "Type":
261 + label = "Type de bâtiment"
262 + details.setdefault(label, val[:300])
263 +
264 + # détails de pièce -> features + nombre de pièces
265 + rooms = _ROOM_RE.findall(html)
266 + if rooms:
267 + feats = []
268 + for name, dim, lvl, floor in rooms:
269 + name, dim = _html.unescape(name).strip(), _html.unescape(dim).strip()
270 + lvl, floor = _html.unescape(lvl).strip(), _html.unescape(floor).strip()
271 + line = name + (f" — {dim}" if dim else "") + (f", {lvl}" if lvl else "")
272 + if floor:
273 + line += f" ({floor})"
274 + feats.append(line)
275 + out["features"] = feats[:40]
276 + details.setdefault("Nombre de pièces", str(len(rooms)))
277 +
278 + # courtier inscripteur (encadré « Informations »)
279 + mb = _BROKER_RE.search(html)
280 + if mb:
281 + seg = mb.group(1)
282 + mn = _NAME_RE.search(seg)
283 + if mn:
284 + out["broker_name"] = _html.unescape(mn.group(1)).strip()
285 + md = _DESIG_RE.search(seg)
286 + if md:
287 + details.setdefault("Titre du courtier", _html.unescape(md.group(1)).strip())
288 + mt = _TEL_RE.search(seg)
289 + if mt:
290 + out["broker_phone"] = _html.unescape(mt.group(1)).strip()
291 +
292 + # coordonnées GPS (carte Google inline)
293 + mc = _COORD_RE.search(html)
294 + if mc:
295 + lat, lng = float(mc.group(1)), float(mc.group(2))
296 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
297 + out["lat"], out["lng"] = lat, lng
298 +
299 + # visite virtuelle / vidéo (modal #video, matterport, youtube, vimeo)
300 + mt = _TOUR_RE.search(html)
301 + if mt:
302 + details.setdefault("Visite virtuelle / vidéo", mt.group(0))
303 +
304 + if details:
305 + out["details"] = details
306 + return out
modified immoka/connectors/exp_quebec.py +266 −14
@@ -9,9 +9,20 @@
9 9 # /fr/properties/?pages={N} (9 fiches/page, param « pages » uniquement).
10 10 # Chaque carte porte data-href /fr/properties/mls/{MLS}, data-price ($US),
11 11 # data-address (rue) et data-pic (property-images/{MLS}/). Le n° MLS = n° Centris.
12 −# La VILLE (absente de la liste) vient de l'endpoint SSR interne
13 −# /wp-content/themes/canvas/load/map-property.php?mls={MLS}&lang=fr
14 −# (enrichissement requests direct, plafonné + cache).
12 +#
13 +# FICHE DÉTAIL /fr/properties/mls/{MLS}/ : server-rendered elle aussi
14 +# (requêtes directes, aucun anti-bot) et TRÈS riche — galerie complète en
15 +# pleine résolution (~1728px, ancres data-lightbox="gallery-item"), remarque
16 +# du courtier + addendum, inclusions/exclusions, icônes (chambres/sdb/
17 +# superficie MC|PC/année), tables th/td par section (BÂTIMENT, ÉVALUATION,
18 +# DÉPENSES, CARACTÉRISTIQUES, DÉTAILS DE PIÈCE), GPS exact dans l'embed
19 +# Street View, courtier inscripteur (photo alt + tel: du bloc latéral).
20 +# Enrichissement via _detailutil.enrich (cache BD + plafond
21 +# IMMOKA_EXP_DETAIL_LIMIT, ~400/cycle).
22 +#
23 +# La VILLE vient de la fiche détail (en-tête h2) ; repli sur l'endpoint SSR
24 +# /wp-content/themes/canvas/load/map-property.php?mls={MLS}&lang=fr pour les
25 +# fiches au-delà du budget détail (cache « city » déjà peuplé).
15 26 #
16 27 # ⚠ PLAFOND : le SSR reflète le limit=1000 de l'API sous-jacente → au plus
17 28 # ~1000 fiches (pages 1..~114, crawl vérifié). C'est l'inventaire pratique
@@ -28,13 +39,15 @@ import os
28 39 import re
29 40
30 41 from .base import BaseConnector
42 +from . import _detailutil as du
31 43 from ..normalize import parse_price
32 44 from ..schema import PropertyListing
33 45
34 46 SITE = "https://expquebec.com"
35 47 LISTING = SITE + "/fr/properties/"
36 48 MAP_PROP = SITE + "/wp-content/themes/canvas/load/map-property.php"
37 −DETAIL_LIMIT = int(os.environ.get("IMMOKA_EXP_DETAIL_LIMIT", "1200"))
49 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_EXP_DETAIL_LIMIT", "400"))
50 +CITY_LIMIT = int(os.environ.get("IMMOKA_EXP_CITY_LIMIT", "1200"))
38 51 AGENCY = "eXp Agence immobilière"
39 52
40 53 _CARD_SPLIT = re.compile(r'data-href="/fr/properties/mls/(\d{6,9})"')
@@ -42,6 +55,38 @@ _PRICE_RE = re.compile(r'data-price="([^"]*)"', re.I)
42 55 _ADDR_RE = re.compile(r'data-address="([^"]*)"', re.I)
43 56 _PIC_RE = re.compile(r'data-pic="([^"]*)"', re.I)
44 57
58 +# ---- fiche détail -----------------------------------------------------------
59 +_GALLERY_RE = re.compile(r'<a href="(https?://[^"]+)"[^>]*data-lightbox="gallery-item"')
60 +_H2_RE = re.compile(r'<small>MLS\s*#\s*\d+</small><br>\s*(.*?)</h2>', re.S)
61 +_ICON_RE = re.compile(r'<i class="icon-realestate-(bed|bathtub|plan|calendar)">'
62 + r'</i>\s*([^<]+)')
63 +_REMARK_RE = re.compile(r'<h4>Remarque du courtier</h4>\s*<p[^>]*>(.*?)</p>', re.S)
64 +_ADDENDUM_RE = re.compile(r'<h4>Addendum</h4>\s*<p[^>]*>(.*?)</p>', re.S)
65 +_INCL_RE = re.compile(r'<h4>INCLUSION</h4>\s*<p[^>]*>(.*?)</p>', re.S)
66 +_EXCL_RE = re.compile(r'<h4>EXCLUSION</h4>\s*<p[^>]*>(.*?)</p>', re.S)
67 +_TABLE_RE = re.compile(r'<table class="table">(.*?)</table>', re.S)
68 +_SECTION_RE = re.compile(r'>\s*(BÂTIMENT|ÉVALUATION|DÉPENSES|CARACTÉRISTIQUES)\b')
69 +_PAIR_RE = re.compile(r'<th class="prop-table"[^>]*>\s*(?:<[^>]+>\s*)*([^<]+?)\s*</th>'
70 + r'\s*<td[^>]*>\s*([^<]*?)\s*</td>', re.S)
71 +_ROOM_RE = re.compile(r'<td data-title="Chambre">([^<]*)</td>\s*'
72 + r'<td data-title="Dimensions">([^<]*)</td>\s*'
73 + r'<td data-title="Niveau">([^<]*)</td>\s*'
74 + r'<td data-title="Planchers">([^<]*)</td>', re.S)
75 +_SV_RE = re.compile(r'streetview\?key=[^&"\']*&location=(-?\d{1,2}\.\d+),(-?\d{2,3}\.\d+)')
76 +_AGENT_RE = re.compile(r'agent-images/[^"]+"\s+alt="([^"]+)"')
77 +_TEL_RE = re.compile(r'href="tel:(\d{7,15})"')
78 +_SMS_RE = re.compile(r'data-phone="\+?1?(\d{10})"')
79 +_OCCUP_RE = re.compile(r"Date d'occupation\s*:\s*([\d-]{8,10})")
80 +_OPEN_RE = re.compile(r'visite libre<br>\s*(.*?)</h4>', re.S | re.I)
81 +_TOUR_RE = re.compile(r'https?://(?:my\.)?(?:matterport\.com|youtu\.be|'
82 + r'vimeo\.com)/[^"\'<> ]+', re.I)
83 +_AREA_RE = re.compile(r'([\d,]+(?:\.\d+)?)\s*(MC|PC)\b', re.I)
84 +_POSTAL_RE = re.compile(r'^[A-Z]\d[A-Z]\s*\d[A-Z]\d$')
85 +
86 +# lignes du texte-source repliées en dur à ~60 colonnes : on recolle les
87 +# retours qui coupent une phrase (pas de ponctuation finale, suite en minuscule)
88 +_WRAP_RE = re.compile(r'(?<![.!?:])\n(?=[a-zà-ÿœ])')
89 +
45 90
46 91 def _parse_us_price(label: str):
47 92 label = (label or "").strip()
@@ -52,6 +97,200 @@ def _parse_us_price(label: str):
52 97 return price, label
53 98
54 99
100 +def _txt(block: str) -> str:
101 + """Bloc HTML -> texte propre (les <br> deviennent des sauts de ligne,
102 + les retours en dur du flux Centris sont recollés)."""
103 + block = re.sub(r'<br\s*/?>', '\n@@BR@@\n', block, flags=re.I)
104 + block = re.sub(r'<[^>]+>', ' ', block)
105 + block = _html.unescape(block).replace('\r', '')
106 + block = re.sub(r'[ \t]+', ' ', block)
107 + block = re.sub(r' ?\n ?', '\n', block).strip()
108 + block = _WRAP_RE.sub(' ', block) # déplie les phrases coupées
109 + block = block.replace('\n@@BR@@\n', '\n').replace('@@BR@@', '\n')
110 + return re.sub(r'\n{3,}', '\n\n', block).strip()
111 +
112 +
113 +def _area_sqft(val: str) -> float | None:
114 + """« 91.4 MC » (m²) / « 2378.82 PC » (pi²) -> pi²."""
115 + m = _AREA_RE.search(val or "")
116 + if not m:
117 + return None
118 + try:
119 + v = float(m.group(1).replace(",", ""))
120 + except ValueError:
121 + return None
122 + if v <= 0:
123 + return None
124 + return round(v * 10.7639) if m.group(2).upper() == "MC" else round(v)
125 +
126 +
127 +def _fmt_area(val: str) -> str:
128 + """« 91.4 MC » -> « 91.4 m² » ; « 2378.82 PC » -> « 2378.82 pi² »."""
129 + m = _AREA_RE.search(val or "")
130 + if not m:
131 + return (val or "").strip()
132 + unit = "m²" if m.group(2).upper() == "MC" else "pi²"
133 + return f"{m.group(1)} {unit}"
134 +
135 +
136 +def _parse_exp_detail(html: str) -> dict:
137 + """Fiche détail expquebec.com (server-rendered) -> payload riche."""
138 + out: dict = {}
139 + details: dict = {}
140 + features: list[str] = []
141 +
142 + # -- galerie complète, pleine résolution, ordre d'origine (dédoublonnée :
143 + # le carrousel duplique les ancres pour le loop)
144 + images, seen = [], set()
145 + for u in _GALLERY_RE.findall(html):
146 + u = _html.unescape(u).strip()
147 + if u and u not in seen:
148 + seen.add(u)
149 + images.append(u)
150 + if images:
151 + out["images"] = images
152 +
153 + # -- en-tête h2 : adresse <br/> Ville (secteur), Arrondissement X, CP
154 + m = _H2_RE.search(html)
155 + if m:
156 + head = _html.unescape(re.sub(r'<[^>]+>', '\n', m.group(1)))
157 + lines = [ln.strip() for ln in head.split('\n') if ln.strip()]
158 + if lines:
159 + out["address"] = lines[0]
160 + rest = " ".join(lines[1:])
161 + parts = [p.strip() for p in rest.split(",") if p.strip()]
162 + if parts:
163 + cm = re.match(r'^(.*?)\s*\((.+)\)\s*$', parts[0])
164 + if cm:
165 + out["city"], out["sector"] = cm.group(1).strip(), cm.group(2).strip()
166 + else:
167 + out["city"] = parts[0]
168 + for p in parts[1:]:
169 + if _POSTAL_RE.match(p):
170 + details["Code postal"] = p
171 + elif p.startswith("Arrondissement ") and not out.get("sector"):
172 + arr = p[len("Arrondissement "):].strip()
173 + if arr and arr != "Ville" and not arr.startswith("Noms de rues"):
174 + out["sector"] = arr
175 +
176 + # -- icônes : chambres / salles de bains / superficie / année
177 + for icon, val in _ICON_RE.findall(html):
178 + val = val.strip()
179 + if not val:
180 + continue
181 + if icon == "bed" and val.isdigit():
182 + out["bedrooms"] = int(val)
183 + elif icon == "bathtub" and val.isdigit():
184 + out["bathrooms"] = int(val)
185 + elif icon == "plan":
186 + sqft = _area_sqft(val)
187 + if sqft:
188 + out["area_sqft"] = sqft
189 + details["Superficie habitable"] = _fmt_area(val)
190 + elif icon == "calendar" and re.fullmatch(r'(1[6-9]|20)\d{2}', val):
191 + out["year_built"] = int(val)
192 +
193 + # -- description : l'addendum (long) bat la remarque du courtier
194 + remark = _REMARK_RE.search(html)
195 + addendum = _ADDENDUM_RE.search(html)
196 + texts = [_txt(x.group(1)) for x in (addendum, remark) if x]
197 + if texts:
198 + out["description"] = max(texts, key=len)
199 + for rx, key in ((_INCL_RE, "Inclusions"), (_EXCL_RE, "Exclusions")):
200 + m = rx.search(html)
201 + if m:
202 + v = _txt(m.group(1))
203 + if v:
204 + details[key] = v
205 +
206 + # -- tables th/td par section (dupliquées mobile/desktop -> dict dédoublonne)
207 + for tbl in _TABLE_RE.findall(html):
208 + sec = _SECTION_RE.search(tbl)
209 + if not sec:
210 + continue
211 + section = sec.group(1)
212 + for label, val in _PAIR_RE.findall(tbl):
213 + label = _html.unescape(label).strip().rstrip(":")
214 + val = re.sub(r'\s+', ' ', _html.unescape(val)).strip()
215 + if not val or val in ("0", "-"):
216 + continue
217 + if section == "BÂTIMENT":
218 + if label == "Type":
219 + out["property_type"] = val
220 + details["Type de propriété"] = val
221 + elif label == "Style":
222 + details["Style de bâtiment"] = val
223 + elif label == "Dimensions":
224 + details["Dimensions du bâtiment"] = val
225 + elif label == "Dimension terrain":
226 + details["Superficie du terrain"] = _fmt_area(val) \
227 + if _AREA_RE.search(val) else val
228 + lot = _area_sqft(val)
229 + if lot:
230 + out["lot_sqft"] = lot
231 + else: # Nombre d'étages, Année de construction…
232 + details[label] = val
233 + elif section == "ÉVALUATION":
234 + if label == "Année":
235 + details["Évaluation municipale (année)"] = val
236 + elif label in ("Terrain", "Bâtiment", "Total"):
237 + details[f"Évaluation municipale ({label.lower()})"] = val
238 + elif section == "DÉPENSES":
239 + details[re.sub(r'\s*\(\d{4}\)\s*', '', label)] = val
240 + else: # CARACTÉRISTIQUES
241 + details[label] = val
242 + if label in ("Particularités", "Équipement disponible"):
243 + features.extend(f.strip() for f in val.split(",") if f.strip())
244 +
245 + # -- pièces (clé spéciale `pieces` rendue par le frontend) + salles d'eau
246 + rooms, powder = [], 0
247 + for nom, dims, niveau, sol in _ROOM_RE.findall(html):
248 + nom = _html.unescape(nom).strip()
249 + if not nom:
250 + continue
251 + room = {"nom": nom}
252 + for k, v in (("dimensions", dims), ("niveau", niveau), ("revetement", sol)):
253 + v = _html.unescape(v).strip()
254 + if v:
255 + room[k] = v
256 + rooms.append(room)
257 + if nom.lower() == "salle d'eau":
258 + powder += 1
259 + if rooms:
260 + details["pieces"] = rooms
261 + if powder:
262 + out["powder_rooms"] = powder
263 +
264 + # -- GPS exact (embed Street View), courtier, extras
265 + m = _SV_RE.search(html)
266 + if m:
267 + out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))
268 + m = _AGENT_RE.search(html)
269 + if m:
270 + out["broker_name"] = _html.unescape(m.group(1)).strip()
271 + blk = html[m.end():m.end() + 3000] # bloc latéral du courtier
272 + tel = _TEL_RE.search(blk) or _SMS_RE.search(blk)
273 + if tel:
274 + out["broker_phone"] = tel.group(1)
275 + m = _OCCUP_RE.search(html)
276 + if m:
277 + details["Date d'occupation"] = m.group(1)
278 + m = _OPEN_RE.search(html)
279 + if m:
280 + v = re.sub(r'\s+', ' ', _html.unescape(re.sub(r'<[^>]+>', ' ', m.group(1)))).strip(" |")
281 + if v:
282 + details["Visite libre"] = v
283 + m = _TOUR_RE.search(html)
284 + if m:
285 + details["Visite virtuelle"] = m.group(0)
286 +
287 + if details:
288 + out["details"] = details
289 + if features:
290 + out["features"] = features
291 + return out
292 +
293 +
55 294 class ExpQuebecConnector(BaseConnector):
56 295 source_id = "exp_ag_qc"
57 296 request_delay = 0.4
@@ -81,7 +320,13 @@ class ExpQuebecConnector(BaseConnector):
81 320 if empty >= 3:
82 321 break
83 322 listings = list(by_id.values())
84 − self._enrich_cities(listings)
323 + # fiche détail complète : galerie pleine résolution, description,
324 + # tables BÂTIMENT/ÉVALUATION/DÉPENSES/CARACTÉRISTIQUES, pièces, GPS,
325 + # courtier + téléphone. v1 = premier parse complet.
326 + du.enrich(self, listings, DETAIL_LIMIT, _parse_exp_detail, key="v1")
327 + # repli ville (fiches au-delà du budget détail) : endpoint SSR léger,
328 + # cache « city » déjà peuplé pour l'inventaire existant
329 + self._enrich_cities([l for l in listings if not l.city])
85 330 return listings
86 331
87 332 def _card(self, mls: str, blk: str) -> PropertyListing | None:
@@ -95,7 +340,7 @@ class ExpQuebecConnector(BaseConnector):
95 340 return PropertyListing(
96 341 source=self.source_id,
97 342 external_id=mls,
98 − url=f"{SITE}/fr/properties/mls/{mls}",
343 + url=f"{SITE}/fr/properties/mls/{mls}/",
99 344 title=addr or "Propriété à vendre",
100 345 address=addr,
101 346 price=price,
@@ -107,23 +352,30 @@ class ExpQuebecConnector(BaseConnector):
107 352 )
108 353
109 354 def _enrich_cities(self, listings: list[PropertyListing]) -> None:
110 − """Complète la ville (absente de la liste) via map-property.php, avec
111 − cache BD + plafond. La ville est le dernier segment texte de la carte-fiche
112 − (après le prix et l'adresse)."""
113 − if DETAIL_LIMIT <= 0:
355 + """Complète la ville via map-property.php, avec cache BD + plafond.
356 + La ville est le dernier segment texte de la carte-fiche (après le prix
357 + et l'adresse).
358 +
359 + ⚠ detail_cache = UNE ligne par fiche : on lit le payload quelle que
360 + soit sa clé (« city » historique OU « v1 » du parse détail, qui inclut
361 + la ville) et on n'écrit une ligne « city » que s'il n'y en a AUCUNE —
362 + jamais par-dessus un payload détail complet."""
363 + if CITY_LIMIT <= 0:
114 364 return
115 365 from .. import db
116 366 con = db.connect()
117 − budget = DETAIL_LIMIT
367 + budget = CITY_LIMIT
118 368 try:
119 369 for lst in listings:
120 − cached = db.get_cached_detail(con, self.source_id, lst.external_id, "city")
370 + cached = db.get_stale_detail(con, self.source_id, lst.external_id)
121 371 if cached is None:
122 372 if budget <= 0:
123 373 continue
124 − cached = self._fetch_city(lst.external_id)
125 − db.put_cached_detail(con, self.source_id, lst.external_id, "city", cached)
374 + fetched = self._fetch_city(lst.external_id)
375 + db.put_cached_detail(con, self.source_id, lst.external_id,
376 + "city", fetched)
126 377 budget -= 1
378 + cached = fetched
127 379 if cached.get("city") and not lst.city:
128 380 lst.city = cached["city"]
129 381 finally:
modified immoka/connectors/glmc.py +128 −15
@@ -10,6 +10,17 @@
10 10 # détail (enrichissement plafonné + cache). La section « Vendus » (fiches
11 11 # vendues, après la pagination) est exclue du parsing.
12 12 #
13 +# Page détail (thème X / Cornerstone) : PAS de JSON-LD propriété — tout est
14 +# dans le HTML : stats à icônes « N pièces / N chambre(s) / N salle(s) de
15 +# bain / N salle d'eau », description dans <ul class="remarques-list">,
16 +# caractéristiques dans des grilles x-cell alternant libellé/valeur
17 +# (Genre, Année de construction, Terrain [dimensions puis superficie],
18 +# évaluations, taxes…), Inclusions/Exclusions en x-text, courtier(s) dans
19 +# .mw-courtier-card (nom + tel:), galerie = photos Centris re-hébergées
20 +# /wp-content/uploads/…/{md5}.jpg (nom 32 hex, pleine résolution, ordre
21 +# d'origine — les logos/portraits ont un nom lisible et sont donc exclus).
22 +# Coordonnées GPS : chargées en AJAX par mw-centris-map → non exposées.
23 +#
13 24 # source_id « glmc_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —
14 25 # si le n° Centris est déjà porté par une bannière couverte, la fiche est
15 26 # masquée ; les inscriptions propres de GLMC restent visibles.
@@ -36,6 +47,25 @@ _IMG_RE = re.compile(r'<img[^>]+src="(https://www\.glmc\.ca/wp-content/uploads/[
36 47 _TITLE_RE = re.compile(r'x-text-content-text-primary">([^<]+)</p>', re.I)
37 48 _PRICE_RE = re.compile(r'([\d][\d\s ]{2,}\$)', re.I)
38 49
50 +# --- page détail ---
51 +_GALLERY_RE = re.compile(
52 + r'https://www\.glmc\.ca/wp-content/uploads/sites/\d+/\d{4}/\d{2}/'
53 + r'[0-9a-f]{32}\.(?:jpg|jpeg|png|webp)', re.I)
54 +_DESC_RE = re.compile(r'<ul class="remarques-list">(.*?)</ul>', re.S | re.I)
55 +_LI_RE = re.compile(r'<li[^>]*>(.*?)</li>', re.S | re.I)
56 +_CELL_RE = re.compile(r'class="x-cell[^>]*>(.*?)</div>\s*</div>', re.S)
57 +_STAT_ROOMS_RE = re.compile(r'x-text-content-text-primary">\s*(\d+)\s*pi[èe]ces?', re.I)
58 +_STAT_BEDS_RE = re.compile(r'x-text-content-text-primary">\s*(\d+)\s*chambre', re.I)
59 +_STAT_BATHS_RE = re.compile(r'x-text-content-text-primary">\s*(\d+)\s*salle\(s\) de bain', re.I)
60 +_STAT_POWDER_RE = re.compile(r"x-text-content-text-primary\">\s*(\d+)\s*salle d['’]eau", re.I)
61 +_BROKER_RE = re.compile(r'mw-courtier-name">\s*<h3>([^<]+)</h3>', re.I)
62 +_BROKER_TEL_RE = re.compile(r'mw-courtier-phone">\s*<a href="tel:(\d{7,11})"', re.I)
63 +_INCL_RE = re.compile(r'>Inclusions</p></div>\s*<div class="x-text x-content[^>]*>([^<]{2,400})<', re.I)
64 +_EXCL_RE = re.compile(r'>Exclusions</p></div>\s*<div class="x-text x-content[^>]*>([^<]{2,400})<', re.I)
65 +_AREA_VAL_RE = re.compile(r'([\d\s .,]+?)\s*(MC|M2|M²|PC|PI2|PI²)\s*$', re.I)
66 +
67 +_SQFT_PER_SQM = 10.7639
68 +
39 69
40 70 class GlmcConnector(BaseConnector):
41 71 source_id = "glmc_ag_qc"
@@ -63,7 +93,7 @@ class GlmcConnector(BaseConnector):
63 93 if dry >= 2:
64 94 break
65 95 listings = list(by_id.values())
66 − du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1")
96 + du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v2")
67 97 return listings
68 98
69 99 def _card(self, blk: str) -> PropertyListing | None:
@@ -101,16 +131,99 @@ class GlmcConnector(BaseConnector):
101 131 )
102 132
103 133
134 +def _txt(fragment: str) -> str:
135 + """HTML -> texte plat propre (une ligne)."""
136 + t = _html.unescape(re.sub(r"<[^>]+>", " ", fragment))
137 + return re.sub(r"\s+", " ", t.replace("\xa0", " ")).strip()
138 +
139 +
140 +def _to_sqft(val: str) -> float | None:
141 + """« 1729.60 MC » / « 2 400 PC » -> pieds carrés."""
142 + m = _AREA_VAL_RE.search(val.strip())
143 + if not m:
144 + return None
145 + try:
146 + n = float(re.sub(r"[^\d.]", "", m.group(1)))
147 + except ValueError:
148 + return None
149 + if m.group(2).upper() in ("MC", "M2", "M²"):
150 + n *= _SQFT_PER_SQM
151 + return round(n, 1) or None
152 +
153 +
104 154 def _parse_detail(html: str) -> dict:
105 − """Fiche détail GLMC : JSON-LD + tableau Centris aplati."""
155 + """Fiche détail GLMC (thème X) : stats à icônes, remarques, grilles x-cell,
156 + inclusions/exclusions, courtier(s), galerie pleine résolution."""
106 157 out: dict = {}
107 − desc = du.ld_description(html)
108 − if desc:
109 − out["description"] = desc
110 − text = du.flatten(html)
111 − det = du.centris_details(text)
112 − if det:
113 − out["details"] = det
158 +
159 + # description = remarques Centris (<ul class="remarques-list">)
160 + dm = _DESC_RE.search(html)
161 + if dm:
162 + paras = [_txt(li) for li in _LI_RE.findall(dm.group(1))]
163 + desc = "\n\n".join(p for p in paras if p)
164 + if desc:
165 + out["description"] = desc
166 +
167 + # stats à icônes (pièces / chambres / sdb / salles d'eau)
168 + details: dict = {}
169 + sm = _STAT_ROOMS_RE.search(html)
170 + if sm:
171 + details["Nombre de pièces"] = sm.group(1)
172 + for rx, field in ((_STAT_BEDS_RE, "bedrooms"),
173 + (_STAT_BATHS_RE, "bathrooms"),
174 + (_STAT_POWDER_RE, "powder_rooms")):
175 + m = rx.search(html)
176 + if m and int(m.group(1)) > 0:
177 + out[field] = int(m.group(1))
178 +
179 + # grilles x-cell : cellules alternées libellé / valeur (avant les courtiers)
180 + seg = html.split("Courtier(s)")[0]
181 + cells = [_txt(c) for c in _CELL_RE.findall(seg)]
182 + for i in range(0, len(cells) - 1, 2):
183 + lab, val = cells[i], cells[i + 1]
184 + if not lab or not val or len(lab) > 45 or len(val) > 120 or lab == val:
185 + continue
186 + if lab in ("Terrain", "Bâtiment"): # sections Dimensions vs Superficie
187 + if re.search(r"\d\s*[xX]\s*\d", val):
188 + lab = f"Dimensions du {lab.lower()}"
189 + else:
190 + lab = f"Superficie du {lab.lower()}"
191 + elif lab == "Habitable":
192 + lab = "Superficie habitable"
193 + details[lab] = val
194 + if details.get("Superficie du terrain"):
195 + sq = _to_sqft(details["Superficie du terrain"])
196 + if sq:
197 + out["lot_sqft"] = sq
198 + if details.get("Superficie habitable"):
199 + sq = _to_sqft(details["Superficie habitable"])
200 + if sq:
201 + out["area_sqft"] = sq
202 + my = re.search(r"\b(1[6-9]\d{2}|20\d{2})\b", details.get("Année de construction", ""))
203 + if my:
204 + out["year_built"] = int(my.group(1))
205 +
206 + # inclusions / exclusions (x-text hors grille)
207 + for rx, lab in ((_INCL_RE, "Inclusions"), (_EXCL_RE, "Exclusions")):
208 + m = rx.search(seg)
209 + if m:
210 + v = _txt(m.group(1))
211 + if v:
212 + details[lab] = v
213 + if details:
214 + out["details"] = details
215 +
216 + # courtier inscripteur (première carte .mw-courtier-card)
217 + bm = _BROKER_RE.search(html)
218 + if bm:
219 + out["broker_name"] = _txt(bm.group(1))
220 + tm = _BROKER_TEL_RE.search(html)
221 + if tm:
222 + t = tm.group(1)
223 + if len(t) == 10:
224 + t = f"{t[:3]} {t[3:6]}-{t[6:]}"
225 + out["broker_phone"] = t
226 +
114 227 # prix « 349 900 $ »
115 228 mp = re.search(r'([\d][\d\s ]{2,}\$)', html)
116 229 if mp:
@@ -118,18 +231,18 @@ def _parse_detail(html: str) -> dict:
118 231 if p:
119 232 out["price"] = p
120 233 out["price_label"] = mp.group(1).replace(" ", " ").strip()
121 − my = re.search(r"Année de construction\s*\|\s*(\d{4})", text)
122 − if my:
123 − out["year_built"] = int(my.group(1))
234 +
124 235 co = du.gmaps_coords(html)
125 236 if co:
126 237 out["lat"], out["lng"] = co
127 − imgs = re.findall(r'https://www\.glmc\.ca/wp-content/uploads/[^"\']+\.(?:jpg|jpeg|png|webp)', html)
238 +
239 + # galerie : photos Centris re-hébergées (nom de fichier = 32 hex) —
240 + # pleine résolution, ordre d'origine ; exclut logos/portraits (noms lisibles)
128 241 seen, uniq = set(), []
129 − for u in imgs:
242 + for u in _GALLERY_RE.findall(html):
130 243 if u not in seen:
131 244 seen.add(u)
132 245 uniq.append(u)
133 − if len(uniq) > 1:
246 + if uniq:
134 247 out["images"] = uniq
135 248 return out
modified immoka/connectors/guidehabitation.py +124 −3
@@ -10,16 +10,26 @@
10 10 # description, chambres offertes, photo. Aucune clé, aucun rendu JS.
11 11 # On écarte les projets LOCATIFS (prix mensuel / « locatif » dans le texte) :
12 12 # Immo-Ka couvre le neuf À VENDRE. external_id = data-id du projet.
13 +#
14 +# Page détail projet : UN gros JSON-LD RealEstateListing très riche —
15 +# about.articleBody (présentation longue, pseudo-markdown), mainEntity.image
16 +# (galerie complète pleine résolution, ordre d'origine), mainEntity.geo,
17 +# amenityFeature, offers[] (promoteur offeredBy : nom/téléphone/site,
18 +# chambres, garantie, date de livraison), additionalProperty (statut du
19 +# projet, site web externe). Enrichissement plafonné + cache (du.enrich).
13 20 # -----------------------------------------------------------------------------
14 21 from __future__ import annotations
15 22
16 23 import html as _html
24 +import os
17 25 import re
18 26
19 27 from .base import BaseConnector
28 +from . import _detailutil as du
20 29 from ..schema import PropertyListing
21 30
22 31 SITE = "https://www.guidehabitation.ca"
32 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_GH_DETAIL_LIMIT", "150"))
23 33
24 34 # Régions du répertoire (repli si le sitemap est indisponible).
25 35 REGIONS = ["montreal", "monteregie", "laval", "laurentides", "lanaudiere",
@@ -38,8 +48,10 @@ _DESC_RE = re.compile(r'itemprop="description">([^<]*)<')
38 48 _TYPE_RE = re.compile(r'itemtype="https://schema\.org/(SingleFamilyResidence|'
39 49 r'ApartmentComplex|Residence)"')
40 50 _BEDROOMS_RE = re.compile(r'class="card-bedrooms">([^<]*)<')
41 −_PRICE_TXT_RE = re.compile(r'À partir de\s*([\d\s ,.]+)\s*\$')
51 +_PRICE_TXT_RE = re.compile(r'À partir de\s*([\d\s ,.]+)\s*\$')
42 52 _RENTAL_RE = re.compile(r'locati|à louer|for rent', re.I)
53 +_SQFT_RE = re.compile(r'[Ss]uperficie habitable[\s ]*:?[\s ]*'
54 + r'([\d][\d\s  ,]*)\s*pi')
43 55
44 56 # En deçà de ce prix « à partir de », c'est un loyer mensuel (projet locatif).
45 57 MIN_SALE_PRICE = 50_000
@@ -72,7 +84,9 @@ class GuideHabitationConnector(BaseConnector):
72 84 lst = self._to_listing(card, region)
73 85 if lst and lst.external_id not in by_id:
74 86 by_id[lst.external_id] = lst
75 − return list(by_id.values())
87 + listings = list(by_id.values())
88 + du.enrich(self, listings, DETAIL_LIMIT, _parse_gh_detail, key="v1")
89 + return listings
76 90
77 91 def _to_listing(self, card: str, region: str) -> PropertyListing | None:
78 92 mid = _ID_RE.search(card)
@@ -96,7 +110,7 @@ class GuideHabitationConnector(BaseConnector):
96 110 mtxt = _PRICE_TXT_RE.search(card)
97 111 if mtxt:
98 112 try:
99 − price = float(re.sub(r"[\s ,]", "", mtxt.group(1)))
113 + price = float(re.sub(r"[\s ,]", "", mtxt.group(1)))
100 114 except ValueError:
101 115 price = None
102 116 if price is None or price < MIN_SALE_PRICE:
@@ -148,3 +162,110 @@ class GuideHabitationConnector(BaseConnector):
148 162 mbeds.group(1)).strip()
149 163 lst.details["Projet neuf"] = True
150 164 return lst
165 +
166 +
167 +def _clean_md(text: str) -> str:
168 + """articleBody pseudo-markdown -> texte propre (garde les paragraphes)."""
169 + t = _html.unescape(text).replace("\xa0", " ")
170 + t = re.sub(r"\*\*+", "", t) # **gras**
171 + t = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", t) # [lien](url)
172 + t = re.sub(r"^[\s]*[-•#]+\s*", "", t, flags=re.M) # puces/titres
173 + t = re.sub(r"[ \t]+", " ", t)
174 + return re.sub(r"\n{3,}", "\n\n", t).strip()
175 +
176 +
177 +def _parse_gh_detail(html: str) -> dict:
178 + """Fiche projet Guide Habitation : tout est dans le JSON-LD RealEstateListing."""
179 + out: dict = {}
180 + node = None
181 + for n in du.ld_nodes(html):
182 + t = n.get("@type")
183 + if "RealEstateListing" in (t if isinstance(t, list) else [t]):
184 + node = n
185 + break
186 + if not node:
187 + return out
188 +
189 + about = node.get("about") or {}
190 + body = str(about.get("articleBody") or "")
191 + if body:
192 + desc = _clean_md(body)
193 + if desc:
194 + out["description"] = desc
195 +
196 + main = node.get("mainEntity") or {}
197 + imgs = main.get("image") or []
198 + if isinstance(imgs, str):
199 + imgs = [imgs]
200 + imgs = [u for u in imgs if isinstance(u, str) and u.startswith("http")]
201 + if imgs:
202 + out["images"] = imgs
203 + geo = main.get("geo") or {}
204 + try:
205 + lat, lng = float(geo.get("latitude")), float(geo.get("longitude"))
206 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
207 + out["lat"], out["lng"] = lat, lng
208 + except (TypeError, ValueError):
209 + pass
210 +
211 + details: dict = {}
212 + features: list[str] = []
213 + for af in main.get("amenityFeature") or []:
214 + v = str(af.get("value") or "").strip()
215 + if v and v.lower() not in ("true", "false"):
216 + features.append(v)
217 + elif v.lower() == "true" and af.get("name"):
218 + features.append(str(af["name"]))
219 + if features:
220 + out["features"] = features
221 +
222 + for ap in node.get("additionalProperty") or []:
223 + pid, val = ap.get("propertyID") or "", str(ap.get("value") or "").strip()
224 + if not val:
225 + continue
226 + if pid == "gh:status":
227 + details["Statut du projet"] = val
228 + elif pid == "gh:deliveryDate":
229 + details["Livraison"] = val.split(" ")[0]
230 + elif ap.get("name") == "externalUrl":
231 + details["Site du projet"] = val
232 +
233 + # offres (par modèle) : promoteur, chambres min, garantie
234 + offers = node.get("offers") or []
235 + if isinstance(offers, dict):
236 + offers = [offers]
237 + beds = []
238 + for of in offers:
239 + item = of.get("itemOffered") or {}
240 + try:
241 + beds.append(int(item.get("numberOfBedrooms")))
242 + except (TypeError, ValueError):
243 + pass
244 + if of.get("warranty") and "Garantie" not in details:
245 + details["Garantie"] = str(of["warranty"])
246 + by = of.get("offeredBy") or {}
247 + if by.get("name") and "broker_name" not in out:
248 + out["broker_name"] = str(by["name"])
249 + if by.get("telephone"):
250 + out["broker_phone"] = str(by["telephone"])
251 + if beds:
252 + out["bedrooms"] = min(beds)
253 +
254 + # superficies habitables des modèles (texte de présentation) : minimum
255 + # offert (cohérent avec le prix « à partir de »)
256 + areas = []
257 + for m in _SQFT_RE.finditer(body):
258 + try:
259 + areas.append(float(re.sub(r"[^\d]", "", m.group(1))))
260 + except ValueError:
261 + pass
262 + areas = [a for a in areas if 300 <= a <= 20000]
263 + if areas:
264 + out["area_sqft"] = min(areas)
265 + if len(areas) > 1:
266 + details["Superficies offertes"] = (
267 + f"{min(areas):,.0f} à {max(areas):,.0f} pi²".replace(",", " "))
268 +
269 + if details:
270 + out["details"] = details
271 + return out
modified immoka/connectors/imcha.py +145 −1
@@ -9,18 +9,32 @@
9 9 # photo. Le n° Centris est encodé dans le nom de fichier photo
10 10 # (/uploads/photos/max/{Centris}-…) → clé de déduplication.
11 11 #
12 +# Page détail : description dans <h3 class="title--small">Description</h3>
13 +# + <p>, galerie fancybox /uploads/photos/max/… (pleine résolution, ordre
14 +# d'origine — préfixe = n° Centris), specs à icônes (Chambres / Salles de
15 +# bain / Salle d'eau, ordre libellé/nombre variable), sections
16 +# .estate__info (Type + année, Évaluation municipale, Bâtiment, Terrain
17 +# [façade/profondeur/superficie en mètres], Inclusions, Exclusions en
18 +# <dt>/<dd>). AUCUNE coordonnée GPS ni adresse civique exposée (pas de
19 +# carte sur la fiche) — impossible à géocoder honnêtement. ⚠️ Les réponses
20 +# HTTP n'annoncent pas de charset alors que le HTML est en UTF-8 → forcer
21 +# resp.encoding partout. Enrichissement plafonné + cache (du.enrich).
22 +#
12 23 # source_id « imcha_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup).
13 24 # -----------------------------------------------------------------------------
14 25 from __future__ import annotations
15 26
16 27 import html as _html
28 +import os
17 29 import re
18 30
19 31 from .base import BaseConnector
32 +from . import _detailutil as du
20 33 from ..normalize import parse_price
21 34 from ..schema import PropertyListing
22 35
23 36 SITE = "https://www.imcha.com"
37 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_IMCHA_DETAIL_LIMIT", "150"))
24 38 AGENCY = "Les Immeubles Charlevoix"
25 39
26 40 _ITEM_SPLIT = re.compile(r'<li class="estate-single"')
@@ -33,6 +47,24 @@ _CITY_RE = re.compile(r'estate-single__city">([^<]+)<', re.I)
33 47 _PRICE_RE = re.compile(r'estate-single__price">([^<]+)<', re.I)
34 48 _BEDS_RE = re.compile(r'estate-spec__count">(\d+)<', re.I)
35 49
50 +# --- page détail ---
51 +_DESC_RE = re.compile(r'title--small">Description</h3>\s*(.*?)\s*<div class="estate__info', re.S | re.I)
52 +_P_RE = re.compile(r'<p[^>]*>(.*?)</p>', re.S | re.I)
53 +_FANCY_RE = re.compile(r'class="fancybox"[^>]*href="(/uploads/photos/max/[^"]+)"', re.I)
54 +_SPEC_RE = re.compile(r'<div class="estate__spec">\s*(.*?)</div>\s*</div>', re.S | re.I)
55 +_SPEC_LABEL_RE = re.compile(r'estate-spec__label">\s*([^<]+)', re.I)
56 +_SPEC_COUNT_RE = re.compile(r'estate-spec__count">\s*(\d+)', re.I)
57 +_INFO_RE = re.compile(r'<div class="estate__info">\s*<h3 class="estate-info__title">'
58 + r'([^<]+)</h3>\s*<dl class="estate-info__list">(.*?)</dl>', re.S | re.I)
59 +_DTDD_RE = re.compile(r'<dt>([^<]*)</dt>\s*(?:<dd>([^<]*)</dd>)?', re.S | re.I)
60 +_TEL_RE = re.compile(r'Tel\.?\s*:\s*((?:1[\s-])?\d{3}[\s-]\d{3}-\d{4})')
61 +_AREA_M2_RE = re.compile(r'([\d\s .,]+?)\s*(m|mètre|pied)', re.I)
62 +
63 +_SQFT_PER_SQM = 10.7639
64 +_SPEC_FIELDS = {"chambres": "bedrooms", "chambre": "bedrooms",
65 + "salles de bain": "bathrooms", "salle de bain": "bathrooms",
66 + "salle d'eau": "powder_rooms", "salles d'eau": "powder_rooms"}
67 +
36 68
37 69 class ImchaConnector(BaseConnector):
38 70 source_id = "imcha_ag_qc"
@@ -59,7 +91,17 @@ class ImchaConnector(BaseConnector):
59 91 dry = dry + 1 if len(by_id) == before else 0
60 92 if dry >= 2:
61 93 break
62 − return list(by_id.values())
94 + listings = list(by_id.values())
95 + du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1",
96 + fetch_html=self._fetch_utf8)
97 + return listings
98 +
99 + def _fetch_utf8(self, url: str) -> str:
100 + # le serveur n'annonce pas de charset (requests retomberait sur
101 + # ISO-8859-1) alors que les fiches sont en UTF-8
102 + resp = self.get(url)
103 + resp.encoding = "utf-8"
104 + return resp.text
63 105
64 106 def _card(self, blk: str) -> PropertyListing | None:
65 107 hm = _HREF_RE.search(blk)
@@ -99,3 +141,105 @@ class ImchaConnector(BaseConnector):
99 141 agency=AGENCY,
100 142 broker_name=AGENCY,
101 143 )
144 +
145 +
146 +def _txt(fragment: str) -> str:
147 + t = _html.unescape(re.sub(r"<br\s*/?>", "\n", fragment, flags=re.I))
148 + t = re.sub(r"<[^>]+>", " ", t).replace("\xa0", " ").replace("×", "x")
149 + t = re.sub(r"[ \t]+", " ", t)
150 + return re.sub(r"\n{3,}", "\n\n", t).strip()
151 +
152 +
153 +def _parse_detail(html: str) -> dict:
154 + """Fiche détail imcha : description, galerie max, specs, sections dt/dd."""
155 + out: dict = {}
156 +
157 + dm = _DESC_RE.search(html)
158 + if dm:
159 + paras = [_txt(p) for p in _P_RE.findall(dm.group(1))]
160 + desc = "\n\n".join(p for p in paras if p)
161 + if desc:
162 + out["description"] = desc
163 +
164 + # galerie fancybox pleine résolution ; le préfixe du nom de fichier est le
165 + # n° Centris de LA fiche → on ne garde que le lot majoritaire (sécurité)
166 + anchors = _FANCY_RE.findall(html)
167 + if anchors:
168 + prefix = anchors[0].rsplit("/", 1)[-1].split("-", 1)[0]
169 + seen, uniq = set(), []
170 + for a in anchors:
171 + if not a.rsplit("/", 1)[-1].startswith(prefix + "-"):
172 + continue
173 + u = SITE + a
174 + if u not in seen:
175 + seen.add(u)
176 + uniq.append(u)
177 + if uniq:
178 + out["images"] = uniq
179 +
180 + # specs à icônes (ordre libellé/nombre variable selon la spec)
181 + for spec in _SPEC_RE.findall(html):
182 + lm, cm = _SPEC_LABEL_RE.search(spec), _SPEC_COUNT_RE.search(spec)
183 + if not lm or not cm:
184 + continue
185 + field = _SPEC_FIELDS.get(_txt(lm.group(1)).lower())
186 + m = re.search(r"\d+", cm.group(1))
187 + if field and m and int(m.group()) > 0:
188 + out.setdefault(field, int(m.group()))
189 +
190 + # sections .estate__info (<dt>/<dd>)
191 + details: dict = {}
192 + for title, body in _INFO_RE.findall(html):
193 + title = _txt(title)
194 + pairs = [(_txt(dt), _txt(dd or "")) for dt, dd in _DTDD_RE.findall(body)]
195 + if title.lower().startswith(("inclusions", "exclusions")):
196 + items = [dt for dt, _ in pairs if dt]
197 + if items:
198 + details[title.split(" ")[0]] = ", ".join(items)
199 + continue
200 + for dt, dd in pairs:
201 + dt = dt.rstrip(" :")
202 + if not dt or not dd or len(dd) > 120:
203 + continue
204 + if dt == "Année de construction":
205 + m = re.search(r"\b(1[6-9]\d{2}|20\d{2})\b", dd)
206 + if m: # « 0 » = inconnue chez imcha
207 + out["year_built"] = int(m.group(1))
208 + details[dt] = dd
209 + elif dt == "Type":
210 + details["Type de propriété"] = dd
211 + elif title.startswith("Évaluation municipale"):
212 + details[f"Évaluation municipale ({dt.lower()})"] = dd
213 + elif title in ("Terrain", "Bâtiment"):
214 + if dt == "Superficie":
215 + details[f"Superficie du {title.lower()}"] = dd
216 + if title == "Terrain":
217 + sq = _area_to_sqft(dd)
218 + if sq:
219 + out["lot_sqft"] = sq
220 + else:
221 + details[f"{title} — {dt.lower()}"] = dd
222 + else:
223 + details[dt] = dd
224 + if details:
225 + out["details"] = details
226 +
227 + # téléphone de l'agence (meta description « Tel. : 1 418-435-6221 »)
228 + tm = _TEL_RE.search(html)
229 + if tm:
230 + out["broker_phone"] = tm.group(1).strip()
231 + return out
232 +
233 +
234 +def _area_to_sqft(val: str) -> float | None:
235 + """« 7657.10 Mètres carrés » / « 2 400 pieds carrés » -> pi²."""
236 + m = _AREA_M2_RE.match(val.strip())
237 + if not m:
238 + return None
239 + try:
240 + n = float(re.sub(r"[^\d.]", "", m.group(1)))
241 + except ValueError:
242 + return None
243 + if m.group(2).lower().startswith(("m", "mètre")):
244 + n *= _SQFT_PER_SQM
245 + return round(n, 1) or None
modified immoka/connectors/immeuble_contact.py +169 −2
@@ -7,6 +7,15 @@
7 7 # (résidentiel + revenu), encodage ISO-8859-1. Chaque carte porte déjà tout :
8 8 # n° MLS (Centris), type, adresse, ville, prix (data-cost) et photo Centris.
9 9 #
10 +# Page détail immeubles_centris_fiche.php?no_mls={mls} (ISO-8859-1 aussi) :
11 +# galerie complète mesimages/photos_centris/_{mls}_{n}.jpg (spans .item_slide,
12 +# dans l'ordre Centris), prix demandé, région + adresse complète (code postal),
13 +# courtiers (.texte_rouge + « Cell.: »), description (#description),
14 +# évaluation municipale, taxes, dimensions bâtiment/terrain (superficies en
15 +# m² étiquetées « (Mètres) »), compte de pièces (Total/Chambre/S. de bain/
16 +# S. d'eau), caractéristiques principales (#caracteristique_principales),
17 +# inclus/exclus. Pas de GPS sur la fiche.
18 +#
10 19 # source_id « contact_ag_qc » : l'infixe _ag_ active la dédup Centris (db.refresh_dedup,
11 20 # sources %_ag_%) — si le n° Centris est déjà porté par une bannière couverte
12 21 # (RE/MAX, Via Capitale…), la fiche est masquée ; les inscriptions propres de
@@ -15,15 +24,19 @@
15 24 from __future__ import annotations
16 25
17 26 import html as _html
27 +import os
18 28 import re
19 29
20 30 from .base import BaseConnector
21 −from ..normalize import parse_price
31 +from . import _detailutil as du
32 +from ..normalize import parse_price, parse_area_sqft, parse_lot_sqft
22 33 from ..schema import PropertyListing
23 34
24 35 SITE = "https://www.lesimmeublescontact.com"
25 36 PAGES = ["residentiel", "revenu", "commercial"]
26 37 AGENCY = "Les Immeubles Contact"
38 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_CONTACT_DETAIL_LIMIT",
39 + os.environ.get("IMMOKA_DETAIL_LIMIT", "100")))
27 40
28 41 # une carte = <article class="immeuble_row" data-cost="NNN"> … </article>
29 42 _ROW_RE = re.compile(r'<article class="immeuble_row"\s+data-cost="(\d+)"(.*?)</article>', re.S | re.I)
@@ -32,6 +45,149 @@ _ADDR_RE = re.compile(r'class="address">(.*?)</p>', re.S | re.I)
32 45 _MLS_RE = re.compile(r'no_mls=(\d{6,9})', re.I)
33 46 _PRICE_RE = re.compile(r'class="price"><p>([^<]+)</p>', re.S | re.I)
34 47
48 +# --- page détail -------------------------------------------------------------
49 +_DET_IMG_RE = re.compile(r'href="(mesimages/photos_centris/_\d+_(\d+)\.jpg)"', re.I)
50 +_BROKER_RE = re.compile(r'class="texte_rouge">([^<]+)</span>', re.I)
51 +_CELL_RE = re.compile(r'Cell\.?\s*:?\s*([\d][\d.\- ]{8,14}\d)', re.I)
52 +# « Prix vendu / loué : S/O<br /><br /> RÉGION<br />ADRESSE, VILLE CODE </td> »
53 +_REGION_ADDR_RE = re.compile(
54 + r'Prix vendu / loué\s*:</span>[^<]*<br\s*/?>\s*<br\s*/?>\s*'
55 + r'([^<>]{3,60}?)\s*<br\s*/?>\s*([^<>]{5,120}?)\s*</td>', re.S)
56 +
57 +
58 +def _div(html: str, div_id: str) -> str:
59 + m = re.search(r'<div id="%s">(.*?)</div>' % re.escape(div_id), html, re.S | re.I)
60 + return m.group(1) if m else ""
61 +
62 +
63 +def _text(fragment: str) -> str:
64 + return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
65 +
66 +
67 +def _sqm(val: str):
68 + """« 342 (Mètres) » -> pi² (les colonnes Superficie de la fiche sont en m²)."""
69 + v = val.replace("(Mètres)", "m²").replace("(Pieds)", "pi²").strip()
70 + return parse_area_sqft(v)
71 +
72 +
73 +def parse_contact_detail(html: str) -> dict:
74 + """Fiche lesimmeublescontact.com : galerie complète Centris (ordre d'origine),
75 + description, évaluation/taxes, superficies, pièces, caractéristiques,
76 + inclus/exclus, courtier(s) + cellulaire. Pas de GPS sur ces pages."""
77 + out: dict = {}
78 + # galerie : href des .item_slide, une par photo, déjà dans l'ordre Centris
79 + imgs, seen = [], set()
80 + for m in _DET_IMG_RE.finditer(html):
81 + u = f"{SITE}/{m.group(1)}"
82 + if u not in seen:
83 + seen.add(u)
84 + imgs.append(u)
85 + if imgs:
86 + out["images"] = imgs
87 +
88 + details: dict = {}
89 + unesc = _html.unescape(html)
90 + # prix demandé + adresse complète (région | adresse, ville + code postal)
91 + mp = re.search(r'Prix demandé\s*:</span>\s*([^<]+)<', unesc)
92 + if mp:
93 + label = mp.group(1).strip()
94 + if parse_price(label):
95 + out["price_label"] = label
96 + out["price"] = parse_price(label)
97 + ma = _REGION_ADDR_RE.search(unesc)
98 + if ma:
99 + details["Région"] = _text(ma.group(1)) # « Saguenay/Lac-Saint-Jean »
100 + full = _text(ma.group(2)) # « 216 Rue de la Pointe, Dolbeau-Mistassini G8L5M2 »
101 + out["address"] = full.split(",")[0].strip()
102 +
103 + # description
104 + desc = _text(re.sub(r'<span class="moyen_titre">Description</span>', ' ',
105 + _div(html, "description")))
106 + if len(desc) > 20:
107 + out["description"] = desc[:4000]
108 +
109 + # courtiers (souvent 2) + cellulaire du premier
110 + names = [_html.unescape(n).strip() for n in _BROKER_RE.findall(html)]
111 + if names:
112 + out["broker_name"] = " et ".join(dict.fromkeys(names[:2]))
113 + mc = _CELL_RE.search(html)
114 + if mc:
115 + out["broker_phone"] = mc.group(1).replace(".", "-").replace(" ", "-")
116 +
117 + # évaluation municipale (année, bâtiment, terrain, total)
118 + ev = _html.unescape(_div(html, "evaluation"))
119 + my = re.search(r'Année</span>\s*:\s*(\d{4})', ev)
120 + year = f" ({my.group(1)})" if my else ""
121 + for lab, key in (("Bâtiment", "Évaluation municipale (bâtiment)"),
122 + ("Terrain", "Évaluation municipale (terrain)"),
123 + ("Total", "Évaluation municipale")):
124 + mv = re.search(lab + r'</span>\s*:\s*([\d\s ]+\$)', ev)
125 + if mv:
126 + details[key] = re.sub(r"\s+", " ", mv.group(1)).strip() + year
127 +
128 + # taxes (municipales / scolaires / total, avec année)
129 + tx = _html.unescape(_div(html, "taxes"))
130 + for lab, key in (("Municipales", "Taxes municipales"),
131 + ("Scolaires", "Taxes scolaires")):
132 + mv = re.search(lab + r'\s*:</span></td>\s*<td[^>]*>([\d\s ]+\$)</td>\s*'
133 + r'<td[^>]*>(\d{4})?', tx)
134 + if mv:
135 + details[key] = (re.sub(r"\s+", " ", mv.group(1)).strip()
136 + + (f" ({mv.group(2)})" if mv.group(2) else ""))
137 +
138 + # dimensions : lignes Bâtiment / Terrain — Superficie(s) en m² « (Mètres) »
139 + dims = _html.unescape(_div(html, "dimensions"))
140 + _row = (r'</span></td>\s*<td>([^<]*)</td>\s*<td>([^<]*)</td>\s*'
141 + r'<td>([^<]*)</td>\s*<td>([^<]*)</td>')
142 + mb = re.search(r'Bâtiment' + _row + r'\s*<td>([^<]*)</td>', dims)
143 + if mb:
144 + w, d = mb.group(2).strip(), mb.group(3).strip()
145 + if w and d:
146 + details["Dimensions du bâtiment"] = f"{w} x {d}"
147 + hab = _sqm(mb.group(5))
148 + if hab and hab > 200:
149 + out["area_sqft"] = hab
150 + mt = re.search(r'Terrain' + _row, dims)
151 + if mt:
152 + lot = _sqm(mt.group(4))
153 + if lot:
154 + out["lot_sqft"] = lot
155 +
156 + # pièces : Total | Chambre | S. de bain | S. d'eau
157 + mr = re.search(r"S\.\s*d'eau.*?<tr>\s*<td[^>]*>(\d+)</td>\s*<td[^>]*>(\d+)</td>"
158 + r"\s*<td[^>]*>(\d+)</td>\s*<td[^>]*>(\d+)</td>", unesc, re.S)
159 + if mr:
160 + details["Nombre de pièces"] = mr.group(1)
161 + out["bedrooms"] = int(mr.group(2)) or None
162 + out["bathrooms"] = int(mr.group(3)) or None
163 + out["powder_rooms"] = int(mr.group(4)) or None
164 +
165 + # caractéristiques principales : « Libellé : | Valeur » (2 colonnes) —
166 + # le div contient un div imbriqué (tableautop), on tranche jusqu'à #inclus
167 + i = unesc.find('id="caracteristique_principales"')
168 + j = unesc.find('id="inclus"', max(i, 0))
169 + car = unesc[i:j] if 0 <= i < j else (unesc[i:] if i >= 0 else "")
170 + for lab, val in re.findall(r'sous_titre">([^<:]{2,45}?)\s*:</span></td>\s*'
171 + r'<td[^>]*>([^<]+)</td>', car):
172 + val = re.sub(r"\s+", " ", val).strip()
173 + if val and val.lower() != "s/o":
174 + details[lab.strip()] = val[:200]
175 +
176 + # inclus -> features ; exclus -> details
177 + inc = _text(re.sub(r'<span class="moyen_titre">Inclus</span>', ' ',
178 + _div(html, "inclus")))
179 + feats = [s.strip() for s in re.split(r"[,;]| et ", inc) if 2 <= len(s.strip()) <= 90]
180 + if feats:
181 + out["features"] = feats[:20]
182 + exc = _text(re.sub(r'<span class="moyen_titre">Exclus</span>', ' ',
183 + _div(html, "exclus")))
184 + if exc:
185 + details["Exclusions"] = exc[:300]
186 +
187 + if details:
188 + out["details"] = details
189 + return out
190 +
35 191
36 192 class ImmeubleContactConnector(BaseConnector):
37 193 source_id = "contact_ag_qc"
@@ -51,7 +207,18 @@ class ImmeubleContactConnector(BaseConnector):
51 207 lst = self._card(cost, blk, page)
52 208 if lst:
53 209 by_id.setdefault(lst.external_id, lst)
54 − return list(by_id.values())
210 + listings = list(by_id.values())
211 + # fiche détail : galerie complète, description, caractéristiques,
212 + # courtier — plafonné/cycle, cache BD (voir _detailutil)
213 + du.enrich(self, listings, DETAIL_LIMIT, parse_contact_detail,
214 + key="v1", fetch_html=self._fetch_html)
215 + return listings
216 +
217 + def _fetch_html(self, url: str) -> str:
218 + """GET avec décodage ISO-8859-1 (le site n'envoie pas de charset HTTP)."""
219 + resp = self.get(url)
220 + resp.encoding = "iso-8859-1"
221 + return resp.text
55 222
56 223 def _card(self, cost: str, blk: str, page: str) -> PropertyListing | None:
57 224 mm = _MLS_RE.search(blk)
modified immoka/connectors/kw_distinction.py +160 −56
@@ -2,51 +2,65 @@
2 2 # Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # connectors/kw_distinction.py : Keller Williams Distinction (Gatineau/Outaouais)
5 −# Site ASP.NET (plateforme tonikwebstudio) : la grille de résultats et les
6 −# fiches détail sont rendues via AJAX/UpdatePanel (prix et photos absents du
7 −# HTML statique, et non rendus de façon fiable même par Firecrawl). En revanche
8 −# le sitemap.xml énumère TOUTES les propriétés, et l'URL encode ville, type,
9 −# nombre de chambres, rue et numéro Centris — de quoi bâtir une fiche solide
10 −# au niveau liste. Le numéro Centris sert de clé de déduplication.
5 +# Site ASP.NET (plateforme Olivex/tonikweb). La page « Trouver une propriété »
6 +# est rendue serveur et paginée par querystring (PageID=1..N, ~6 cartes/page,
7 +# total affiché « N résultat(s) ») : cartes complètes — type, prix, adresse,
8 +# ville (secteur), MLS, cac/sdb, résumé, photo. Les fiches détail sont aussi
9 +# rendues serveur : galerie complète (/_upload/photos/{id}-{n}.jpg),
10 +# description, tableau « Détails de la propriété », proximité.
11 +# ⚠️ Ne PAS revenir au sitemap.xml : il est figé (anciennes URL
12 +# « sector--city » qui redirigent toutes vers /Pages/Erreur/) — c'est ce qui
13 +# avait laissé la source à 0 % prix/photos/description.
11 14 # -----------------------------------------------------------------------------
12 15 from __future__ import annotations
13 16
17 +import html as _html
18 +import os
14 19 import re
15 20
16 21 from .base import BaseConnector
22 +from . import _detailutil as du
23 +from ..normalize import parse_price, parse_year
17 24 from ..schema import PropertyListing
18 25
19 26 BASE = "https://www.kwdistinction.com"
20 −SITEMAP = f"{BASE}/sitemap.xml"
21 −# /maisons-a-vendre-{ville}/{type}/{maison-N-chambres}/{rue}/{no-centris}
22 −_URL_RE = re.compile(
23 − r'/maisons?-a-vendre-([a-z0-9-]+)/([a-z0-9-]+)/(?:maison-(\d+)-chambres?|[a-z0-9-]+)'
24 − r'/([a-z0-9-]+)/(\d{6,})', re.I)
27 +LIST = (BASE + "/maison-a-vendre-au-quebec/condo-a-vendre-au-quebec/"
28 + "?intMunicipaliteId=&intTypeHabitation=&curPrixFrom=0&curPrixTo=999999999"
29 + "&PageID={page}&intListeMode=1&intSecteur=&intTri=2")
30 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_KW_DETAIL_LIMIT", "400"))
25 31
26 −_TYPE_MAP = {
27 − "maison-de-plain-pied": "Maison", "maison-a-etages": "Maison",
28 − "maison-a-paliers": "Maison", "maison-a-paliers-multiples": "Maison",
29 − "maison-mobile": "Maison mobile", "appartement": "Condo", "condo": "Condo",
30 − "duplex": "Duplex", "triplex": "Triplex", "quadruplex": "Multiplex",
31 − "quintuplex": "Multiplex", "terrain": "Terrain", "fermette": "Fermette/Agricole",
32 − "chalet": "Chalet", "commercial": "Commercial", "commerciale": "Commercial",
33 −}
32 +_TOTAL_RE = re.compile(r"(\d+)\s*r[ée]sultat", re.I)
33 +_HREF_RE = re.compile(r'href="(/maisons?-a-vendre-[^"]+/(\d{6,}))"')
34 +_TITLE_RE = re.compile(r'class="listing_text_title"[^>]*>(.*?)</p>', re.S)
35 +_PRICE_RE = re.compile(r'class="listing_text_price"[^>]*>(.*?)</p>', re.S)
36 +_ADDR_RE = re.compile(r'class="listing_text_adresse"[^>]*>(.*?)</p>', re.S)
37 +_ROOMS_RE = re.compile(r'class="listing_text_room"[^>]*>(.*?)</p>', re.S)
38 +_DESC_RE = re.compile(r'class="listing_text_description"[^>]*>(.*?)</p>', re.S)
39 +_BED_RE = re.compile(r"Chambres?\s*:\s*(\d+)")
40 +_BATH_RE = re.compile(r"Salles? de bains?\s*:\s*(\d+)")
34 41
42 +# fiche détail
43 +_PHOTO_RE = re.compile(r'/_upload/photos/(\d{6,})-(\d+)\.jpg', re.I)
44 +_DDESC_RE = re.compile(
45 + r'titre_texte_fiche_propriete">\s*Description\s*</p>\s*<p[^>]*>(.*?)</p>',
46 + re.S)
47 +_PROX_RE = re.compile(
48 + r'titre_texte_fiche_propriete">\s*Proximité\s*</p>\s*<p[^>]*>(.*?)</p>', re.S)
49 +_ROW_RE = re.compile(
50 + r'<td class="ProprieteInfoText">\s*(.*?)\s*</td>\s*<td[^>]*>\s*(.*?)\s*</td>',
51 + re.S)
35 52
36 −def _deslug(s: str) -> str:
37 − return re.sub(r"\s+", " ", s.replace("--", " ").replace("-", " ")).strip().title()
38 53
54 +def _txt(s: str) -> str:
55 + return _html.unescape(re.sub(r"<[^>]+>", " ", s or "")).replace("\xa0", " ").strip()
39 56
40 −def _split_city(slug: str) -> tuple[str, str]:
41 − """« aylmer--gatineau- » -> (Gatineau, Aylmer) ; « cayamant » -> (Cayamant, '')."""
42 − slug = slug.strip("-")
43 − fixes = {"Gatineau": "Gatineau", "Quebec": "Québec", "Montreal": "Montréal"}
44 − if "--" in slug:
45 − sector, city = slug.split("--", 1)
46 − city = _deslug(city)
47 − return fixes.get(city, city), _deslug(sector)
48 − city = _deslug(slug)
49 − return fixes.get(city, city), ""
57 +
58 +def _city_sector(label: str) -> tuple[str, str]:
59 + """« Gatineau (Hull) » -> (Gatineau, Hull) ; « Bouchette » -> (Bouchette, '')."""
60 + m = re.match(r"(.+?)\s*\((.+?)\)\s*$", label or "")
61 + if m:
62 + return m.group(1).strip(), m.group(2).strip()
63 + return (label or "").strip(), ""
50 64
51 65
52 66 class KwDistinctionConnector(BaseConnector):
@@ -54,29 +68,119 @@ class KwDistinctionConnector(BaseConnector):
54 68 request_delay = 0.5
55 69
56 70 def fetch(self) -> list[PropertyListing]:
57 − try:
58 − xml = self.get(SITEMAP).text
59 − except Exception:
60 − return []
61 71 by_id: dict[str, PropertyListing] = {}
62 − for m in _URL_RE.finditer(xml):
63 − ville, ptype, beds, rue, centris = m.groups()
64 − if centris in by_id:
65 − continue
66 − city, sector = _split_city(ville)
67 − path = m.group(0)
68 − by_id[centris] = PropertyListing(
69 − source=self.source_id,
70 − external_id=centris,
71 − url=f"{BASE}{path}",
72 − title=_deslug(rue),
73 − address=_deslug(rue),
74 − city=city,
75 − sector=sector,
76 − region="Outaouais",
77 − property_type=_TYPE_MAP.get(ptype.lower(), ""),
78 − bedrooms=int(beds) if beds else None,
79 − mls=centris,
80 − broker_name="Keller Williams Distinction",
81 − )
82 − return list(by_id.values())
72 + total = None
73 + page = 1
74 + while page <= 80: # garde-fou (~6 cartes/page)
75 + html = self.get(LIST.format(page=page)).text
76 + if total is None:
77 + m = _TOTAL_RE.search(html)
78 + total = int(m.group(1)) if m else None
79 + new = 0
80 + for bloc in html.split('class="listing_bloc"')[1:]:
81 + bloc = bloc[:5000] # ne pas déborder sur le pied de page
82 + lst = self._to_listing(bloc)
83 + if lst is not None and lst.external_id not in by_id:
84 + by_id[lst.external_id] = lst
85 + new += 1
86 + if new == 0 or (total and len(by_id) >= total):
87 + break
88 + page += 1
89 + listings = list(by_id.values())
90 + du.enrich(self, listings, DETAIL_LIMIT, parse_kw_distinction_detail, key="v2")
91 + return listings
92 +
93 + def _to_listing(self, bloc: str) -> PropertyListing | None:
94 + m = _HREF_RE.search(bloc)
95 + if not m:
96 + return None
97 + path, cid = m.group(1), m.group(2)
98 + m_title = _TITLE_RE.search(bloc)
99 + m_price = _PRICE_RE.search(bloc)
100 + m_addr = _ADDR_RE.search(bloc)
101 + m_rooms = _ROOMS_RE.search(bloc)
102 + m_desc = _DESC_RE.search(bloc)
103 + address = cityline = ""
104 + if m_addr:
105 + parts = re.split(r"<br\s*/?>", m_addr.group(1))
106 + address = _txt(parts[0])
107 + cityline = _txt(parts[1]) if len(parts) > 1 else ""
108 + city, sector = _city_sector(cityline)
109 + rooms = m_rooms.group(1) if m_rooms else ""
110 + beds = _BED_RE.search(rooms)
111 + baths = _BATH_RE.search(rooms)
112 + price_label = _txt(m_price.group(1)) if m_price else ""
113 + ptype = _txt(m_title.group(1)) if m_title else ""
114 + low = price_label.lower()
115 + status = "vendu" if low.startswith(("vendu", "loué", "loue")) else "a-vendre"
116 + return PropertyListing(
117 + source=self.source_id,
118 + external_id=cid,
119 + url=BASE + path,
120 + title=address or ptype,
121 + status=status,
122 + address=address,
123 + city=city,
124 + sector=sector,
125 + property_type=ptype,
126 + price=parse_price(price_label),
127 + price_label=price_label,
128 + bedrooms=int(beds.group(1)) if beds else None,
129 + bathrooms=int(baths.group(1)) if baths else None,
130 + description=_txt(m_desc.group(1)).rstrip(" .") if m_desc else "",
131 + images=[f"{BASE}/_upload/photos/{cid}-1.jpg"],
132 + mls=cid,
133 + broker_name="Keller Williams Distinction",
134 + agency="Keller Williams Distinction",
135 + )
136 +
137 +
138 +def parse_kw_distinction_detail(html: str) -> dict:
139 + """Fiche détail Olivex : galerie complète, description, tableau « Détails
140 + de la propriété » (chambres, sdb, année, superficies…), proximité."""
141 + out: dict = {}
142 + # galerie : la page contient aussi la photo -1 d'autres inscriptions (menu) ;
143 + # la fiche courante est le seul id avec des numéros de photo > 1
144 + by_id: dict[str, set[int]] = {}
145 + for pid, n in _PHOTO_RE.findall(html):
146 + by_id.setdefault(pid, set()).add(int(n))
147 + cur = max(by_id, key=lambda p: len(by_id[p]), default=None)
148 + if cur and len(by_id[cur]) > 1:
149 + out["images"] = [f"{BASE}/_upload/photos/{cur}-{n}.jpg"
150 + for n in sorted(by_id[cur])]
151 + m = _DDESC_RE.search(html)
152 + if m:
153 + d = _txt(m.group(1))
154 + if len(d) > 40:
155 + out["description"] = d
156 + m = _PROX_RE.search(html)
157 + if m:
158 + feats = [f.strip() for f in _txt(m.group(1)).split(",") if f.strip()]
159 + if feats:
160 + out["features"] = feats
161 + details: dict = {}
162 + for lab, val in _ROW_RE.findall(html):
163 + lab = _txt(lab).rstrip(" :")
164 + val = _txt(val)
165 + if lab and val and len(val) <= 300:
166 + details[lab] = val
167 + if details:
168 + def dget(pattern: str):
169 + return next((v for k, v in details.items()
170 + if re.match(pattern, k, re.I)), None)
171 + # promotion vers les colonnes structurées
172 + if dget(r"Chambres à coucher"):
173 + out["bedrooms"] = du._int(dget(r"Chambres à coucher"))
174 + if dget(r"Salles? de bains?"):
175 + out["bathrooms"] = du._int(dget(r"Salles? de bains?"))
176 + if dget(r"Salles? d.eau"):
177 + out["powder_rooms"] = du._int(dget(r"Salles? d.eau"))
178 + y = parse_year(dget(r"Année de construction") or "")
179 + if y:
180 + out["year_built"] = y
181 + # clé canonique Centris pour finalize() (aire en MC = m², convertie)
182 + aire = dget(r"Aire habitable")
183 + if aire:
184 + details.setdefault("Superficie habitable", aire)
185 + out["details"] = details
186 + return out
modified immoka/connectors/kw_prestige.py +136 −35
@@ -2,26 +2,53 @@
2 2 # Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # connectors/kw_prestige.py : Keller Williams Prestige (kwprestige.com)
5 −# Bannière KW du Grand Montréal. La grille /fr/proprietes/ et les fiches détail
6 −# sont rendues par un widget JS (pagination AJAX, pas d'URL exploitable). Mais
7 −# le sitemap.xml expose TOUTES les fiches : URL
8 −# « {type}-a-vendre-{ville-secteur}-{no-centris}-detail-Fr ». On énumère donc
9 −# par le sitemap (rendu serveur, fiable) ; type/ville/secteur/n° Centris depuis
10 −# le slug. external_id = n° Centris → déduplication.
5 +# Plateforme macleweb.com. La grille /fr/proprietes/ est servie par l'API
6 +# interne /ajax/getProps.php (JSON {html, nbPages}) : cartes complètes —
7 +# prix, adresse, ville (secteur), cac/sdb, mini-description, photo. Les
8 +# fiches détail sont rendues serveur : galerie complète (data-centris/
9 +# large_photo), description, tableau Centris, GPS (iframe maps/embed),
10 +# courtiers inscripteurs.
11 +# ⚠️ Ne PAS revenir au sitemap.xml : il est figé (lastmod 2025-09) et ses
12 +# fiches expirées rendent un gabarit vide (prix « 0 », aucune adresse).
13 +# ⚠️ Le site exige un User-Agent navigateur (curl nu → 403) ; l'UA de
14 +# BaseConnector passe.
11 15 # -----------------------------------------------------------------------------
12 16 from __future__ import annotations
13 17
18 +import html as _html
19 +import os
14 20 import re
15 21
16 22 from .base import BaseConnector
23 +from . import _detailutil as du
24 +from ..normalize import parse_price
17 25 from ..schema import PropertyListing
18 26
19 −SITEMAP = "https://www.kwprestige.com/sitemap.xml"
20 −# {type}-a-vendre-{ville-secteur}-{centris}-detail-Fr
21 −_URL_RE = re.compile(
22 − r'https://www\.kwprestige\.com/([a-z0-9-]+)-a-vendre-([a-z0-9-]+?)-(\d{7,8})-detail-[A-Za-z]+',
23 − re.I)
27 +BASE = "https://www.kwprestige.com"
28 +AJAX = BASE + "/ajax/getProps.php?json=1&default_lang=fr&page={page}&qty=100"
29 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_KW_DETAIL_LIMIT", "400"))
24 30
31 +# carte de la grille : <a href='/{slug}-{id}-detail-Fr' class='proprieteitem'>…</a>
32 +_CARD_RE = re.compile(
33 + r"<a href='(/[^']*?-(\d{6,9})-detail-Fr)' class='proprieteitem'>(.*?)</a>", re.S)
34 +_PRICE_RE = re.compile(r"class='titre2'>(.*?)</p>", re.S)
35 +_ADDR_RE = re.compile(r"class='titresm'>(.*?)</h3>\s*<p>(.*?)</p>", re.S)
36 +_ICON_RE = re.compile(r"</svg>\s*(\d+)\s*</div>")
37 +_MINIDESC_RE = re.compile(r"class='minidescription'>(.*?)</p>", re.S)
38 +_THUMB_RE = re.compile(r"src='(https://www\.kwprestige\.com/data-centris/photo/[^']+)'")
39 +
40 +# fiche détail
41 +_LARGE_RE = re.compile(
42 + r'https://www\.kwprestige\.com/data-centris/large_photo/[^"\'\\ ]+'
43 + r'\.(?:jpg|jpeg|png|webp)', re.I)
44 +_DPRICE_RE = re.compile(r'<p class="prix">(.*?)</p>', re.S)
45 +_DDESC_RE = re.compile(r'fichecdescription">.*?<p>(.*?)</p>', re.S)
46 +_GPS_RE = re.compile(r'!2d(-?\d{2,3}\.\d+)!3d(-?\d{2}\.\d+)')
47 +_BROKER_RE = re.compile(r'courtierfiche">.*?<h3 class="titresm">(.*?)</h3>', re.S)
48 +_PHONE_RE = re.compile(r'href="tel:([+\d.\-]{7,})"')
49 +_ROOM_RE = re.compile(r'class="nbr_(chambres|sdb|pieces)">\s*<img[^>]*>\s*(\d+)')
50 +
51 +# type de propriété depuis le slug d'URL (« {type}-a-vendre-… »)
25 52 _TYPE = [
26 53 ("location-d-espace", "Commercial"), ("location-de", "Commercial"),
27 54 ("location", "Commercial"),
@@ -38,45 +65,119 @@ _TYPE = [
38 65 ("commerce", "Commercial"), ("bureau", "Commercial"),
39 66 ("batisse", "Commercial"), ("industriel", "Commercial"),
40 67 ]
41 −# villes composées fréquentes (sinon 1er token = ville)
42 −_CITY_FIX = {"montreal": "Montréal", "quebec": "Québec", "levis": "Lévis",
43 − "trois": "Trois-Rivières", "saint": "Saint", "sainte": "Sainte"}
68 +
69 +
70 +def _txt(s: str) -> str:
71 + return _html.unescape(re.sub(r"<[^>]+>", " ", s or "")).replace("\xa0", " ").strip()
72 +
73 +
74 +def _city_sector(label: str) -> tuple[str, str]:
75 + """« Montréal (Ville-Marie) » -> (Montréal, Ville-Marie)."""
76 + m = re.match(r"(.+?)\s*\((.+?)\)\s*$", label or "")
77 + if m:
78 + return m.group(1).strip(), m.group(2).strip()
79 + return (label or "").strip(), ""
44 80
45 81
46 82 class KwPrestigeConnector(BaseConnector):
47 83 source_id = "kw_prestige"
48 − request_delay = 0.4
84 + request_delay = 0.5
49 85
50 86 def fetch(self) -> list[PropertyListing]:
51 − try:
52 − xml = self.get(SITEMAP).text
53 − except Exception:
54 − return []
55 87 by_id: dict[str, PropertyListing] = {}
56 − for m in _URL_RE.finditer(xml):
57 − typ_slug, loc_slug, centris = m.group(1), m.group(2), m.group(3)
58 − if centris in by_id:
59 − continue
60 − by_id[centris] = self._to_listing(m.group(0), typ_slug, loc_slug, centris)
61 − return list(by_id.values())
62 −
63 − def _to_listing(self, url, typ_slug, loc_slug, centris) -> PropertyListing:
88 + page = 1
89 + while page <= 50: # garde-fou
90 + data = self.get(AJAX.format(page=page)).json()
91 + for path, cid, block in _CARD_RE.findall(data.get("html") or ""):
92 + if cid not in by_id:
93 + by_id[cid] = self._to_listing(path, cid, block)
94 + if page >= int(data.get("nbPages") or 1):
95 + break
96 + page += 1
97 + listings = list(by_id.values())
98 + du.enrich(self, listings, DETAIL_LIMIT, parse_kw_prestige_detail, key="v3")
99 + return listings
100 +
101 + def _to_listing(self, path: str, cid: str, block: str) -> PropertyListing:
102 + m_price = _PRICE_RE.search(block)
103 + price_label = _txt(m_price.group(1)) if m_price else ""
104 + m_addr = _ADDR_RE.search(block)
105 + addr = _txt(m_addr.group(1)) if m_addr else ""
106 + city, sector = _city_sector(_txt(m_addr.group(2)) if m_addr else "")
107 + nums = _ICON_RE.findall(block)
108 + m_desc = _MINIDESC_RE.search(block)
109 + m_img = _THUMB_RE.search(block)
110 + slug = path.lstrip("/")
111 + typ_slug = re.split(r"-a-(?:vendre|louer)-", slug)[0]
64 112 ptype = ""
65 113 for key, canon in _TYPE:
66 114 if typ_slug.startswith(key):
67 115 ptype = canon
68 116 break
69 − parts = loc_slug.split("-")
70 − city = _CITY_FIX.get(parts[0], parts[0].title()) if parts else ""
71 − sector = " ".join(parts[1:]).title() if len(parts) > 1 else ""
117 + low = price_label.lower()
118 + status = "vendu" if low.startswith(("vendu", "loué", "loue")) else "a-vendre"
72 119 return PropertyListing(
73 − source=self.source_id, external_id=centris, url=url,
74 − title=_deslug(loc_slug), address="", city=city, sector=sector,
75 − region="", property_type=ptype, mls=centris,
120 + source=self.source_id,
121 + external_id=cid,
122 + url=BASE + path,
123 + title=addr or _txt(price_label),
124 + status=status,
125 + address=addr,
126 + city=city,
127 + sector=sector,
128 + property_type=ptype,
129 + price=parse_price(price_label),
130 + price_label=price_label,
131 + bedrooms=int(nums[0]) if nums else None,
132 + bathrooms=int(nums[1]) if len(nums) > 1 else None,
133 + description=_txt(m_desc.group(1)) if m_desc else "",
134 + images=[m_img.group(1)] if m_img else [],
135 + mls=cid,
76 136 broker_name="Keller Williams Prestige",
77 137 agency="Keller Williams Prestige",
78 138 )
79 139
80 140
81 −def _deslug(s: str) -> str:
82 − return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()
141 +def parse_kw_prestige_detail(html: str) -> dict:
142 + """Fiche détail macleweb : galerie complète, prix, description, tableau
143 + Centris, GPS (iframe Google Maps embed), courtiers, cac/sdb/pièces."""
144 + out: dict = {}
145 + seen, imgs = set(), []
146 + for u in _LARGE_RE.findall(html):
147 + if u not in seen:
148 + seen.add(u)
149 + imgs.append(u)
150 + if imgs:
151 + out["images"] = imgs
152 + m = _DPRICE_RE.search(html)
153 + if m:
154 + label = _txt(m.group(1))
155 + price = parse_price(label)
156 + if price: # « 0 » = gabarit vide/expiré
157 + out["price"] = price
158 + out["price_label"] = label
159 + m = _DDESC_RE.search(html)
160 + if m:
161 + d = _txt(m.group(1))
162 + if len(d) > 40:
163 + out["description"] = d
164 + m = _GPS_RE.search(html)
165 + if m:
166 + lng, lat = float(m.group(1)), float(m.group(2))
167 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
168 + out["lat"], out["lng"] = lat, lng
169 + m = _BROKER_RE.search(html)
170 + if m:
171 + out["broker_name"] = _txt(m.group(1))
172 + m = _PHONE_RE.search(html)
173 + if m:
174 + out["broker_phone"] = m.group(1)
175 + for key, n in _ROOM_RE.findall(html):
176 + if key == "chambres":
177 + out["bedrooms"] = int(n)
178 + elif key == "sdb":
179 + out["bathrooms"] = int(n)
180 + det = du.centris_details(du.flatten(html))
181 + if det:
182 + out.setdefault("details", {}).update(det)
183 + return out
modified immoka/connectors/logisquebec.py +112 −12
@@ -27,7 +27,8 @@ from ..normalize import parse_price
27 27 from ..schema import PropertyListing
28 28
29 29 SITE = "https://www.logisquebec.com"
30 −DETAIL_LIMIT = int(os.environ.get("IMMOKA_LOGISQC_DETAIL_LIMIT", "300"))
30 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_LOGISQC_DETAIL_LIMIT",
31 + os.environ.get("IMMOKA_DETAIL_LIMIT", "300")))
31 32 AGENCY = "LogisQuébec"
32 33
33 34 _PAGE_MAX_RE = re.compile(r'href="/a-vendre/(\d+)"')
@@ -35,7 +36,17 @@ _CARD_RE = re.compile(
35 36 r'<a href="/([a-z0-9_-]+?)-a-vendre-([a-z0-9_-]+)-v(\d+)"[^>]*'
36 37 r'class="property-result[^"]*"(.*?)</a>', re.S | re.I)
37 38 _MLS_RE = re.compile(r"(?:MLS|Centris)[^0-9]{0,15}(\d{7,9})", re.I)
38 −_PHOTO_RE = re.compile(r"https://i\.logisquebec\.com/i-a-vendre/[^\"'\\ ]+\.jpg", re.I)
39 +# galerie : la VRAIE galerie est le tableau JS lightGallery « dynamicimgEl »
40 +# dont les src sont RELATIFS (i-a-vendre/<uid>/<vid>/N.jpg, sans https://) —
41 +# un motif ancré sur le domaine ne voit que la photo 1 (og:image), les
42 +# vignettes /thumbnail/ et les couvertures des « propriétés similaires »
43 +# (autres vid). D'où le motif src: + repli absolu filtré sur le vid canonique.
44 +_GALLERY_RE = re.compile(
45 + r"src:\s*'(?:https://i\.logisquebec\.com/)?(i-a-vendre/[^']+?\.jpe?g)'", re.I)
46 +_PHOTO_RE = re.compile(
47 + r"https://i\.logisquebec\.com/(i-a-vendre/(\d+)/(\d+)/([^\"'\\ ]+\.jpe?g))", re.I)
48 +_CANON_RE = re.compile(r'canonical"\s+href="[^"]*-v(\d+)"')
49 +_IMG_HOST = "https://i.logisquebec.com/"
39 50
40 51
41 52 class LogisQuebecConnector(BaseConnector):
@@ -62,7 +73,12 @@ class LogisQuebecConnector(BaseConnector):
62 73 if dry >= 3 or page >= (last_page or 1):
63 74 break
64 75 listings = list(by_id.values())
65 − du.enrich(self, listings, DETAIL_LIMIT, parse_logisqc_detail, key="v1")
76 + # v2 : galerie filtrée sur le vid de la fiche (v1 mélangeait vignettes
77 + # et « propriétés similaires »), specs du tableau Libellé|Valeur,
78 + # description HTML complète, garde-fou GPS Québec. NB : certaines
79 + # fiches encore listées répondent 410 (fantômes côté site) — l'échec
80 + # est mis en cache {} et ne sera retenté qu'au prochain bump de clé.
81 + du.enrich(self, listings, DETAIL_LIMIT, parse_logisqc_detail, key="v2")
66 82 # n° MLS connu -> external_id (dédup Centris contre les bannières)
67 83 out: dict[str, PropertyListing] = {}
68 84 for lst in listings:
@@ -129,7 +145,11 @@ def parse_logisqc_detail(html: str) -> dict:
129 145 details["postal_code"] = str(addr["postalCode"])
130 146 geo = node.get("geo") or {}
131 147 try:
132 − out["lat"], out["lng"] = float(geo["latitude"]), float(geo["longitude"])
148 + lat, lng = float(geo["latitude"]), float(geo["longitude"])
149 + # le géocodage du site déraille parfois (Guadeloupe, Floride…) :
150 + # on ne garde que des coordonnées plausibles pour le Québec
151 + if 44.0 <= lat <= 63.0 and -80.5 <= lng <= -56.0:
152 + out["lat"], out["lng"] = lat, lng
133 153 except (KeyError, TypeError, ValueError):
134 154 pass
135 155 if node.get("numberOfRooms"):
@@ -149,26 +169,106 @@ def parse_logisqc_detail(html: str) -> dict:
149 169 if mm:
150 170 details["_mls"] = mm.group(1)
151 171
152 − seen, imgs = set(), []
153 − for u in _PHOTO_RE.findall(html):
172 + # galerie : tableau lightGallery (src relatifs, ordre de la fiche, pleine
173 + # taille) ; repli sur les URL absolues du vid canonique (sans /thumbnail/
174 + # ni couvertures des « propriétés similaires »)
175 + mc = _CANON_RE.search(html)
176 + want = mc.group(1) if mc else None
177 + seen: set = set()
178 + imgs = []
179 + for rel in _GALLERY_RE.findall(html):
180 + if "/thumbnail/" in rel:
181 + continue
182 + u = _IMG_HOST + rel
154 183 if u not in seen:
155 184 seen.add(u)
156 185 imgs.append(u)
186 + if want:
187 + imgs = [u for u in imgs if f"/{want}/" in u] or imgs
188 + if not imgs:
189 + buckets: dict = {}
190 + for m in _PHOTO_RE.finditer(html):
191 + _rel, _uid, vid, fname = m.groups()
192 + u = m.group(0)
193 + if fname.lower().startswith("thumbnail") or u in seen:
194 + continue
195 + seen.add(u)
196 + buckets.setdefault(vid, []).append(u)
197 + if want and want in buckets:
198 + imgs = buckets[want]
199 + elif buckets:
200 + imgs = max(buckets.values(), key=len)
157 201 if imgs:
158 202 out["images"] = imgs
159 203
160 − # specs du tableau (texte aplati) — best effort
204 + # specs du tableau « Libellé | Valeur » (texte aplati) : Chambre à coucher,
205 + # Salle de bain/d'eau, Pièces, Aire habitable (pc/mc), Dimension terrain…
206 + # (0 et « -- » = non renseigné). Prose « N chambres » gardée en repli.
161 207 text = du.flatten(html)
162 − mb = re.search(r"(\d{1,2})\s*chambre", text, re.I)
163 − if mb:
208 + mb = (re.search(r"Chambre à coucher\s*\|\s*(\d{1,2})\b", text)
209 + or re.search(r"(\d{1,2})\s*chambre", text, re.I))
210 + if mb and int(mb.group(1)) > 0:
164 211 out["bedrooms"] = int(mb.group(1))
165 − ms = re.search(r"(\d{1,2})\s*salle[s]?\s*de\s*bain", text, re.I)
166 − if ms:
212 + ms = (re.search(r"Salle de bain/d[’']eau\s*\|\s*(\d{1,2})\b", text)
213 + or re.search(r"(\d{1,2})\s*salle[s]?\s*de\s*bain", text, re.I))
214 + if ms and int(ms.group(1)) > 0:
167 215 out["bathrooms"] = int(ms.group(1))
168 − my = re.search(r"Année de construction\s*\|?\s*:?\s*\|?\s*(\d{4})", text)
216 + mp2 = re.search(r"Pièces\s*\|\s*(\d{1,2})\b", text)
217 + if mp2 and int(mp2.group(1)) > 0:
218 + details.setdefault("Nombre de pièces", int(mp2.group(1)))
219 + my = re.search(r"Année de construction\s*\|?\s*:?\s*\|?\s*([12]\d{3})\b", text)
169 220 if my:
170 221 out["year_built"] = int(my.group(1))
171 222
223 + ma = re.search(r"Aire habitable\s*\|\s*([\d\s.,]+?)\s*(pc|pi2|pi²|pi|mc|m2|m²)\b",
224 + text, re.I)
225 + if ma:
226 + v = _num(ma.group(1))
227 + if v:
228 + out["area_sqft"] = round(v * (10.7639 if ma.group(2).lower()
229 + in ("mc", "m2", "m²") else 1), 0)
230 +
231 + # « Dimension terrain | 26.02x25.29 mc » (LxW) ou superficie directe
232 + mt = re.search(r"Dimension terrain\s*\|\s*([\d.,]+)\s*[xX]\s*([\d.,]+)"
233 + r"\s*(pc|pi|mc|m)?\b", text)
234 + if mt:
235 + a, b = _num(mt.group(1)), _num(mt.group(2))
236 + if a and b:
237 + f = 10.7639 if (mt.group(3) or "").lower() in ("mc", "m") else 1
238 + out["lot_sqft"] = round(a * b * f, 0)
239 + else:
240 + mt2 = re.search(r"Dimension terrain\s*\|\s*([\d\s.,]+?)\s*(pc|mc)\b", text)
241 + if mt2:
242 + v = _num(mt2.group(1))
243 + if v:
244 + out["lot_sqft"] = round(v * (10.7639 if mt2.group(2).lower() == "mc"
245 + else 1), 0)
246 +
247 + mbat = re.search(r"Dimension bâtiment\s*\|\s*([\d][\d\sxX.,]{1,20}?(?:pc|pi|mc|m)?)\s*\|",
248 + text)
249 + if mbat:
250 + details["Dimension bâtiment"] = mbat.group(1).strip()
251 + for lab in ("Étages", "Garage", "Stationnement privé"):
252 + mv = re.search(re.escape(lab) + r"\s*\|\s*(\d{1,3})\b", text)
253 + if mv and int(mv.group(1)) > 0:
254 + details[lab] = int(mv.group(1))
255 +
256 + # description HTML de la fiche (le JSON-LD Product la tronque souvent)
257 + mdsc = re.search(r'class="content-annonce-description-text"[^>]*>(.*?)</div>',
258 + html, re.S)
259 + if mdsc:
260 + d = re.sub(r"\s+", " ",
261 + _html.unescape(re.sub(r"<[^>]+>", " ", mdsc.group(1)))).strip()
262 + if len(d) > len(out.get("description") or ""):
263 + out["description"] = d[:4000]
264 +
172 265 if details:
173 266 out["details"] = details
174 267 return out
268 +
269 +
270 +def _num(s: str) -> float | None:
271 + try:
272 + return float(s.replace(" ", "").replace(" ", "").replace(",", "."))
273 + except (AttributeError, ValueError):
274 + return None
modified immoka/connectors/publimaison.py +108 −16
@@ -32,16 +32,26 @@ SITE = "https://www.publimaison.ca"
32 32 HASH = ("/show=liste/regions=/villes=/type_propriete=/categories=/prix_min=0"
33 33 "/prix_max=0/caracteristiques=/chambres=0/salles_bain=0/etat=1"
34 34 "/parution=4/construction=5/trier_par=3/existante=true/nbr_item=8")
35 −DETAIL_LIMIT = int(os.environ.get("IMMOKA_PUBLIMAISON_DETAIL_LIMIT", "300"))
35 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_PUBLIMAISON_DETAIL_LIMIT",
36 + os.environ.get("IMMOKA_DETAIL_LIMIT", "300")))
36 37 AGENCY = "Publimaison"
37 38
38 39 _CARD_RE = re.compile(
39 40 r'href="/fr/annonce/(\d+)/([a-z0-9-]+?)-a-vendre-([a-z0-9-]+)"'
40 41 r'\s+title="(\d{6,9}) - ([^"]+?) à vendre"', re.I)
41 42 _TOTAL_RE = re.compile(r"sur\s+(\d{2,6})\s*</p>")
42 −# fiche détail : « original » = pleine résolution (extrabig/extrathumbnail = variantes)
43 +# fiche détail : « original » = pleine résolution (extrabig/extrathumbnail =
44 +# variantes). La galerie fotorama porte data-full="…fr_<type>_original_…" où le
45 +# slug de type suit celui de l'annonce (maison, condo, terrain, duplex, chalet…)
46 +# — NE PAS figer « maison » : 44 % de l'inventaire n'en est pas. L'avatar du
47 +# courtier (fr_original_…, sans slug de type) ne matche aucun des deux motifs.
48 +_GALLERY_RE = re.compile(r'data-full="(https://image\.publimaison\.ca/[^"]+)"', re.I)
43 49 _PHOTO_RE = re.compile(
44 − r"https://image\.publimaison\.ca/fr_maison_original_[^\"'\\ ]+\.jpg", re.I)
50 + r"https://image\.publimaison\.ca/fr_[a-z0-9_]+?_original_[^\"'\\ ]+\.jpg", re.I)
51 +# page /carte : seul le marqueur de la propriété a des littéraux dans LatLng(…)
52 +# (le centre par défaut 46.8032826,-71.242796 passe par des variables)
53 +_LATLNG_RE = re.compile(
54 + r"google\.maps\.LatLng\(\s*(-?\d{1,2}\.\d+)\s*,\s*(-?\d{2,3}\.\d+)\s*\)")
45 55
46 56
47 57 class PublimaisonConnector(BaseConnector):
@@ -72,9 +82,22 @@ class PublimaisonConnector(BaseConnector):
72 82 if total and len(by_id) >= total:
73 83 break
74 84 listings = list(by_id.values())
75 − du.enrich(self, listings, DETAIL_LIMIT, parse_publimaison_detail, key="v1")
85 + # v2 : galerie toutes catégories (v1 ne captait que fr_maison_*),
86 + # description fiche-description, prix d'en-tête, GPS via /carte
87 + du.enrich(self, listings, DETAIL_LIMIT, parse_publimaison_detail,
88 + key="v2", fetch_html=self._detail_html)
76 89 return listings
77 90
91 + def _detail_html(self, url: str) -> str:
92 + """Fiche détail + onglet /carte (marqueur Google Maps -> lat/lng) ;
93 + la carte est best-effort : son échec ne coûte pas la fiche."""
94 + html = self.get(url).text
95 + try:
96 + html += "\n" + self.get(url.rstrip("/") + "/carte").text
97 + except Exception:
98 + pass
99 + return html
100 +
78 101 # -- cartes de résultats ----------------------------------------------------
79 102 def _cards(self, html: str):
80 103 for blk in html.split('maison_resultat_recherche')[1:]:
@@ -118,29 +141,84 @@ class PublimaisonConnector(BaseConnector):
118 141
119 142 # ---------------------------------------------------------------------------
120 143 def parse_publimaison_detail(html: str) -> dict:
121 − """Fiche détail (HTML pur) : galerie extralarge, code postal, année,
122 − pièces et dimensions, taxes/évaluation, superficies."""
144 + """Fiche détail (HTML pur, + page /carte concaténée) : galerie originale
145 + complète (fotorama data-full, ordre de la fiche), code postal, année, prix,
146 + pièces et dimensions, taxes/évaluation, superficies, GPS du marqueur."""
123 147 out: dict = {}
124 148 details: dict = {}
125 149
126 150 seen, imgs = set(), []
127 − for u in _PHOTO_RE.findall(html):
151 + for u in (_GALLERY_RE.findall(html) or _PHOTO_RE.findall(html)):
128 152 if u not in seen:
129 153 seen.add(u)
130 154 imgs.append(u)
131 155 if imgs:
132 156 out["images"] = imgs
133 157
158 + # GPS : marqueur de la page /carte (littéraux), borné au Québec ;
159 + # on saute le centre par défaut de la carte au cas où.
160 + for mlat, mlng in _LATLNG_RE.findall(html):
161 + try:
162 + lat, lng = float(mlat), float(mlng)
163 + except ValueError:
164 + continue
165 + if (44.0 <= lat <= 63.0 and -80.5 <= lng <= -56.0
166 + and (round(lat, 4), round(lng, 4)) != (46.8033, -71.2428)):
167 + out["lat"], out["lng"] = lat, lng
168 + break
169 +
134 170 text = du.flatten(html)
135 171
172 + # prix d'en-tête « … J7Y0A5 (No. MLS …) | 324 900 $ | Détails | … »
173 + # (les cartes « Prix sur demande » ont parfois un prix sur la fiche)
174 + mp = re.search(r"\|\s*([\d][\d\s]{1,11}\$)\s*\|\s*Détails\b", text)
175 + if mp:
176 + p = parse_price(mp.group(1))
177 + if p:
178 + out["price"] = p
179 + out["price_label"] = re.sub(r"\s+", " ", mp.group(1)).strip()
180 +
136 181 # « 128 Rue Léonard, Châteauguay (Montérégie) J6K1N8 (No. MLS 14082277) »
137 182 mh = re.search(r"([A-Z]\d[A-Z]\s?\d[A-Z]\d)\s*(?:\(No\. MLS|\|)", text)
138 183 if mh:
139 184 details["postal_code"] = mh.group(1).strip()
140 185
141 − my = re.search(r"Année de construction\s*\|\s*(\d{4})", text)
186 + # bloc « Données de base » (Libellé | Valeur, N/D = absent) :
187 + # Construction | 2007 | … | Pièces | 8 | Chambre(s) | 2 | Salle(s) de bain | 1
188 + my = re.search(r"(?:Année de construction|Construction)\s*\|\s*((?:1[6-9]|20)\d{2})\b", text)
142 189 if my:
143 190 out["year_built"] = int(my.group(1))
191 + mb2 = re.search(r"Chambre\(s\)\s*\|\s*(\d{1,2})\b", text)
192 + if mb2 and int(mb2.group(1)) > 0:
193 + out["bedrooms"] = int(mb2.group(1))
194 + ms2 = re.search(r"Salle\(s\) de bain\s*\|\s*(\d{1,2})\b", text)
195 + if ms2 and int(ms2.group(1)) > 0:
196 + out["bathrooms"] = int(ms2.group(1))
197 + mse = re.search(r"Salle\(s\) d[’']eau\s*\|\s*(\d{1,2})\b", text)
198 + if mse and int(mse.group(1)) > 0:
199 + out["powder_rooms"] = int(mse.group(1))
200 + mpc = re.search(r"Pièces\s*\|\s*(\d{1,2})\b", text)
201 + if mpc and int(mpc.group(1)) > 0:
202 + details["Nombre de pièces"] = int(mpc.group(1))
203 + met = re.search(r"Nombre d[’']étage\(s\)\s*\|\s*(\d{1,2})\b", text)
204 + if met and int(met.group(1)) > 0:
205 + details["Étages"] = int(met.group(1))
206 + for lab in ("Dimensions du bâtiment", "Dimensions du terrain"):
207 + mdim = re.search(re.escape(lab) + r"\s*\|\s*([\d][^|]{0,24}?)\s*\|", text)
208 + if mdim:
209 + val = mdim.group(1).strip()
210 + details[lab] = val
211 + if lab == "Dimensions du terrain" and "lot_sqft" not in out:
212 + mlx = re.match(r"([\d.,]+)\s*[xX]\s*([\d.,]+)\s*(pi|m)\b", val)
213 + if mlx:
214 + try:
215 + a = float(mlx.group(1).replace(",", "."))
216 + b = float(mlx.group(2).replace(",", "."))
217 + except ValueError:
218 + a = b = 0.0
219 + if a and b:
220 + f = 10.7639 if mlx.group(3) == "m" else 1
221 + out["lot_sqft"] = round(a * b * f, 0)
144 222
145 223 for label, key in [("Évaluation totale", "Évaluation municipale"),
146 224 ("Taxes municipales", "Taxes municipales"),
@@ -150,7 +228,7 @@ def parse_publimaison_detail(html: str) -> dict:
150 228 details[key] = mv.group(1).strip()
151 229
152 230 # superficies (habitable / terrain), formats « NNNN pi² » ou « N X M pi »
153 − ma = re.search(r"Superficie habitable\s*\|\s*([\d\s,.]+)\s*pi", text)
231 + ma = re.search(r"(?:Superficie habitable|Sup\. habitable)\s*\|\s*([\d\s,.]+)\s*pi", text)
154 232 if ma:
155 233 try:
156 234 out["area_sqft"] = float(ma.group(1).replace(" ", "").replace(",", "."))
@@ -163,22 +241,36 @@ def parse_publimaison_detail(html: str) -> dict:
163 241 except ValueError:
164 242 pass
165 243
166 − # tableau des pièces : « Nom | Niveau | L X l pi | Revêtement »
244 + # tableau des pièces : « Nom | Niveau | L X l pi | Revêtement » — le niveau
245 + # est libre (Rez-de-jardin, Mezzanine…), c'est la cellule dimensions qui ancre
167 246 rooms = []
168 247 for rm in re.finditer(
169 − r"\|\s*([A-ZÀ-Ü][^|]{2,28}?)\s*\|\s*(Sous-sol|Rez-de-chauss[ée]e|"
170 − r"[1-9]er? étage|2e étage|3e étage)\s*\|\s*([\d.,]+\s*X\s*[\d.,]+\s*pi)"
171 − r"\s*\|\s*([^|]{0,25}?)\s*\|", text):
248 + r"\|\s*([A-ZÀ-Ü][^|]{1,28}?)\s*\|\s*([A-Za-zÀ-ü][A-Za-zÀ-ü0-9'’ -]{1,20}?)"
249 + r"\s*\|\s*([\d.,]+\s*X\s*[\d.,]+\s*(?:pi|m))\s*\|\s*([^|]{0,25}?)\s*(?=\|)", text):
172 250 rooms.append({"nom": rm.group(1).strip(), "niveau": rm.group(2).strip(),
173 251 "dimensions": rm.group(3).strip(),
174 252 "revetement": rm.group(4).strip()})
175 253 if rooms:
176 254 details["pieces"] = rooms[:30]
255 + # comptes dérivés du tableau des pièces (appliqués seulement si le
256 + # champ manque encore — apply_detail n'écrase jamais une valeur)
257 + nb_ch = sum(1 for r in rooms if r["nom"].lower().startswith("chambre"))
258 + nb_sdb = sum(1 for r in rooms if r["nom"].lower().startswith("salle de bain"))
259 + nb_se = sum(1 for r in rooms
260 + if r["nom"].lower().startswith(("salle d'eau", "salle d’eau")))
261 + if nb_ch and "bedrooms" not in out:
262 + out["bedrooms"] = nb_ch
263 + if nb_sdb and "bathrooms" not in out:
264 + out["bathrooms"] = nb_sdb
265 + if nb_se and "powder_rooms" not in out:
266 + out["powder_rooms"] = nb_se
177 267
178 − # description longue de la fiche (souvent plus complète que la carte)
179 − md = re.search(r'class="description[^"]*"[^>]*>(.*?)</(?:div|p)>', html, re.S)
268 + # description longue de la fiche : bloc « fiche-description » (h4 + <div>
269 + # de <p>) — le lien statcounter « Voir … » est retiré avant l'aplatissage
270 + md = re.search(r'class="fiche-description[^"]*".*?<div>(.*?)</div>', html, re.S)
180 271 if md:
181 − d = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", md.group(1)))).strip()
272 + d = re.sub(r"<a\b.*?</a>", " ", md.group(1), flags=re.S)
273 + d = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", d))).strip()
182 274 if len(d) > 60:
183 275 out["description"] = d[:4000]
184 276
modified immoka/connectors/realtor_ca.py +169 −3
@@ -21,19 +21,33 @@
21 21 # activent la dédup existante — toute fiche déjà couverte par un connecteur
22 22 # direct (RE/MAX, Royal LePage, Sutton…) est masquée, seules les fiches
23 23 # UNIQUES (petites bannières sans connecteur) restent visibles.
24 +#
25 +# ENRICHISSEMENT DÉTAIL (2026-08-18) : la réponse PropertySearch_Post ne
26 +# contient qu'UNE photo et un PublicRemarks VIDE (vérifié) — la galerie
27 +# complète, la description et les caractéristiques Building/Land viennent de
28 +# l'API Listing.svc/PropertyDetails (GET, mêmes protections → Scrapfly ASP
29 +# sans render_js, 1 crédit-appel), rejouée avec CultureId=2 (français) via
30 +# ReferenceNumber=<MLS> + PropertyID=<Id interne>. Cache BD detail_cache
31 +# (_detailutil.enrich) : chaque fiche n'est détaillée qu'UNE fois ; budget
32 +# IMMOKA_REALTOR_DETAIL_LIMIT appels/cycle (défaut 100, override ponctuel
33 +# IMMOKA_DETAIL_LIMIT) → rattrapage progressif du stock, nouveautés ensuite.
24 34 # -----------------------------------------------------------------------------
25 35 from __future__ import annotations
26 36
27 37 import json
28 38 import os
29 39
40 +from . import _detailutil as du
30 41 from .base import BaseConnector
31 42 from ..schema import PropertyListing
32 43
33 44 SEARCH_URL = "https://api2.realtor.ca/Listing.svc/PropertySearch_Post"
45 +DETAIL_URL = "https://api2.realtor.ca/Listing.svc/PropertyDetails"
34 46 SITE = "https://www.realtor.ca"
35 47
36 48 MAX_RECORDS = int(os.environ.get("IMMOKA_REALTOR_MAX", "2000"))
49 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_DETAIL_LIMIT")
50 + or os.environ.get("IMMOKA_REALTOR_DETAIL_LIMIT", "100"))
37 51 PER_PAGE = 200 # maximum accepté par l'API
38 52 MAX_PAGES_PER_SHARD = 3 # plafond serveur : MaxRecords=600 par recherche
39 53
@@ -59,8 +73,13 @@ SHARDS: list[tuple[str, float, float, float, float]] = [
59 73
60 74 class RealtorCaConnector(BaseConnector):
61 75 source_id = "realtor_ag_ca"
62 − request_delay = 0.2 # Scrapfly gère la politesse côté cible
63 − use_detail_cache = False # tout est déjà dans la réponse liste
76 + request_delay = 0.2 # Scrapfly gère la politesse côté cible
77 + use_detail_cache = True # galerie/description via PropertyDetails, cachées
78 +
79 + def __init__(self) -> None:
80 + super().__init__()
81 + # external_id -> Id interne Realtor (PropertyID requis par l'API détail)
82 + self._prop_ids: dict[str, str] = {}
64 83
65 84 def _search(self, shard: tuple, page: int) -> dict:
66 85 _, south, west, north, east = shard
@@ -114,7 +133,57 @@ class RealtorCaConnector(BaseConnector):
114 133 by_id[lst.external_id] = lst
115 134 if len(by_id) >= MAX_RECORDS:
116 135 break
117 − return list(by_id.values())
136 + listings = list(by_id.values())
137 + self._enrich(listings)
138 + return listings
139 +
140 + # -- détail : galerie complète + description + caractéristiques -----------
141 + def _fetch_detail(self, mls: str, pid: str) -> str:
142 + """JSON brut de l'API PropertyDetails (CultureId=2 → contenu français)."""
143 + result = self.scrapfly(
144 + f"{DETAIL_URL}?ReferenceNumber={mls}&PropertyID={pid}"
145 + "&ApplicationId=1&CultureId=2&PreferedMeasurementUnit=1",
146 + render_js=False, asp=True,
147 + headers={"Referer": "https://www.realtor.ca/",
148 + "Origin": "https://www.realtor.ca"})
149 + if result.get("status_code") != 200:
150 + raise RuntimeError(f"PropertyDetails {mls}: "
151 + f"HTTP {result.get('status_code')}")
152 + return result.get("content") or ""
153 +
154 + def _enrich(self, listings: list[PropertyListing]) -> None:
155 + """Complète chaque fiche via PropertyDetails, avec cache BD + budget.
156 +
157 + Contrairement à _detailutil.enrich, un échec (challenge ASP, JSON
158 + invalide) n'est JAMAIS mis en cache : la fiche est retentée au
159 + prochain cycle. Une fiche détaillée avec succès ne coûte plus rien.
160 + """
161 + if DETAIL_LIMIT <= 0:
162 + return
163 + from .. import db
164 + con = db.connect()
165 + budget = DETAIL_LIMIT
166 + try:
167 + for lst in listings:
168 + payload = db.get_cached_detail(con, self.source_id,
169 + lst.external_id, "v1")
170 + if payload is None:
171 + pid = self._prop_ids.get(lst.external_id, "")
172 + mls = lst.mls or lst.external_id
173 + if budget <= 0 or not pid or not mls:
174 + continue
175 + budget -= 1
176 + try:
177 + payload = _parse_detail(self._fetch_detail(mls, pid))
178 + except Exception:
179 + payload = {}
180 + if not payload:
181 + continue # échec transitoire : nouvel essai plus tard
182 + db.put_cached_detail(con, self.source_id,
183 + lst.external_id, "v1", payload)
184 + du.apply_detail(lst, payload)
185 + finally:
186 + con.close()
118 187
119 188 def _to_listing(self, item: dict) -> PropertyListing | None:
120 189 prop = item.get("Property") or {}
@@ -171,12 +240,19 @@ class RealtorCaConnector(BaseConnector):
171 240 lst.bathrooms = int(building.get("BathroomTotal"))
172 241 except (TypeError, ValueError):
173 242 pass
243 + try:
244 + lst.powder_rooms = int(building.get("HalfBathTotal"))
245 + except (TypeError, ValueError):
246 + pass
174 247 size = building.get("SizeInterior") or ""
175 248 if size:
176 249 lst.details["Superficie habitable"] = size
177 250 land = (item.get("Land") or {}).get("SizeTotal") or ""
178 251 if land:
179 252 lst.details["Superficie du terrain"] = land
253 + alt = (item.get("AlternateURL") or {}).get("DetailsLink") or ""
254 + if alt:
255 + lst.details["Annonce originale"] = alt
180 256 lst.images = [p.get("HighResPath") or p.get("MedResPath")
181 257 for p in (prop.get("Photo") or []) if isinstance(p, dict)]
182 258 lst.images = [u for u in lst.images if u]
@@ -188,4 +264,94 @@ class RealtorCaConnector(BaseConnector):
188 264 if phones:
189 265 p0 = phones[0]
190 266 lst.broker_phone = f"{p0.get('AreaCode', '')} {p0.get('PhoneNumber', '')}".strip()
267 + if rid: # Id interne Realtor, requis par l'API PropertyDetails
268 + self._prop_ids[lst.external_id] = rid
191 269 return lst
270 +
271 +
272 +# libellés français des caractéristiques structurées de PropertyDetails
273 +_DETAIL_LABELS = [
274 + # (section, clé API, libellé Immo-Ka)
275 + ("Building", "ConstructedDate", "Année de construction"),
276 + ("Building", "SizeInterior", "Superficie habitable"),
277 + ("Building", "StoriesTotal", "Nombre d'étages"),
278 + ("Building", "ConstructionStyleAttachment", "Type de bâtiment"),
279 + ("Building", "HeatingType", "Système de chauffage"),
280 + ("Building", "HeatingFuel", "Énergie pour le chauffage"),
281 + ("Building", "CoolingType", "Climatisation"),
282 + ("Building", "ExteriorFinish", "Revêtement"),
283 + ("Building", "RoofMaterial", "Toiture"),
284 + ("Building", "FoundationType", "Fondations"),
285 + ("Building", "BasementType", "Sous-sol"),
286 + ("Building", "FireplaceTotal", "Foyers"),
287 + ("Building", "Water", "Approvisionnement en eau"),
288 + ("Land", "SizeTotal", "Superficie du terrain"),
289 + ("Land", "Sewer", "Système d'égouts"),
290 + ("Land", "LandscapeFeatures", "Aménagement paysager"),
291 + ("Property", "PoolType", "Piscine"),
292 + ("Property", "ZoningType", "Zonage"),
293 + ("Property", "ParkingSpaceTotal", "Stationnement (total)"),
294 + ("Property", "OwnershipType", "Type de copropriété"),
295 + ("Property", "TaxTotal", "Taxes annuelles"),
296 +]
297 +
298 +
299 +def _parse_detail(content: str) -> dict:
300 + """Réponse PropertyDetails (JSON) → payload compatible du.apply_detail."""
301 + try:
302 + d = json.loads(content or "")
303 + except ValueError:
304 + return {}
305 + if not isinstance(d, dict) or not d.get("Id"):
306 + return {}
307 + prop = d.get("Property") or {}
308 + sections = {"Property": prop, "Building": d.get("Building") or {},
309 + "Land": d.get("Land") or {}}
310 + out: dict = {}
311 + images = [p.get("HighResPath") or p.get("MedResPath")
312 + for p in (prop.get("Photo") or []) if isinstance(p, dict)]
313 + images = [u for u in images if u]
314 + if images:
315 + out["images"] = images
316 + desc = (d.get("PublicRemarks") or "").strip()
317 + if desc:
318 + out["description"] = desc
319 + details: dict = {}
320 + for section, key, label in _DETAIL_LABELS:
321 + val = sections[section].get(key)
322 + if val not in (None, "", 0, "0"):
323 + details[label] = str(val)
324 + parking = [p.get("Name") for p in (prop.get("Parking") or [])
325 + if isinstance(p, dict) and p.get("Name")]
326 + if parking:
327 + details["Stationnement"] = ", ".join(dict.fromkeys(parking))
328 + rooms = (sections["Building"].get("Room") or [])
329 + if rooms:
330 + details["Nombre de pièces"] = str(len(rooms))
331 + alt = (d.get("AlternateURL") or {}).get("DetailsLink") or ""
332 + if alt:
333 + details["Annonce originale"] = alt
334 + if details:
335 + out["details"] = details
336 + feats: list[str] = []
337 + for blob in (prop.get("Features"), prop.get("AmmenitiesNearBy")):
338 + for f in (blob or "").split(","):
339 + if f.strip():
340 + feats.append(f.strip())
341 + if feats:
342 + out["features"] = feats
343 + b = sections["Building"]
344 + for src, dst in (("ConstructedDate", "year_built"),
345 + ("HalfBathTotal", "powder_rooms")):
346 + v = du._int(b.get(src))
347 + if v:
348 + out[dst] = v
349 + ind = (d.get("Individual") or [{}])[0]
350 + if ind.get("Name"):
351 + out["broker_name"] = ind["Name"]
352 + for p in ind.get("Phones") or []:
353 + if p.get("PhoneNumber"):
354 + out["broker_phone"] = (f"{p.get('AreaCode', '')} "
355 + f"{p['PhoneNumber']}").strip()
356 + break
357 + return out
modified immoka/connectors/sourceimmo.py +247 −7
@@ -8,10 +8,23 @@
8 8 # avec les en-têtes x-si-account / x-si-api / x-si-appId (exposés dans la
9 9 # config publique {_configs.json} du site). Pagination par `next_token`.
10 10 # Une sous-classe fournit account_id / api_key / app_id / view_id.
11 +#
12 +# ENRICHISSEMENT DÉTAIL (fiche complète) : l'endpoint
13 +# GET .../listing/view/{view}/{locale}/items/ref_number/{ref}
14 +# retourne TOUT : galerie complète (blob.source.immo, variante -lg) avec
15 +# étiquettes de pièces, description longue + addendum, ville/secteur résolus
16 +# (dictionary), taxes et frais (expenses), pièces avec dimensions (rooms),
17 +# attributs traduits (attribute_value), courtier + téléphone, inclusions/
18 +# exclusions, lien Centris original. Mise en cache BD (detail_cache) — seules
19 +# les fiches nouvelles ou modifiées (prix/photo de couverture) sont re-lues.
11 20 # -----------------------------------------------------------------------------
12 21 from __future__ import annotations
13 22
23 +import os
24 +import re
25 +
14 26 from .base import BaseConnector
27 +from ._detailutil import apply_detail
15 28 from ..schema import PropertyListing
16 29
17 30 API_ROOT = "https://api-v1.source.immo/api"
@@ -27,6 +40,21 @@ _SUBCATEGORY = {
27 40 "2X": "Duplex", "3X": "Triplex", "4X": "Quadruplex", "5X": "Quintuplex",
28 41 }
29 42
43 +# « Montréal (Verdun/Île-des-Sœurs) » -> ville + secteur
44 +_CITY_SECTOR_RE = re.compile(r"^(.*?)\s*\((.*)\)\s*$")
45 +
46 +
47 +def _fmt_money(amount) -> str:
48 + try:
49 + return f"{float(amount):,.0f} $".replace(",", " ")
50 + except (TypeError, ValueError):
51 + return ""
52 +
53 +
54 +def _big(url: str) -> str:
55 + """Variante grande taille d'une photo blob.source.immo (-sm/-md -> -lg)."""
56 + return re.sub(r"-(?:sm|md)\.(jpg|jpeg|png|webp)$", r"-lg.\1", url or "")
57 +
30 58
31 59 class SourceImmoConnector(BaseConnector):
32 60 """Base : définir source_id, account_id, api_key, app_id, view_id, site_url."""
@@ -38,8 +66,11 @@ class SourceImmoConnector(BaseConnector):
38 66 site_url = ""
39 67 locale = "fr"
40 68 request_delay = 0.25
41 − use_detail_cache = False
69 + use_detail_cache = True
42 70 max_pages = 400 # garde-fou (100 items/page)
71 + # fiches détail (re)lues au plus par synchronisation — le cache BD rend les
72 + # cycles suivants quasi gratuits. IMMOKA_DETAIL_LIMIT pour un rattrapage.
73 + detail_limit = 400
43 74
44 75 def _headers(self) -> dict:
45 76 return {
@@ -79,9 +110,213 @@ class SourceImmoConnector(BaseConnector):
79 110 break
80 111 prev_token = token
81 112 params = {"st": meta.get("search_token") or "", "nt": token}
113 + self._enrich_details(out)
114 + return out
115 +
116 + # -- fiche détail (galerie complète, taxes, pièces, ville…) ----------------
117 + def _enrich_details(self, listings: list[PropertyListing]) -> None:
118 + budget = int(os.environ.get("IMMOKA_DETAIL_LIMIT", self.detail_limit))
119 + if budget <= 0 or not self.view_id:
120 + return
121 + from .. import db
122 + con = db.connect()
123 + try:
124 + for lst in listings:
125 + # clé de changement : prix + photo de couverture (les fiches
126 + # modifiées à la source sont re-lues, les autres viennent du cache)
127 + key = f"v1:{lst.price or ''}:{lst.details.get('cover_thumb', '')}"
128 + cached = db.get_cached_detail(con, self.source_id,
129 + lst.external_id, key)
130 + if cached is None:
131 + stale = db.get_stale_detail(con, self.source_id, lst.external_id)
132 + if budget <= 0:
133 + if stale:
134 + self._apply(lst, stale)
135 + continue
136 + try:
137 + raw = self.get(
138 + f"{API_ROOT}/listing/view/{self.view_id}/"
139 + f"{self.locale}/items/ref_number/{lst.external_id}",
140 + headers=self._headers()).json()
141 + cached = self._detail_payload(raw)
142 + except Exception:
143 + cached = {}
144 + if not cached and stale:
145 + # échec transitoire : on garde l'ancien payload
146 + self._apply(lst, stale)
147 + budget -= 1
148 + continue
149 + db.put_cached_detail(con, self.source_id, lst.external_id,
150 + key, cached)
151 + budget -= 1
152 + self._apply(lst, cached)
153 + finally:
154 + con.close()
155 +
156 + def _apply(self, lst: PropertyListing, payload: dict) -> None:
157 + if not payload:
158 + return
159 + # ville/secteur résolus par le détail : plus fiables que la liste
160 + for f in ("city", "sector"):
161 + if payload.get(f):
162 + setattr(lst, f, payload[f])
163 + apply_detail(lst, payload)
164 +
165 + def _detail_payload(self, d: dict) -> dict:
166 + """Payload compact (JSON-sérialisable, mis en cache) depuis la fiche API."""
167 + if not isinstance(d, dict) or not d.get("ref_number"):
168 + return {}
169 + dic = d.get("dictionary") or {}
170 +
171 + def cap(group: str, code) -> str:
172 + return ((dic.get(group) or {}).get(str(code)) or {}).get("caption", "") \
173 + if code is not None else ""
174 +
175 + out: dict = {}
176 + # -- galerie complète, meilleure résolution, ordre source + étiquettes --
177 + photos = d.get("photos") or []
178 + images, captions = [], []
179 + for p in photos:
180 + u = _big(p.get("url") or "")
181 + if u:
182 + images.append(u)
183 + captions.append(cap("photo_category", p.get("category_code")))
184 + if images:
185 + out["images"] = images
186 + details: dict = {}
187 + if any(captions):
188 + details["photo_captions"] = captions
189 +
190 + # -- description longue (+ addendum) ------------------------------------
191 + desc = (d.get("description") or "").strip()
192 + addendum = (d.get("addendum") or "").strip()
193 + if addendum and addendum not in desc:
194 + desc = (desc + "\n\n" + addendum).strip()
195 + if desc:
196 + out["description"] = desc
197 +
198 + # -- localisation précise ------------------------------------------------
199 + loc = d.get("location") or {}
200 + addr = loc.get("address") or {}
201 + street = " ".join(filter(None, [addr.get("street_number"),
202 + addr.get("street_name")])).strip()
203 + if addr.get("door") and street:
204 + street = f"{addr['door']}-{street}"
205 + if street:
206 + out["address"] = street
207 + if addr.get("postal_code"):
208 + details["postal_code"] = addr["postal_code"]
209 + city_caption = cap("city", loc.get("city_code"))
210 + if city_caption:
211 + m = _CITY_SECTOR_RE.match(city_caption)
212 + if m:
213 + out["city"], out["sector"] = m.group(1), m.group(2)
214 + else:
215 + out["city"] = city_caption
216 + region = cap("region", loc.get("region_code"))
217 + if region:
218 + details["region"] = region
219 + if loc.get("latitude") and loc.get("longitude"):
220 + out["lat"], out["lng"] = loc["latitude"], loc["longitude"]
221 +
222 + # -- pièces/chambres/année/superficies (unité principale) ---------------
223 + units = d.get("units") or []
224 + main = next((u for u in units if u.get("category_code") == "MAIN"),
225 + units[0] if units else {})
226 + for src_k, dst_k in (("bedroom_count", "bedrooms"),
227 + ("bathroom_count", "bathrooms"),
228 + ("waterroom_count", "powder_rooms")):
229 + if main.get(src_k) is not None:
230 + out[dst_k] = main[src_k]
231 + if main.get("room_count"):
232 + details["Nombre de pièces"] = main["room_count"]
233 + b = d.get("building") or {}
234 + if b.get("construction_year"):
235 + out["year_built"] = b["construction_year"]
236 + if b.get("total_levels"):
237 + details["Nombre d'étages"] = b["total_levels"]
238 + if (b.get("dimension") or {}).get("area_sf"):
239 + out["area_sqft"] = b["dimension"]["area_sf"]
240 + land = d.get("land") or {}
241 + if (land.get("dimension") or {}).get("area_sf"):
242 + out["lot_sqft"] = land["dimension"]["area_sf"]
243 +
244 + # -- coûts : taxes, frais, évaluation municipale -------------------------
245 + _period = {"Y": "an", "M": "mois", "W": "sem."}
246 + for e in d.get("expenses") or []:
247 + label = cap("expense_type", e.get("type_code")) or e.get("type_code")
248 + money = _fmt_money(e.get("amount"))
249 + if not (label and money):
250 + continue
251 + per = _period.get(e.get("period_code") or "", "")
252 + year = f" ({e['year']})" if e.get("year") else ""
253 + details[label] = f"{money}/{per}{year}" if per else f"{money}{year}"
254 + assess = (d.get("assessment") or {}).get("amount") or (
255 + ((b.get("assessment") or {}).get("amount") or 0)
256 + + ((land.get("assessment") or {}).get("amount") or 0)) or None
257 + if assess:
258 + year = (d.get("assessment") or {}).get("year") or \
259 + (b.get("assessment") or {}).get("year")
260 + details["Évaluation municipale"] = _fmt_money(assess) + (
261 + f" ({year})" if year else "")
262 +
263 + # -- inclusions / exclusions / lien original / visites -------------------
264 + if d.get("inclusions"):
265 + details["Inclusions"] = str(d["inclusions"])[:600]
266 + if d.get("exclusions"):
267 + details["Exclusions"] = str(d["exclusions"])[:600]
268 + links = d.get("links") or {}
269 + if links.get("website"):
270 + details["listing_origin_url"] = links["website"]
271 + for k, lab in (("virtual_tour", "Visite virtuelle"), ("video", "Vidéo")):
272 + if links.get(k):
273 + details[lab] = links[k]
274 +
275 + # -- caractéristiques traduites (attributs) + pièces ---------------------
276 + features: list[str] = []
277 + for a in d.get("attributes") or []:
278 + group = cap("attribute", a.get("code")) or (a.get("code") or "").title()
279 + vals = [cap("attribute_value", (v or {}).get("code"))
280 + or str((v or {}).get("code") or "")
281 + for v in a.get("values") or []]
282 + vals = [v for v in vals if v]
283 + if group and vals:
284 + features.append(f"{group} : {', '.join(vals)}")
285 + _unit = {"I": "po", "F": "pi", "M": "m"}
286 + for r in d.get("rooms") or []:
287 + name = cap("room_category", r.get("category_code"))
288 + if not name:
289 + continue
290 + dim = r.get("dimension") or {}
291 + w, l = dim.get("width"), dim.get("length")
292 + u = _unit.get(dim.get("unit_code") or "", "pi")
293 + size = ""
294 + if w and l:
295 + if u == "po": # dimensions en pouces -> pieds,pouces lisibles
296 + size = f" — {int(l // 12)},{int(l % 12)} × {int(w // 12)},{int(w % 12)} pi"
297 + else:
298 + size = f" — {l:g} × {w:g} {u}"
299 + floor = cap("flooring", r.get("flooring_code"))
300 + features.append(f"{name}{size}" + (f" ({floor})" if floor else ""))
301 + if features:
302 + out["features"] = features
303 +
304 + # -- courtier inscripteur (nom + téléphone) -------------------------------
305 + brokers = d.get("brokers") or []
306 + if brokers:
307 + bk = brokers[0]
308 + name = bk.get("name") or " ".join(
309 + filter(None, [bk.get("first_name"), bk.get("last_name")]))
310 + phone = next(iter((bk.get("phones") or {}).values()), "")
311 + if name:
312 + details["Courtier inscripteur"] = name + (f" — {phone}" if phone else "")
313 + if phone:
314 + out["broker_phone"] = phone
315 + if details:
316 + out["details"] = details
82 317 return out
83 318
84 − # -- mapping ---------------------------------------------------------------
319 + # -- mapping (item de liste) ------------------------------------------------
85 320 def _to_listing(self, it: dict) -> PropertyListing | None:
86 321 ref = it.get("ref_number") or it.get("id")
87 322 if not ref or not it.get("for_sale_flag", True):
@@ -93,6 +328,8 @@ class SourceImmoConnector(BaseConnector):
93 328 addr = loc.get("address") or {}
94 329 street = " ".join(filter(None, [addr.get("street_number"),
95 330 addr.get("street_name")])).strip()
331 + if addr.get("door") and street:
332 + street = f"{addr['door']}-{street}"
96 333 price = ((it.get("price") or {}).get("sell") or {}).get("amount")
97 334
98 335 cat = (it.get("category_code") or "").upper()
@@ -107,17 +344,19 @@ class SourceImmoConnector(BaseConnector):
107 344 lot = (land.get("dimension") or {}).get("area_sf")
108 345
109 346 photo = it.get("photo_url") or ""
110 − images = [photo.replace("-sm.", "-lg.")] if photo else []
347 + images = [_big(photo)] if photo else []
111 348
112 − # caractéristiques disponibles dans l'item (aucune requête détail)
349 + # caractéristiques disponibles dans l'item (la fiche détail enrichit)
113 350 details = {"postal_code": addr.get("postal_code", "")}
351 + if photo:
352 + # miniature légère pour les cartes de liste (mobile) — la galerie
353 + # pleine taille vient de la fiche détail
354 + details["cover_thumb"] = photo
114 355 b_ass = (building.get("assessment") or {}).get("amount")
115 356 l_ass = (land.get("assessment") or {}).get("amount")
116 357 if b_ass or l_ass:
117 358 details["Évaluation municipale"] = (
118 359 f"{(b_ass or 0) + (l_ass or 0):,.0f} $".replace(",", " "))
119 − if dim.get("area_sf"):
120 − details["Superficie du bâtiment (au sol)"] = f"{dim['area_sf']:,.0f} pi²".replace(",", " ")
121 360 if unit.get("room_count"):
122 361 details["Nombre de pièces"] = unit["room_count"]
123 362 features = []
@@ -135,7 +374,7 @@ class SourceImmoConnector(BaseConnector):
135 374 url=self._listing_url(it),
136 375 title=street,
137 376 address=street,
138 − city="", # code ville source.immo non résolu ici
377 + city="", # résolu par la fiche détail (dictionary)
139 378 sector="",
140 379 region=str(loc.get("region_code") or ""),
141 380 property_type=prop_type,
@@ -150,6 +389,7 @@ class SourceImmoConnector(BaseConnector):
150 389 mls=str(ref),
151 390 lat=loc.get("latitude"),
152 391 lng=loc.get("longitude"),
392 + description=(it.get("description") or "").strip(),
153 393 images=images,
154 394 features=features,
155 395 details=details,
modified immoka/connectors/ubee.py +361 −1
@@ -6,13 +6,31 @@
6 6 # (pageIndex=N, 24 résultats/page, JSON riche : adresse, GPS, prix, pièces,
7 7 # superficies m², année, galerie Cloudinary complète).
8 8 # Deux volets À VENDRE (listingType=Seller) : résidentiel + commercial.
9 +#
10 +# ENRICHISSEMENT DÉTAIL (fiche complète) :
11 +# GET api.ubee.ca/api/anonymous/Listing/{id}
12 +# retourne TOUT : description longue (FR/EN), n° Centris, ~30 listes de
13 +# caractéristiques énumérées (chauffage, sous-sol, garage, piscine, vue,
14 +# proximité…), taxes/frais (expenses), évaluation municipale, garantie
15 +# légale, délai d'occupation, stationnements, courtier inscripteur
16 +# (nom/agence/téléphone/courriel), visite virtuelle, lien Centris original.
17 +# Mise en cache BD (detail_cache) — clé = prix + photo de couverture, seules
18 +# les fiches nouvelles/modifiées sont re-lues (plafond IMMOKA_DETAIL_LIMIT).
19 +# NB : l'endpoint détail refuse les User-Agent non navigateur (403) — le
20 +# User-Agent de BaseConnector passe. Images Cloudinary : transformations
21 +# nommées strictes, t_default_size (1260 px) est le maximum public.
9 22 # -----------------------------------------------------------------------------
10 23 from __future__ import annotations
11 24
25 +import os
26 +import re
27 +
12 28 from ..schema import PropertyListing
29 +from ._detailutil import apply_detail
13 30 from .base import BaseConnector
14 31
15 32 API = "https://api.ubee.ca/api/anonymous/Search/SearchProperties"
33 +DETAIL_API = "https://api.ubee.ca/api/anonymous/Listing"
16 34 SITE = "https://ubee.com"
17 35 M2_TO_SQFT = 10.7639
18 36
@@ -22,10 +40,164 @@ _TYPES = {
22 40 "Fermette": "Fermette", "Chalet": "Chalet",
23 41 }
24 42
43 +# --- vocabulaire énuméré de l'API détail -> libellés français ----------------
44 +_ENUM_FR = {
45 + # sous-sol / fondation / structure
46 + "Aucun": "Aucun", "Autre": "Autre", "EntreeExterieure": "Entrée extérieure",
47 + "MoinsDeSixPieds": "Moins de 6 pieds", "SixPiedsOuPlus": "6 pieds ou plus",
48 + "NonAmenage": "Non aménagé", "PartiellementAmenage": "Partiellement aménagé",
49 + "TotalementAmenage": "Totalement aménagé", "VideSanitaire": "Vide sanitaire",
50 + "BetonCoule": "Béton coulé", "BlocDeBeton": "Bloc de béton",
51 + "DalleDeBetonAuSol": "Dalle de béton au sol",
52 + # chauffage / énergie
53 + "AirSouffle": "Air soufflé", "PlinthesAConvection": "Plinthes à convection",
54 + "PlinthesElectriques": "Plinthes électriques", "Radiant": "Radiant",
55 + "Electricite": "Électricité", "GazNaturel": "Gaz naturel",
56 + "Mazout": "Mazout", "Propane": "Propane", "Bois": "Bois",
57 + "Geothermie": "Géothermie", "Solaire": "Solaire",
58 + # foyer / poêle
59 + "FoyerAuBois": "Foyer au bois", "FoyerAuGaz": "Foyer au gaz",
60 + "FoyerAuMazout": "Foyer au mazout", "PoeleAuBois": "Poêle au bois",
61 + "PoeleAuxGranules": "Poêle aux granules",
62 + # garage / abri / allée / stationnement
63 + "Attache": "Attaché", "Detache": "Détaché", "Integre": "Intégré",
64 + "Chauffe": "Chauffé", "DoubleOuPlus": "Double ou plus",
65 + "SimpleLargeur": "Simple largeur", "DoubleLargeurOuPlus": "Double largeur ou plus",
66 + "Asphalte": "Asphalte", "NonPave": "Non pavé", "PaveUni": "Pavé uni",
67 + "AvecPriseExterieure": "Avec prise extérieure",
68 + # revêtements / toiture / fenêtres
69 + "Acier": "Acier", "Aluminium": "Aluminium", "Brique": "Brique",
70 + "Pierre": "Pierre", "Stucco": "Stucco", "Vinyle": "Vinyle", "PVC": "PVC",
71 + "BardeauxDAsphalte": "Bardeaux d'asphalte", "BardeauxDeCedre": "Bardeaux de cèdre",
72 + "BitumeEtGravier": "Bitume et gravier", "MembraneElastomere": "Membrane élastomère",
73 + "Tole": "Tôle", "Crank": "À manivelle", "PorteFenetre": "Porte-fenêtre",
74 + "Sliding": "Coulissante", "Melamine": "Mélamine", "Polyester": "Polyester",
75 + # commodités
76 + "AdoucisseurDEau": "Adoucisseur d'eau", "Ascenseur": "Ascenseur",
77 + "AspirateurCentral": "Aspirateur central", "Balcon": "Balcon",
78 + "BorneDeRecharge": "Borne de recharge", "ClimatiseurCentral": "Climatiseur central",
79 + "ClimatiseurMural": "Climatiseur mural", "EchangeurDAir": "Échangeur d'air",
80 + "EspaceDeRangement": "Espace de rangement", "Interphone": "Interphone",
81 + "Meuble": "Meublé", "SemiMeuble": "Semi-meublé", "Sauna": "Sauna",
82 + "PorteDeGarageElectrique": "Porte de garage électrique",
83 + "SystemeDAlarme": "Système d'alarme", "ThermopompeCentrale": "Thermopompe centrale",
84 + "ThermopompeMurale": "Thermopompe murale", "WaterHeater": "Chauffe-eau",
85 + # salle de bains
86 + "AttachedToMasterBedroom": "Attenante à la chambre principale",
87 + "BaignoireARemous": "Baignoire à remous", "SeparateShower": "Douche séparée",
88 + # piscine
89 + "Chauffee": "Chauffée", "Creusee": "Creusée", "HorsTerre": "Hors terre",
90 + "Interieure": "Intérieure",
91 + # eau / égouts
92 + "ChampDEpuration": "Champ d'épuration", "FosseSeptique": "Fosse septique",
93 + "Municipalite": "Municipalité", "SystemeBIONEST": "Système BIONEST",
94 + "EauDuLac": "Eau du lac", "PuitsArtesien": "Puits artésien",
95 + "PuitsDeSurface": "Puits de surface", "AccesALEau": "Accès à l'eau",
96 + "BordeParLEau": "Bordé par l'eau", "Navigable": "Navigable",
97 + # vue / terrain / zonage / particularités
98 + "Panoramique": "Panoramique", "SurLEau": "Sur l'eau",
99 + "SurLaMontagne": "Sur la montagne", "SurLaVille": "Sur la ville",
100 + "Other": "Autre", "Boise": "Boisé", "Cloture": "Clôturé",
101 + "Paysage": "Paysagé", "Agricultural": "Agricole", "Commercial": "Commercial",
102 + "Holiday": "Villégiature", "Multifamily": "Multifamilial",
103 + "Recreational": "Récréatif", "Residential": "Résidentiel",
104 + "CoinDeRue": "Coin de rue", "CornerUnit": "Unité de coin",
105 + "CulDeSac": "Cul-de-sac", "ForestMixed": "Forêt mixte",
106 + "MotorizedBoatPermitted": "Bateau à moteur permis",
107 + "MotorlessBoatOnly": "Bateau sans moteur seulement",
108 + "PrivateRoad": "Chemin privé", "SansVoisinArriere": "Sans voisin à l'arrière",
109 + # proximité
110 + "Autoroute": "Autoroute", "Cegep": "Cégep", "EcolePrimaire": "École primaire",
111 + "EcoleSecondaire": "École secondaire", "Garderie": "Garderie", "Golf": "Golf",
112 + "Hopital": "Hôpital", "MetropolitanExpressNetwork": "REM", "Parc": "Parc",
113 + "PisteCyclable": "Piste cyclable", "SentierDeMotoneige": "Sentier de motoneige",
114 + "SentierDeVTT": "Sentier de VTT", "SkiAlpin": "Ski alpin",
115 + "SkiDeFond": "Ski de fond", "TransportEnCommun": "Transport en commun",
116 + "Universite": "Université",
117 + # divers
118 + "AnimauxPermis": "Animaux permis", "RezDeChaussee": "Rez-de-chaussée",
119 + "SousSol": "Sous-sol", "Flat": "Plat", "Slope": "En pente",
120 +}
121 +
122 +# champs-listes de la fiche détail -> groupe de caractéristiques (features)
123 +_FEATURE_GROUPS = [
124 + ("heatingType", "Chauffage"),
125 + ("heatingEnergyType", "Énergie pour le chauffage"),
126 + ("basementType", "Sous-sol"),
127 + ("garageType", "Garage"),
128 + ("carportType", "Abri d'auto"),
129 + ("drivewayType", "Allée d'accès"),
130 + ("poolType", "Piscine"),
131 + ("fireplaceType", "Foyer/Poêle"),
132 + ("convenienceType", "Commodités"),
133 + ("cabinetType", "Armoires"),
134 + ("bathroomFeatureType", "Salle de bains"),
135 + ("exteriorCoveringType", "Revêtement extérieur"),
136 + ("roofingType", "Toiture"),
137 + ("foundationType", "Fondation"),
138 + ("fenestrationType", "Fenestration"),
139 + ("windowMechanismType", "Type de fenêtres"),
140 + ("sewerSystemType", "Système d'égouts"),
141 + ("waterSupplyType", "Approvisionnement en eau"),
142 + ("waterAccessType", "Accès à l'eau"),
143 + ("viewType", "Vue"),
144 + ("proximityType", "À proximité"),
145 + ("landType", "Terrain"),
146 + ("landZoningType", "Zonage"),
147 + ("particularityType", "Particularités"),
148 + ("reducedMobilityType", "Mobilité réduite"),
149 + ("animalPermissionType", "Animaux"),
150 + ("energyEfficiencyType", "Efficacité énergétique"),
151 + ("rentedApplianceType", "Équipement loué"),
152 + ("washerDryerLocationType", "Laveuse/sécheuse"),
153 + ("commercialListingUsage", "Usage commercial"),
154 + ("farmAnimalType", "Animaux de ferme"),
155 + ("farmBuildingType", "Bâtiments agricoles"),
156 +]
157 +
158 +_EXPENSE_FR = {
159 + "Taxmun": "Taxes municipales", "Taxsco": "Taxes scolaires",
160 + "Fcop": "Frais de copropriété", "Taxeau": "Taxe d'eau",
161 + "Elec": "Électricité", "Chauffage": "Chauffage",
162 + "CommonExpenses": "Frais communs", "Assurance": "Assurances",
163 +}
164 +_FREQ_FR = {"Annual": "an", "Monthly": "mois", "Weekly": "sem.", "Daily": "jour"}
165 +_WARRANTY_FR = {
166 + "WithWarranty": "Avec garantie légale", "WithExclusions": "Avec exclusions",
167 + "WithoutWarranty": "Sans garantie légale (aux risques et périls de l'acheteur)",
168 + "NoWarranty": "Sans garantie légale (aux risques et périls de l'acheteur)",
169 +}
170 +_PARKING_FR = {"Garage": "garage", "Driveway": "allée", "Carport": "abri d'auto",
171 + "Street": "rue", "Interior": "intérieur", "Exterior": "extérieur"}
172 +_HALF_UNITS = [("oneAndHalfCount", "1½"), ("twoAndHalfCount", "2½"),
173 + ("threeAndHalfCount", "3½"), ("fourAndHalfCount", "4½"),
174 + ("fiveAndHalfCount", "5½"), ("sixAndHalfCount", "6½"),
175 + ("sevenAndHalfCount", "7½+")]
176 +
177 +_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
178 +
179 +
180 +def _fr(code) -> str:
181 + """Libellé français d'un code énuméré de l'API (repli : découpe CamelCase)."""
182 + code = str(code or "").strip()
183 + if not code:
184 + return ""
185 + return _ENUM_FR.get(code) or _CAMEL_RE.sub(" ", code)
186 +
187 +
188 +def _money(amount) -> str:
189 + try:
190 + return f"{float(amount):,.0f} $".replace(",", " ")
191 + except (TypeError, ValueError):
192 + return ""
193 +
25 194
26 195 class UbeeConnector(BaseConnector):
27 196 source_id = "ubee"
28 197 request_delay = 0.5
198 + # fiches détail (re)lues au plus par synchronisation — le cache BD rend les
199 + # cycles suivants quasi gratuits. IMMOKA_DETAIL_LIMIT pour un rattrapage.
200 + detail_limit = 300
29 201
30 202 def _search(self, body: dict) -> list[dict]:
31 203 out, page = [], 0
@@ -98,4 +270,192 @@ class UbeeConnector(BaseConnector):
98 270 lst = self._to_listing(it)
99 271 if lst is not None:
100 272 out.setdefault(lst.uid, lst)
101 − return list(out.values())
273 + listings = list(out.values())
274 + self._enrich_details(listings)
275 + return listings
276 +
277 + # -- fiche détail (description, caractéristiques, taxes, courtier…) --------
278 + def _enrich_details(self, listings: list[PropertyListing]) -> None:
279 + budget = int(os.environ.get("IMMOKA_DETAIL_LIMIT", self.detail_limit))
280 + if budget <= 0:
281 + return
282 + from .. import db
283 + con = db.connect()
284 + try:
285 + for lst in listings:
286 + # clé de changement : prix + photo de couverture (l'URL Cloudinary
287 + # porte la version) — les fiches modifiées sont re-lues
288 + cover = lst.images[0] if lst.images else ""
289 + key = f"v1:{lst.price or ''}:{cover}"
290 + cached = db.get_cached_detail(con, self.source_id,
291 + lst.external_id, key)
292 + if cached is None:
293 + stale = db.get_stale_detail(con, self.source_id, lst.external_id)
294 + if budget <= 0:
295 + if stale:
296 + self._apply(lst, stale)
297 + continue
298 + try:
299 + raw = self.get(f"{DETAIL_API}/{lst.external_id}").json()
300 + cached = self._detail_payload(raw)
301 + except Exception:
302 + cached = {}
303 + if not cached and stale:
304 + # échec transitoire : on garde l'ancien payload
305 + self._apply(lst, stale)
306 + budget -= 1
307 + continue
308 + db.put_cached_detail(con, self.source_id, lst.external_id,
309 + key, cached)
310 + budget -= 1
311 + self._apply(lst, cached)
312 + finally:
313 + con.close()
314 +
315 + def _apply(self, lst: PropertyListing, payload: dict) -> None:
316 + if not payload:
317 + return
318 + if payload.get("mls") and not lst.mls:
319 + lst.mls = payload["mls"]
320 + # agence réelle du courtier inscripteur (remplace « Ubee Québec »)
321 + if payload.get("agency"):
322 + lst.agency = payload["agency"]
323 + apply_detail(lst, payload)
324 +
325 + def _detail_payload(self, d: dict) -> dict:
326 + """Payload compact (JSON-sérialisable, mis en cache) depuis la fiche API."""
327 + if not isinstance(d, dict) or not d.get("id"):
328 + return {}
329 + out: dict = {}
330 + details: dict = {}
331 + features: list[str] = []
332 +
333 + # -- description longue (préférence au texte FR) -------------------------
334 + texts = d.get("descriptionTexts") or []
335 + desc = next((t.get("plainText") or t.get("text") or ""
336 + for t in texts if (t.get("language") or "").lower() == "fr"), "")
337 + desc = (desc or d.get("plainTextDescription") or d.get("description") or "").strip()
338 + if desc:
339 + out["description"] = desc
340 +
341 + # -- galerie (même famille Cloudinary ; on garde la plus fournie) --------
342 + images = [im["publicUrls"]["default_size"]
343 + for im in d.get("images") or []
344 + if (im.get("publicUrls") or {}).get("default_size")]
345 + if images:
346 + out["images"] = images
347 +
348 + # -- identifiants / champs simples ---------------------------------------
349 + if d.get("mlsNumber"):
350 + out["mls"] = str(d["mlsNumber"])
351 + details["Numéro Centris"] = str(d["mlsNumber"])
352 + if d.get("nbRooms"):
353 + details["Nombre de pièces"] = d["nbRooms"]
354 + if d.get("numberOfFloors"):
355 + details["Nombre d'étages"] = d["numberOfFloors"]
356 + if d.get("numberOfParkings"):
357 + details["Stationnement (total)"] = d["numberOfParkings"]
358 + parks = [f"{_PARKING_FR.get(p.get('parkingKind'), _fr(p.get('parkingKind')).lower())}"
359 + f" ({p['count']})"
360 + for p in d.get("parkings") or [] if p.get("count")]
361 + if parks:
362 + features.append("Stationnement : " + ", ".join(parks))
363 + land = d.get("landSurfaceInMeters")
364 + if land:
365 + out["lot_sqft"] = round(land * M2_TO_SQFT)
366 + if d.get("yearBuilt"):
367 + out["year_built"] = d["yearBuilt"]
368 +
369 + # -- taxes / frais / évaluation municipale --------------------------------
370 + for e in d.get("expenses") or []:
371 + label = _EXPENSE_FR.get(e.get("expenseType") or "") or \
372 + _fr(e.get("expenseType"))
373 + money = _money(e.get("amount"))
374 + if not (label and money):
375 + continue
376 + per = _FREQ_FR.get(e.get("paymentFrequency") or "", "")
377 + details[label] = f"{money}/{per}" if per else money
378 + lot_a = d.get("lotMunicipalAssessment") or 0
379 + bld_a = d.get("buildingMunicipalAssessment") or 0
380 + if lot_a or bld_a:
381 + year = d.get("municipalAssessmentYear")
382 + details["Évaluation municipale"] = _money(lot_a + bld_a) + \
383 + (f" ({year})" if year else "")
384 + if lot_a:
385 + details["Évaluation municipale (terrain)"] = _money(lot_a)
386 + if bld_a:
387 + details["Évaluation municipale (bâtiment)"] = _money(bld_a)
388 +
389 + # -- conditions de vente / occupation -------------------------------------
390 + if d.get("legalWarrantyType") in _WARRANTY_FR:
391 + details["Garantie légale"] = _WARRANTY_FR[d["legalWarrantyType"]]
392 + if d.get("coOwnershipKind") in ("Divise", "Indivise"):
393 + details["Copropriété"] = d["coOwnershipKind"]
394 + if d.get("coOwnershipPercentage"):
395 + details["Quote-part"] = f"{d['coOwnershipPercentage']} %"
396 + if d.get("moveInDelay"):
397 + details["Délai d'occupation"] = str(d["moveInDelay"]).strip()
398 + if d.get("moveInDate"):
399 + details["Date d'occupation"] = str(d["moveInDate"])[:10]
400 + if d.get("topographyType"):
401 + details["Topographie"] = _fr(d["topographyType"])
402 + if d.get("waterBodyName"):
403 + details["Plan d'eau"] = str(d["waterBodyName"])
404 + if d.get("constructionCharacteristic") == "New":
405 + features.append("Construction neuve")
406 + if d.get("shortTermRentalPolicy"):
407 + details["Location court terme"] = (
408 + "Non permise" if d["shortTermRentalPolicy"] == "NotAllowed"
409 + else "Permise")
410 +
411 + # -- plex / commercial -----------------------------------------------------
412 + units = [f"{lab} × {d[k]}" for k, lab in _HALF_UNITS if d.get(k)]
413 + for k, lab in (("commercialCount", "commercial"),
414 + ("officeCount", "bureau"), ("industrialCount", "industriel")):
415 + if d.get(k):
416 + units.append(f"{lab} × {d[k]}")
417 + if units:
418 + details["Unités"] = ", ".join(units)
419 + if d.get("potentialCommercialRevenue"):
420 + details["Revenus potentiels"] = _money(d["potentialCommercialRevenue"])
421 +
422 + # -- caractéristiques énumérées (~30 listes) ------------------------------
423 + for field, group in _FEATURE_GROUPS:
424 + vals = [_fr(v) for v in d.get(field) or []]
425 + vals = [v for v in vals if v]
426 + if vals:
427 + features.append(f"{group} : {', '.join(vals)}")
428 +
429 + # -- liens : fiche Centris originale, visite virtuelle ---------------------
430 + links = d.get("additionalLinks") or {}
431 + if links.get("listingLink"):
432 + details["listing_origin_url"] = links["listingLink"]
433 + tour = links.get("virtualTourFrLink") or links.get("virtualTourEnLink")
434 + if tour:
435 + details["Visite virtuelle"] = tour
436 +
437 + # -- courtier inscripteur (sinon la fiche reste « Ubee » = proprio direct) --
438 + pro = d.get("professional") or {}
439 + name = (pro.get("fullname") or "").strip()
440 + if name:
441 + out["broker_name"] = name
442 + phone = (pro.get("displayPhoneNumber") or "").strip()
443 + if phone:
444 + out["broker_phone"] = phone
445 + agence = (pro.get("agenceName") or pro.get("corporationName") or "").strip()
446 + if agence:
447 + out["agency"] = agence
448 + details["Courtier inscripteur"] = name + \
449 + (f" ({agence})" if agence else "") + (f" — {phone}" if phone else "")
450 + if pro.get("displayEmail"):
451 + details["Courriel du courtier"] = pro["displayEmail"]
452 + cols = [c.get("fullname") for c in d.get("coListingProfessionals") or []
453 + if c.get("fullname")]
454 + if cols:
455 + details["Courtier collaborateur"] = ", ".join(cols)
456 +
457 + if features:
458 + out["features"] = features
459 + if details:
460 + out["details"] = details
461 + return out
modified immoka/db.py +59 −1
@@ -251,7 +251,10 @@ def dedup_by_address(con: sqlite3.Connection) -> int:
251 251
252 252
253 253 def _mask_cluster(con: sqlite3.Connection, cluster: list) -> int:
254 − """Masque les doublons d'un groupe (même clé d'adresse, prix ±1 %)."""
254 + """Masque les doublons d'un groupe (même clé d'adresse, prix ±1 %), puis
255 + FUSIONNE en « golden record » : les champs vides de la fiche conservée sont
256 + complétés depuis les doublons masqués (description plus longue, galerie plus
257 + riche, superficies, année, GPS, téléphone) — le meilleur des deux sources."""
255 258 if len(cluster) < 2:
256 259 return 0
257 260 sources = [c[3] for c in cluster]
@@ -259,13 +262,68 @@ def _mask_cluster(con: sqlite3.Connection, cluster: list) -> int:
259 262 return 0 # même source en double = probables unités distinctes : prudence
260 263 keep = min(cluster, key=lambda c: (c[1], c[2])) # (autorité, uid)
261 264 n = 0
265 + donors = []
262 266 for c in cluster:
263 267 if c[2] != keep[2]:
264 268 con.execute("UPDATE listings SET dup_hidden=1 WHERE uid=?", (c[2],))
269 + donors.append(c[2])
265 270 n += 1
271 + try:
272 + _merge_golden(con, keep[2], donors)
273 + except Exception:
274 + pass # la fusion est un bonus — ne jamais casser la dédup
266 275 return n
267 276
268 277
278 +_MERGE_NUM_FIELDS = ("bedrooms", "bathrooms", "powder_rooms", "area_sqft",
279 + "lot_sqft", "year_built")
280 +
281 +
282 +def _merge_golden(con: sqlite3.Connection, keep_uid: str, donor_uids: list[str]) -> None:
283 + """Complète les champs vides de `keep_uid` depuis ses doublons masqués."""
284 + if not donor_uids:
285 + return
286 + cols = ("uid, description, images, bedrooms, bathrooms, powder_rooms,"
287 + " area_sqft, lot_sqft, year_built, lat, lng, broker_phone")
288 + keep = con.execute(f"SELECT {cols} FROM listings WHERE uid=?",
289 + (keep_uid,)).fetchone()
290 + if keep is None:
291 + return
292 + sets, args = [], []
293 + kimgs = len(json.loads(keep["images"] or "[]"))
294 + kdesc = len(keep["description"] or "")
295 + best_imgs, best_desc = None, None
296 + donor_rows = con.execute(
297 + f"SELECT {cols} FROM listings WHERE uid IN "
298 + f"({','.join('?' * len(donor_uids))})", donor_uids).fetchall()
299 + merged_num: dict = {}
300 + for d in donor_rows:
301 + di = json.loads(d["images"] or "[]")
302 + if len(di) > max(kimgs, len(json.loads(best_imgs or "[]"))):
303 + best_imgs = d["images"]
304 + dd = d["description"] or ""
305 + if len(dd) > max(kdesc, 80, len(best_desc or "")):
306 + best_desc = dd
307 + for f in _MERGE_NUM_FIELDS:
308 + if keep[f] is None and merged_num.get(f) is None and d[f] is not None:
309 + merged_num[f] = d[f]
310 + # GPS : toujours la PAIRE du même donneur (jamais lat et lng mélangés)
311 + if (keep["lat"] is None and "lat" not in merged_num
312 + and d["lat"] is not None and d["lng"] is not None):
313 + merged_num["lat"], merged_num["lng"] = d["lat"], d["lng"]
314 + if not keep["broker_phone"] and d["broker_phone"] and "broker_phone" not in merged_num:
315 + merged_num["broker_phone"] = d["broker_phone"]
316 + if best_imgs is not None:
317 + sets.append("images=?"); args.append(best_imgs)
318 + if best_desc is not None and kdesc < 80:
319 + sets.append("description=?"); args.append(best_desc)
320 + for f, v in merged_num.items():
321 + sets.append(f"{f}=?"); args.append(v)
322 + if sets:
323 + args.append(keep_uid)
324 + con.execute(f"UPDATE listings SET {', '.join(sets)} WHERE uid=?", args)
325 +
326 +
269 327 def connect() -> sqlite3.Connection:
270 328 global _SCHEMA_READY
271 329 DB_PATH.parent.mkdir(parents=True, exist_ok=True)
added immoka/imgaudit.py +285 −0
@@ -0,0 +1,285 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# imgaudit.py : qualité des images d'annonces.
5 +#
6 +# 1) clean_gallery(urls) — nettoyage STATIQUE (sans réseau), appliqué par
7 +# PropertyListing.finalize() : URLs invalides, placeholders connus des
8 +# portails (« photo à venir », logos), doublons (y compris la même photo
9 +# en deux tailles).
10 +# 2) run_batch(...) — audit RÉSEAU budgété des photos de couverture : lien
11 +# mort (4xx/5xx/timeout), image minuscule/pixellisée (dimensions décodées
12 +# de l'en-tête JPEG/PNG/WebP/GIF), fichier corrompu. Résultats en cache
13 +# (table image_audit, TTL 30 jours). Une couverture morte est retirée et
14 +# la première image VALIDE de la galerie est promue ; une annonce sans
15 +# aucune image valide est marquée (details.needs_image_review) — le
16 +# frontend affiche alors l'image de secours par type de bien.
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import json
21 +import re
22 +import sqlite3
23 +import struct
24 +import time
25 +from concurrent.futures import ThreadPoolExecutor
26 +
27 +import requests
28 +
29 +AUDIT_TTL = 30 * 86400 # re-vérification d'une URL après 30 jours
30 +MIN_WIDTH, MIN_HEIGHT = 250, 160 # sous ces dimensions : miniature inutilisable
31 +MIN_BYTES = 3_000 # fichier suspicieusement petit (icône/placeholder)
32 +TIMEOUT = 10
33 +UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
34 + "(KHTML, like Gecko) Chrome/126 Safari/537.36 ImmoKaBot/1.0")
35 +
36 +# motifs de placeholders/logos des portails (jamais une photo de propriété).
37 +# ⚠ default/defaut/logo sont ANCRÉS sur le nom de fichier : un segment de
38 +# chemin comme Cloudinary « t_default_size/ » n'est PAS un placeholder
39 +# (bug payé : toutes les galeries Ubee vidées par « default[-_.] »).
40 +_PLACEHOLDER_RE = re.compile(
41 + r"placeholder|no[-_]?photo|nophoto|photo[-_]?a[-_]?venir|coming[-_]?soon|"
42 + r"missing|awaiting|shadow_listing|image[-_]?indisponible|no[-_]?image|"
43 + r"/(?:default|defaut|logo)[^/]*\.(?:jpe?g|png|webp|gif|svg)(?:\?|$)", re.I)
44 +
45 +_SIZE_VARIANT_RE = re.compile(r"-(?:sm|md|lg|xl|thumb|small|medium|large)(?=\.\w+$)")
46 +
47 +# handlers dynamiques : la photo est identifiée par la QUERYSTRING (propId/seq,
48 +# id…), pas par le chemin — ex. yoamo.immo/ALSPicture.axd?propId=…&seq=N,
49 +# mediaserver.centris.ca/media.ashx?id=… On ne retire que les params de taille.
50 +_DYNAMIC_EXT = (".axd", ".ashx", ".php", ".aspx", ".cfm")
51 +_SIZE_PARAM_RE = re.compile(r"&(?:w|h|width|height|size|sm|scale|quality)=[^&]*", re.I)
52 +
53 +
54 +def _canon(url: str) -> str:
55 + """Clé de déduplication : ignore la variante de taille et la querystring
56 + de redimensionnement pour attraper la même photo en deux formats."""
57 + path, _, query = url.partition("?")
58 + if query and path.lower().endswith(_DYNAMIC_EXT):
59 + params = _SIZE_PARAM_RE.sub("", "&" + query.replace("&amp;", "&")).lstrip("&")
60 + return f"{path}?{params}".lower()
61 + return _SIZE_VARIANT_RE.sub("", path).lower()
62 +
63 +
64 +def clean_gallery(urls: list[str]) -> list[str]:
65 + """Nettoyage statique d'une galerie : URLs http(s) uniquement, placeholders
66 + retirés, doublons (même photo, autre taille) dédupliqués, ordre préservé."""
67 + out: list[str] = []
68 + seen: set[str] = set()
69 + for u in urls or []:
70 + if not isinstance(u, str):
71 + continue
72 + u = u.strip()
73 + if not u.lower().startswith(("http://", "https://")):
74 + continue
75 + if _PLACEHOLDER_RE.search(u):
76 + continue
77 + key = _canon(u)
78 + if key in seen:
79 + continue
80 + seen.add(key)
81 + out.append(u)
82 + return out
83 +
84 +
85 +# ---------------------------------------------------------------------------
86 +# Décodage des dimensions depuis les premiers octets (sans télécharger tout)
87 +# ---------------------------------------------------------------------------
88 +
89 +def image_size(data: bytes) -> tuple[int, int] | None:
90 + """(largeur, hauteur) depuis l'en-tête PNG/GIF/WebP/JPEG, None si indécodable."""
91 + if len(data) < 26:
92 + return None
93 + if data[:8] == b"\x89PNG\r\n\x1a\n":
94 + w, h = struct.unpack(">II", data[16:24])
95 + return w, h
96 + if data[:6] in (b"GIF87a", b"GIF89a"):
97 + w, h = struct.unpack("<HH", data[6:10])
98 + return w, h
99 + if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
100 + if data[12:16] == b"VP8 " and len(data) >= 30:
101 + w, h = struct.unpack("<HH", data[26:30])
102 + return w & 0x3FFF, h & 0x3FFF
103 + if data[12:16] == b"VP8L" and len(data) >= 25:
104 + bits = struct.unpack("<I", data[21:25])[0]
105 + return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
106 + if data[12:16] == b"VP8X" and len(data) >= 30:
107 + w = int.from_bytes(data[24:27], "little") + 1
108 + h = int.from_bytes(data[27:30], "little") + 1
109 + return w, h
110 + if data[:2] == b"\xff\xd8": # JPEG : chercher le SOF
111 + i = 2
112 + while i + 9 < len(data):
113 + if data[i] != 0xFF:
114 + i += 1
115 + continue
116 + marker = data[i + 1]
117 + if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
118 + 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
119 + h, w = struct.unpack(">HH", data[i + 5:i + 9])
120 + return w, h
121 + seg_len = struct.unpack(">H", data[i + 2:i + 4])[0]
122 + i += 2 + seg_len
123 + return None
124 +
125 +
126 +def check_url(url: str) -> dict:
127 + """Vérifie une URL d'image : {ok, status, width, height, bytes, reason}."""
128 + try:
129 + r = requests.get(url, headers={"User-Agent": UA, "Range": "bytes=0-65535"},
130 + timeout=TIMEOUT, stream=True)
131 + status = r.status_code
132 + if status >= 400:
133 + return {"ok": 0, "status": status, "reason": "http"}
134 + data = next(r.iter_content(65536), b"") or b""
135 + r.close()
136 + total = int((r.headers.get("Content-Range") or "/0").split("/")[-1] or 0) \
137 + or int(r.headers.get("Content-Length") or 0) or len(data)
138 + size = image_size(data)
139 + if size is None:
140 + ctype = (r.headers.get("Content-Type") or "").lower()
141 + if "image" not in ctype:
142 + return {"ok": 0, "status": status, "bytes": total, "reason": "format"}
143 + # image valide mais en-tête non décodé (format exotique) : on tolère
144 + return {"ok": 1, "status": status, "bytes": total}
145 + w, h = size
146 + if w < MIN_WIDTH or h < MIN_HEIGHT:
147 + return {"ok": 0, "status": status, "width": w, "height": h,
148 + "bytes": total, "reason": "minuscule"}
149 + if total and total < MIN_BYTES:
150 + return {"ok": 0, "status": status, "width": w, "height": h,
151 + "bytes": total, "reason": "poids_suspect"}
152 + return {"ok": 1, "status": status, "width": w, "height": h, "bytes": total}
153 + except requests.RequestException:
154 + return {"ok": 0, "status": 0, "reason": "timeout"}
155 +
156 +
157 +# ---------------------------------------------------------------------------
158 +# Audit budgété des couvertures (+ galeries courtes) avec cache BD
159 +# ---------------------------------------------------------------------------
160 +
161 +def _ensure_table(con: sqlite3.Connection) -> None:
162 + con.execute("""CREATE TABLE IF NOT EXISTS image_audit (
163 + url TEXT PRIMARY KEY,
164 + ok INTEGER,
165 + status INTEGER,
166 + width INTEGER,
167 + height INTEGER,
168 + bytes INTEGER,
169 + reason TEXT,
170 + checked_at REAL
171 + )""")
172 + con.commit()
173 +
174 +
175 +def _cached(con: sqlite3.Connection, url: str) -> dict | None:
176 + r = con.execute("SELECT ok, reason, checked_at FROM image_audit WHERE url=?",
177 + (url,)).fetchone()
178 + if r and time.time() - (r["checked_at"] or 0) < AUDIT_TTL:
179 + return {"ok": r["ok"], "reason": r["reason"]}
180 + return None
181 +
182 +
183 +def _store(con: sqlite3.Connection, url: str, res: dict) -> None:
184 + con.execute(
185 + "INSERT INTO image_audit (url, ok, status, width, height, bytes, reason,"
186 + " checked_at) VALUES (?,?,?,?,?,?,?,?)"
187 + " ON CONFLICT(url) DO UPDATE SET ok=excluded.ok, status=excluded.status,"
188 + " width=excluded.width, height=excluded.height, bytes=excluded.bytes,"
189 + " reason=excluded.reason, checked_at=excluded.checked_at",
190 + (url, res.get("ok"), res.get("status"), res.get("width"),
191 + res.get("height"), res.get("bytes"), res.get("reason"), time.time()))
192 +
193 +
194 +def audit_listing(con: sqlite3.Connection, uid: str, images: list[str],
195 + details: dict, pool: ThreadPoolExecutor) -> tuple[list[str], dict, int]:
196 + """Vérifie la couverture (et remonte la 1re image valide en tête).
197 +
198 + Vérifie au plus les 4 premières images ; les mortes sont retirées de la
199 + galerie. Retourne (nouvelle galerie, details, nb de vérifications réseau)."""
200 + checked = 0
201 + good_idx = None
202 + dead: set[int] = set()
203 + for i, url in enumerate(images[:4]):
204 + res = _cached(con, url)
205 + if res is None:
206 + res = check_url(url)
207 + _store(con, url, res)
208 + checked += 1
209 + if res.get("ok"):
210 + good_idx = i
211 + break
212 + dead.add(i)
213 + new_images = [u for i, u in enumerate(images) if i not in dead]
214 + details = dict(details)
215 + if good_idx is None and images:
216 + # aucune image valide parmi les premières : re-vérification demandée,
217 + # le frontend applique l'image de secours par type de bien
218 + details["needs_image_review"] = True
219 + else:
220 + details.pop("needs_image_review", None)
221 + return new_images, details, checked
222 +
223 +
224 +def run_batch(limit: int = 2000, workers: int = 24) -> dict:
225 + """Audit réseau budgété : les annonces publiées jamais auditées d'abord.
226 +
227 + Appelé après chaque synchronisation (ingest.watch) ; relancer avec un gros
228 + `limit` pour un rattrapage complet. Idempotent grâce au cache par URL."""
229 + from . import db
230 + con = db.connect()
231 + _ensure_table(con)
232 + rows = con.execute(
233 + "SELECT uid, images, details FROM listings"
234 + " WHERE active=1 AND dup_hidden=0 AND images IS NOT NULL AND images!='[]'"
235 + " AND json_extract(COALESCE(details,'{}'), '$.img_audited') IS NULL"
236 + " LIMIT ?", (limit,)).fetchall()
237 + checked = removed = flagged = 0
238 +
239 + def _work(row):
240 + images = json.loads(row["images"] or "[]")
241 + details = json.loads(row["details"] or "{}")
242 + # les URLs sont vérifiées séquentiellement par annonce ; le parallélisme
243 + # est au niveau des annonces (une connexion BD par worker serait fragile,
244 + # donc le réseau seul est parallèle : cache lu/écrit dans le fil principal)
245 + return row["uid"], images, details
246 +
247 + with ThreadPoolExecutor(max_workers=workers) as pool:
248 + futures = []
249 + for row in rows:
250 + uid, images, details = _work(row)
251 + futures.append((uid, images, details))
252 + # traitement principal (cache BD dans ce fil, réseau via check_url —
253 + # parallélisé par lots d'URLs de couverture inconnues)
254 + unknown = []
255 + for uid, images, details in futures:
256 + for u in images[:4]:
257 + if _cached(con, u) is None:
258 + unknown.append(u)
259 + unknown = list(dict.fromkeys(unknown))
260 + # commits par tranches : ne JAMAIS tenir le verrou d'écriture pendant
261 + # tout l'audit (des dizaines de minutes) — les syncs/le web écrivent aussi
262 + for url, res in zip(unknown, pool.map(check_url, unknown)):
263 + _store(con, url, res)
264 + checked += 1
265 + if checked % 400 == 0:
266 + con.commit()
267 + con.commit()
268 + done = 0
269 + for uid, images, details in futures:
270 + new_images, new_details, _ = audit_listing(con, uid, images, details,
271 + pool)
272 + new_details["img_audited"] = int(time.time())
273 + if new_details.get("needs_image_review"):
274 + flagged += 1
275 + removed += len(images) - len(new_images)
276 + con.execute("UPDATE listings SET images=?, details=? WHERE uid=?",
277 + (json.dumps(new_images, ensure_ascii=False),
278 + json.dumps(new_details, ensure_ascii=False), uid))
279 + done += 1
280 + if done % 400 == 0:
281 + con.commit()
282 + con.commit()
283 + con.close()
284 + return {"annonces": len(rows), "urls_verifiees": checked,
285 + "images_retirees": removed, "sans_image_valide": flagged}
modified immoka/ingest.py +18 −0
@@ -53,6 +53,16 @@ def run(sources: list[str] | None = None) -> list[dict]:
53 53 "(sous-agences Centris + adresse inter-sources)")
54 54 except Exception:
55 55 traceback.print_exc()
56 + # contrôle qualité : score de complétude, cohérence immobilière, seuil de
57 + # publication (quarantaine) et champs dérivés (prix/pi², transaction)
58 + try:
59 + from . import quality
60 + q = quality.refresh(con)
61 + print(f"[immo-ka] qualité: {q['publiees']} publiée(s), "
62 + f"{q['quarantaine']} en quarantaine, "
63 + f"{q['recalculees']} recalculée(s)")
64 + except Exception:
65 + traceback.print_exc()
56 66 # statistiques du planificateur SQLite : sans elles, les requêtes bbox
57 67 # de la carte (Ka Maps) n'utilisent pas idx_listings_geo
58 68 try:
@@ -87,5 +97,13 @@ def watch(interval_seconds: int = 3600) -> None:
87 97 vraiprix_local.enrich_role(limit=20000)
88 98 except Exception as exc:
89 99 print(f"[immo-ka] vraiprix_local: erreur non bloquante: {exc}", file=sys.stderr)
100 + try: # audit budgété des images (liens morts, minuscules, corrompues)
101 + from . import imgaudit
102 + ia = imgaudit.run_batch(limit=2000)
103 + print(f"[immo-ka] images: {ia['urls_verifiees']} URL vérifiée(s), "
104 + f"{ia['images_retirees']} retirée(s), "
105 + f"{ia['sans_image_valide']} annonce(s) sans image valide")
106 + except Exception as exc:
107 + print(f"[immo-ka] imgaudit: erreur non bloquante: {exc}", file=sys.stderr)
90 108 print(f"[immo-ka] prochaine synchronisation dans {interval_seconds // 60} min")
91 109 time.sleep(interval_seconds)
modified immoka/kapdf.py +388 −69
@@ -1,10 +1,17 @@
1 1 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
2 +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2
3 3 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4 −# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5 −# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
4 +# les rapports estampillés Groupe-KA. 5 modes :
5 +# complet — toutes les sections (KPI, jauges, séries + stats, multi-
6 +# séries, empilées, distributions, répartitions, géo,
7 +# heatmap horaire, tableaux, records)
8 +# synthese — couverture + KPI + records (2-3 pages)
9 +# tendances — KPI + toutes les séries temporelles + stats de séries
10 +# repartitions — breakdowns, distributions, géo, activité horaire
11 +# donnees — tous les tableaux en version longue (400 lignes max)
12 +# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
6 13 # Usage :
7 −# from kapdf import GroupeKAReport
14 +# from kapdf import GroupeKAReport, REPORT_MODES, filename
8 15 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9 16 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10 17 # mode="complet").build()
@@ -26,6 +33,14 @@ GREEN = (28, 92, 65)
26 33 DANGER = (179, 66, 58)
27 34 WHITE = (255, 255, 255)
28 35
36 +REPORT_MODES = {
37 + "complet": "Rapport complet",
38 + "synthese": "Synthèse exécutive",
39 + "tendances": "Tendances & évolution",
40 + "repartitions": "Répartitions & géographie",
41 + "donnees": "Données détaillées",
42 +}
43 +
29 44 EMAILS = [
30 45 ("contact@groupe-ka.com", "Projets, partenariats & données"),
31 46 ("info@groupe-ka.com", "Médias & questions générales"),
@@ -52,7 +67,8 @@ def _fr(n) -> str:
52 67 _SUBST = {
53 68 "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54 69 "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55 − "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
70 + "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
71 + "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",
56 72 }
57 73
58 74
@@ -95,6 +111,8 @@ class _PDF(FPDF):
95 111 self.set_y(20)
96 112
97 113 def footer(self):
114 + # page 1 = couverture (le flag cover_mode est déjà retombé quand
115 + # add_page() clôt la page 1 → tester aussi le numéro de page)
98 116 if self.cover_mode or self.page_no() == 1:
99 117 return
100 118 self.set_y(-15)
@@ -112,7 +130,7 @@ class GroupeKAReport:
112 130 def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
113 131 self.site = site
114 132 self.d = dashboard
115 − self.mode = mode
133 + self.mode = mode if mode in REPORT_MODES else "complet"
116 134 self.accent = _hex(site.get("accent", "#d9f26b"))
117 135 period = dashboard.get("period", {}) or {}
118 136 self.period_label = period.get("label") or "toute la période"
@@ -127,6 +145,11 @@ class GroupeKAReport:
127 145 p.set_fill_color(*fill)
128 146 p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
129 147
148 + def _shade(self, i, n=8):
149 + shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
150 + f = shades[i % len(shades)]
151 + return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
152 +
130 153 def _kicker(self, text):
131 154 p = self.pdf
132 155 p.set_font("helvetica", "B", 8)
@@ -150,6 +173,14 @@ class GroupeKAReport:
150 173 self.toc.append((title, self.pdf.page_no()))
151 174 self.pdf.ln(11)
152 175
176 + def _chart_title(self, title):
177 + p = self.pdf
178 + p.set_font("helvetica", "B", 10)
179 + p.set_text_color(*INK)
180 + p.set_x(p.l_margin)
181 + p.cell(0, 6, title)
182 + p.ln(7)
183 +
153 184 # ---------- pages ----------
154 185 def _cover(self):
155 186 p = self.pdf
@@ -161,12 +192,10 @@ class GroupeKAReport:
161 192 p.set_draw_color(*INK)
162 193 p.set_line_width(1.0)
163 194 p.rect(10, 10, 190, 277)
164 − # kicker
165 195 p.set_font("helvetica", "B", 10)
166 196 p.set_text_color(*GREEN)
167 197 p.set_xy(24, 34)
168 198 p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
169 − # wordmark : partie gauche + boîte encre/accent
170 199 wm = self.site.get("wordmark", "")
171 200 left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
172 201 p.set_xy(24, 70)
@@ -184,7 +213,7 @@ class GroupeKAReport:
184 213 p.set_xy(24, 100)
185 214 p.set_font("helvetica", "", 13)
186 215 p.set_text_color(*INK2)
187 − p.multi_cell(150, 7, f"Rapport statistique — {wm}")
216 + p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}")
188 217 now = datetime.now(ZoneInfo("America/Toronto"))
189 218 per = self.d.get("period", {}) or {}
190 219 p.set_xy(24, 125)
@@ -193,7 +222,7 @@ class GroupeKAReport:
193 222 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
194 223 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
195 224 ("Plateforme", "https://" + self.site.get("domain", "")),
196 − ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
225 + ("Type de rapport", REPORT_MODES[self.mode]),
197 226 ]
198 227 y = 128
199 228 for k, v in rows:
@@ -205,7 +234,6 @@ class GroupeKAReport:
205 234 p.cell(0, 6, str(v))
206 235 p.set_font("helvetica", "", 10.5)
207 236 y += 8
208 − # bande encre au pied
209 237 p.set_fill_color(*INK)
210 238 p.rect(10, 262, 190, 25, style="F")
211 239 p.set_xy(24, 270)
@@ -230,7 +258,7 @@ class GroupeKAReport:
230 258 p = self.pdf
231 259 cols, gw, gh, gap = 3, 56, 26, 3
232 260 x0, y = p.l_margin, p.get_y()
233 − for i, k in enumerate(kpis[:9]):
261 + for i, k in enumerate(kpis[:12]):
234 262 x = x0 + (i % cols) * (gw + gap)
235 263 if i and i % cols == 0:
236 264 y += gh + gap
@@ -252,20 +280,81 @@ class GroupeKAReport:
252 280 p.set_font("helvetica", "B", 8)
253 281 p.set_text_color(*(GREEN if up else DANGER))
254 282 arrow = "+" if k["delta_pct"] >= 0 else ""
255 − p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
283 + dv = round(float(k["delta_pct"]), 1)
284 + dv = int(dv) if float(dv).is_integer() else dv
285 + p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")
256 286 p.set_y(y + gh + 8)
257 287
258 − def _line_chart(self, s):
288 + def _gauges(self):
289 + gs = self.d.get("gauges") or []
290 + gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]
291 + if not gs:
292 + return
293 + self._section_title("Taux & couvertures")
294 + p = self.pdf
295 + cols, gw, gh, gap = 3, 56, 34, 3
296 + x0, y = p.l_margin, p.get_y()
297 + for i, g in enumerate(gs[:9]):
298 + x = x0 + (i % cols) * (gw + gap)
299 + if i and i % cols == 0:
300 + y += gh + gap
301 + if y > 240:
302 + p.add_page(); y = p.get_y()
303 + self._card(x, y, gw, gh)
304 + frac = max(0.0, min(1.0, g["value"] / g["max"]))
305 + cx, cy, r = x + gw / 2, y + 20, 14
306 + # arc de fond + arc de valeur (demi-cercle en petits segments)
307 + for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):
308 + p.set_draw_color(*pass_col)
309 + p.set_line_width(lw)
310 + steps = max(2, int(60 * pass_frac))
311 + last = None
312 + for st in range(steps + 1):
313 + a = math.pi + math.pi * pass_frac * st / steps
314 + pt = (cx + r * math.cos(a), cy + r * math.sin(a))
315 + if last:
316 + p.line(last[0], last[1], pt[0], pt[1])
317 + last = pt
318 + p.set_font("helvetica", "B", 11)
319 + p.set_text_color(*INK)
320 + p.set_xy(x + 4, cy - 5)
321 + p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")
322 + p.set_font("helvetica", "", 6.6)
323 + p.set_text_color(*INK3)
324 + p.set_xy(x + 4, cy + 1.5)
325 + p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")
326 + p.set_xy(x + 3, y + gh - 7)
327 + p.set_font("helvetica", "", 7)
328 + p.set_text_color(*INK2)
329 + p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")
330 + p.set_y(y + gh + 8)
331 +
332 + def _serie_stats_row(self, s):
333 + """Ligne min/max/moyenne/médiane sous un graphique de série."""
334 + p = self.pdf
335 + vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]
336 + if len(vs) < 2:
337 + return
338 + sv = sorted(vs)
339 + mean = sum(vs) / len(vs)
340 + med = sv[len(sv) // 2]
341 + sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))
342 + p.set_font("helvetica", "", 6.8)
343 + p.set_text_color(*INK3)
344 + p.cell(0, 4, f"min {_fr(sv[0])} · max {_fr(sv[-1])} · moyenne {_fr(round(mean, 2))} · médiane {_fr(med)} · écart-type {_fr(round(sd, 2))}")
345 + p.ln(5.5)
346 +
347 + def _line_chart(self, s, with_stats=False):
259 348 p = self.pdf
260 349 pts = s.get("points") or []
261 350 if len(pts) < 2:
262 351 return
352 + if s.get("kind") == "bar":
353 + self._vbars(s)
354 + return
263 355 if p.get_y() > 200:
264 356 p.add_page()
265 − p.set_font("helvetica", "B", 10)
266 − p.set_text_color(*INK)
267 − p.cell(0, 6, s.get("title", ""))
268 − p.ln(7)
357 + self._chart_title(s.get("title", ""))
269 358 x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
270 359 self._card(x0, y0, w, h, fill=WHITE)
271 360 cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
@@ -273,7 +362,6 @@ class GroupeKAReport:
273 362 vmax = max(vals) or 1
274 363 vmin = min(0, min(vals))
275 364 rng = (vmax - vmin) or 1
276 − # grille + graduations
277 365 p.set_font("helvetica", "", 6.3)
278 366 p.set_text_color(*INK3)
279 367 p.set_draw_color(200, 200, 195)
@@ -284,6 +372,20 @@ class GroupeKAReport:
284 372 p.set_xy(x0 + 1, gy - 1.6)
285 373 p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
286 374
375 + def xy(i, n, v):
376 + return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))
377 +
378 + # aire sous la courbe (kind=area) : petits trapèzes accent pâle
379 + if s.get("kind") == "area":
380 + fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))
381 + p.set_fill_color(*fill)
382 + p.set_draw_color(*fill)
383 + n = len(pts)
384 + for i in range(n - 1):
385 + x1, y1 = xy(i, n, pts[i]["v"])
386 + x2, y2 = xy(i + 1, n, pts[i + 1]["v"])
387 + p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")
388 +
287 389 def draw(series, color, width, dash=None):
288 390 n = len(series)
289 391 p.set_draw_color(*color)
@@ -292,8 +394,7 @@ class GroupeKAReport:
292 394 p.set_dash_pattern(dash=1.2, gap=1.2)
293 395 last = None
294 396 for i, pt in enumerate(series):
295 − px = cx + cw * (i / (n - 1))
296 − py = cy + ch - ch * ((pt["v"] - vmin) / rng)
397 + px, py = xy(i, n, pt["v"])
297 398 if last:
298 399 p.line(last[0], last[1], px, py)
299 400 last = (px, py)
@@ -302,7 +403,6 @@ class GroupeKAReport:
302 403 if s.get("compare"):
303 404 draw(s["compare"], INK3, 0.35, dash=True)
304 405 draw(pts, self.accent, 0.7)
305 − # libellés d'axe X (premier / milieu / dernier)
306 406 p.set_text_color(*INK3)
307 407 for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
308 408 p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
@@ -312,9 +412,166 @@ class GroupeKAReport:
312 412 p.set_font("helvetica", "", 6.8)
313 413 p.set_text_color(*INK3)
314 414 p.cell(0, 4, "— période courante (accent) · ---- période comparée")
315 − p.ln(6)
316 − else:
317 − p.ln(2)
415 + p.ln(5.5)
416 + if with_stats:
417 + self._serie_stats_row(s)
418 + p.ln(1.5)
419 +
420 + def _vbars(self, s):
421 + """Barres verticales : série kind=bar ou distribution (bins)."""
422 + p = self.pdf
423 + pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]
424 + pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]
425 + if not pts:
426 + return
427 + if p.get_y() > 205:
428 + p.add_page()
429 + self._chart_title(s.get("title", ""))
430 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 48
431 + self._card(x0, y0, w, h, fill=WHITE)
432 + cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14
433 + vmax = max(pt["v"] for pt in pts) or 1
434 + p.set_font("helvetica", "", 6.3)
435 + p.set_text_color(*INK3)
436 + p.set_draw_color(200, 200, 195)
437 + p.set_line_width(0.15)
438 + for g in range(5):
439 + gy = cy + ch - ch * g / 4
440 + p.line(cx, gy, cx + cw, gy)
441 + p.set_xy(x0 + 1, gy - 1.6)
442 + p.cell(10, 3, _fr(vmax * g / 4), align="R")
443 + n = len(pts)
444 + bw = max(0.8, cw / n - 0.6)
445 + p.set_fill_color(*self.accent)
446 + p.set_draw_color(*INK)
447 + p.set_line_width(0.15)
448 + for i, pt in enumerate(pts):
449 + bh = ch * (pt["v"] / vmax)
450 + p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")
451 + p.set_text_color(*INK3)
452 + for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):
453 + p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)
454 + p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")
455 + p.set_y(y0 + h + 5)
456 +
457 + def _multiline(self, ms):
458 + """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /
459 + gris pointillé — l'identité passe par le motif, pas la couleur seule."""
460 + p = self.pdf
461 + series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]
462 + if not series:
463 + return
464 + if p.get_y() > 195:
465 + p.add_page()
466 + self._chart_title(ms.get("title", ""))
467 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
468 + self._card(x0, y0, w, h, fill=WHITE)
469 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
470 + vals = [pt["v"] for s in series for pt in s["points"]]
471 + vmax = max(vals) or 1
472 + vmin = min(0, min(vals))
473 + rng = (vmax - vmin) or 1
474 + p.set_font("helvetica", "", 6.3)
475 + p.set_text_color(*INK3)
476 + p.set_draw_color(200, 200, 195)
477 + p.set_line_width(0.15)
478 + for g in range(5):
479 + gy = cy + ch - ch * g / 4
480 + p.line(cx, gy, cx + cw, gy)
481 + p.set_xy(x0 + 1, gy - 1.6)
482 + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
483 + styles = [
484 + (self.accent, 0.7, None),
485 + (INK, 0.45, None),
486 + (self.accent, 0.55, True),
487 + (INK3, 0.5, True),
488 + ]
489 + for si, s in enumerate(series):
490 + col, lw, dash = styles[si]
491 + p.set_draw_color(*col)
492 + p.set_line_width(lw)
493 + if dash:
494 + p.set_dash_pattern(dash=1.4, gap=1.2)
495 + n = len(s["points"])
496 + last = None
497 + for i, pt in enumerate(s["points"]):
498 + px = cx + cw * (i / (n - 1))
499 + py = cy + ch - ch * ((pt["v"] - vmin) / rng)
500 + if last:
501 + p.line(last[0], last[1], px, py)
502 + last = (px, py)
503 + p.set_dash_pattern()
504 + ref = series[0]["points"]
505 + p.set_text_color(*INK3)
506 + for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):
507 + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
508 + p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")
509 + p.set_y(y0 + h + 4)
510 + p.set_font("helvetica", "", 6.8)
511 + p.set_text_color(*INK3)
512 + marks = ["—", "—", "----", "----"]
513 + leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))
514 + p.cell(0, 4, leg[:120])
515 + p.ln(6)
516 +
517 + def _stacked(self, st):
518 + p = self.pdf
519 + keys = (st.get("keys") or [])[:6]
520 + pts = st.get("points") or []
521 + if not keys or not pts:
522 + return
523 + if p.get_y() > 195:
524 + p.add_page()
525 + self._chart_title(st.get("title", ""))
526 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
527 + self._card(x0, y0, w, h, fill=WHITE)
528 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
529 + totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]
530 + vmax = max(totals) or 1
531 + p.set_font("helvetica", "", 6.3)
532 + p.set_text_color(*INK3)
533 + p.set_draw_color(200, 200, 195)
534 + p.set_line_width(0.15)
535 + for g in range(5):
536 + gy = cy + ch - ch * g / 4
537 + p.line(cx, gy, cx + cw, gy)
538 + p.set_xy(x0 + 1, gy - 1.6)
539 + p.cell(10, 3, _fr(vmax * g / 4), align="R")
540 + n = len(pts)
541 + bw = max(0.8, cw / n - 0.6)
542 + p.set_draw_color(*WHITE)
543 + p.set_line_width(0.12)
544 + for i, pt in enumerate(pts):
545 + yacc = cy + ch
546 + for j, k in enumerate(keys):
547 + v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0
548 + if not v:
549 + continue
550 + bh = ch * (v / vmax)
551 + yacc -= bh
552 + p.set_fill_color(*self._shade(j))
553 + p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")
554 + p.set_text_color(*INK3)
555 + for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):
556 + p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)
557 + p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")
558 + p.set_y(y0 + h + 4)
559 + # légende
560 + p.set_font("helvetica", "", 6.8)
561 + lx = p.l_margin
562 + for j, k in enumerate(keys):
563 + p.set_fill_color(*self._shade(j))
564 + p.set_draw_color(*INK)
565 + p.set_line_width(0.2)
566 + p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")
567 + p.set_xy(lx + 4, p.get_y())
568 + p.set_text_color(*INK2)
569 + txt = str(k)[:22]
570 + p.cell(p.get_string_width(txt) + 3, 4, txt)
571 + lx = p.get_x() + 3
572 + if lx > 165:
573 + break
574 + p.ln(7)
318 575
319 576 def _bars(self, title, items, unit=""):
320 577 p = self.pdf
@@ -324,10 +581,8 @@ class GroupeKAReport:
324 581 need = 10 + len(items) * 7
325 582 if p.get_y() + need > 265:
326 583 p.add_page()
327 − p.set_font("helvetica", "B", 10)
328 − p.set_text_color(*INK)
329 − p.cell(0, 6, title)
330 − p.ln(8)
584 + self._chart_title(title)
585 + p.ln(1)
331 586 vmax = max(it["value"] for it in items) or 1
332 587 for it in items:
333 588 y = p.get_y()
@@ -335,19 +590,23 @@ class GroupeKAReport:
335 590 p.set_text_color(*INK)
336 591 p.set_x(p.l_margin)
337 592 p.cell(46, 5, str(it["label"])[:34])
338 − bw = 96 * (it["value"] / vmax)
593 + bw = 86 * (it["value"] / vmax)
339 594 p.set_fill_color(*self.accent)
340 595 p.set_draw_color(*INK)
341 596 p.set_line_width(0.25)
342 597 p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
343 − p.set_xy(p.l_margin + 148, y)
598 + p.set_xy(p.l_margin + 136, y)
344 599 p.set_font("helvetica", "B", 7.6)
345 − p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
600 + p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
601 + if it.get("delta_pct") is not None:
602 + up = it["delta_pct"] >= 0
603 + p.set_font("helvetica", "B", 6.6)
604 + p.set_text_color(*(GREEN if up else DANGER))
605 + p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")
346 606 p.ln(6.4)
347 607 p.ln(3)
348 608
349 609 def _donut(self, b):
350 − # anneau vectoriel simple (arcs) + légende
351 610 p = self.pdf
352 611 items = [it for it in (b.get("items") or []) if it.get("value")][:8]
353 612 total = sum(it["value"] for it in items)
@@ -355,17 +614,13 @@ class GroupeKAReport:
355 614 return
356 615 if p.get_y() > 210:
357 616 p.add_page()
358 − p.set_font("helvetica", "B", 10)
359 − p.set_text_color(*INK)
360 − p.cell(0, 6, b.get("title", ""))
361 − p.ln(8)
617 + self._chart_title(b.get("title", ""))
618 + p.ln(1)
362 619 cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
363 − shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
364 620 start = -90.0
365 621 for i, it in enumerate(items):
366 622 frac = it["value"] / total
367 − f = shades[i % len(shades)]
368 − col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
623 + col = self._shade(i)
369 624 steps = max(2, int(72 * frac))
370 625 p.set_fill_color(*col)
371 626 p.set_draw_color(*col)
@@ -384,11 +639,9 @@ class GroupeKAReport:
384 639 p.set_line_width(0.4)
385 640 p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
386 641 p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
387 − # légende
388 642 ly = cy - 22
389 643 for i, it in enumerate(items):
390 − f = shades[i % len(shades)]
391 − col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
644 + col = self._shade(i)
392 645 p.set_fill_color(*col)
393 646 p.set_draw_color(*INK)
394 647 p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
@@ -400,7 +653,39 @@ class GroupeKAReport:
400 653 ly += 5.6
401 654 p.set_y(max(cy + r, ly) + 6)
402 655
403 − def _table(self, t):
656 + def _hourly(self):
657 + hh = self.d.get("hourly") or {}
658 + cells = hh.get("cells") or []
659 + if not cells:
660 + return
661 + p = self.pdf
662 + if p.get_y() > 190:
663 + p.add_page()
664 + self._chart_title(hh.get("title", "Activité par jour et heure"))
665 + x0, y0 = p.l_margin, p.get_y()
666 + cw, chh, lx, ly = 6.4, 6.4, 12, 5
667 + vmax = max((c.get("value") or 0) for c in cells) or 1
668 + grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}
669 + dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
670 + p.set_font("helvetica", "", 5.8)
671 + p.set_text_color(*INK3)
672 + for h in (0, 6, 12, 18, 23):
673 + p.set_xy(x0 + lx + h * cw, y0)
674 + p.cell(cw, 3, f"{h}h", align="C")
675 + for d in range(7):
676 + p.set_xy(x0, y0 + ly + d * chh + 1.5)
677 + p.cell(lx - 1, 3, dows[d], align="R")
678 + for h in range(24):
679 + v = grid.get((d, h), 0)
680 + f = 0.1 + 0.9 * (v / vmax) if v else 0.0
681 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)
682 + p.set_fill_color(*col)
683 + p.set_draw_color(215, 213, 207)
684 + p.set_line_width(0.1)
685 + p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
686 + p.set_y(y0 + ly + 7 * chh + 5)
687 +
688 + def _table(self, t, max_rows=200):
404 689 p = self.pdf
405 690 cols = t.get("columns") or []
406 691 rows = t.get("rows") or []
@@ -417,7 +702,7 @@ class GroupeKAReport:
417 702 p.ln(6)
418 703 head()
419 704 p.set_text_color(*INK)
420 − for i, row in enumerate(rows[:200]):
705 + for i, row in enumerate(rows[:max_rows]):
421 706 if p.get_y() > 262:
422 707 p.add_page()
423 708 head()
@@ -428,10 +713,10 @@ class GroupeKAReport:
428 713 txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
429 714 p.cell(w, 5.4, " " + txt[:34], fill=True)
430 715 p.ln(5.4)
431 − if len(rows) > 200:
716 + if len(rows) > max_rows:
432 717 p.set_font("helvetica", "", 7)
433 718 p.set_text_color(*INK3)
434 − p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
719 + p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")
435 720 p.ln(6)
436 721
437 722 def _records(self):
@@ -440,7 +725,7 @@ class GroupeKAReport:
440 725 return
441 726 self._section_title("Records & faits marquants")
442 727 p = self.pdf
443 − for r in recs[:10]:
728 + for r in recs[:14]:
444 729 if p.get_y() > 258:
445 730 p.add_page()
446 731 y = p.get_y()
@@ -498,43 +783,76 @@ class GroupeKAReport:
498 783 "groupe-ka.com/conditions · /confidentialite · /loi-25.",
499 784 )
500 785
501 − def _toc_page(self):
502 − # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
503 − # page 2 en réservant la page lors du build (voir build()).
504 − pass
786 + # ---------- groupes de sections ----------
787 + def _all_series(self, with_stats=True):
788 + for s in self.d.get("series") or []:
789 + self._line_chart(s, with_stats=with_stats)
790 + for ms in self.d.get("multiseries") or []:
791 + self._multiline(ms)
792 + for st in self.d.get("stacked") or []:
793 + self._stacked(st)
794 +
795 + def _all_breakdowns(self):
796 + for b in self.d.get("breakdowns") or []:
797 + if b.get("kind") == "donut":
798 + self._donut(b)
799 + else:
800 + self._bars(b.get("title", ""), b.get("items"))
801 + for dist in self.d.get("distributions") or []:
802 + self._vbars(dist)
803 + geo = self.d.get("geo")
804 + if geo:
805 + self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
806 + self._hourly()
505 807
506 808 def build(self) -> bytes:
507 809 p = self.pdf
508 810 p.alias_nb_pages()
509 811 self._cover()
812 + with_toc = self.mode in ("complet", "donnees")
813 + toc_page_no = None
510 814 if self.mode == "synthese":
511 815 p.add_page()
512 816 self._kpis()
817 + self._gauges()
818 + self._records()
819 + self._final_page()
820 + elif self.mode == "tendances":
821 + p.add_page()
822 + self._kpis()
823 + self._section_title("Évolution & tendances")
824 + self._all_series(with_stats=True)
513 825 self._records()
514 826 self._final_page()
515 − else:
827 + elif self.mode == "repartitions":
828 + p.add_page()
829 + self._section_title("Répartitions, distributions & géographie")
830 + self._all_breakdowns()
831 + self._final_page()
832 + elif self.mode == "donnees":
833 + p.add_page()
834 + toc_page_no = p.page_no()
835 + for t in self.d.get("tables") or []:
836 + self._table(t, max_rows=400)
837 + self._final_page()
838 + else: # complet
516 839 p.add_page()
517 840 toc_page_no = p.page_no()
518 841 p.add_page()
519 842 self._kpis()
520 − for s in self.d.get("series") or []:
521 − if s.get("kind") == "bar":
522 − self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
523 − else:
524 − self._line_chart(s)
525 − for b in self.d.get("breakdowns") or []:
526 − if b.get("kind") == "donut":
527 − self._donut(b)
528 − else:
529 − self._bars(b.get("title", ""), b.get("items"))
530 − geo = self.d.get("geo")
531 − if geo:
532 − self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
843 + self._gauges()
844 + if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):
845 + self._section_title("Évolution & tendances")
846 + self._all_series(with_stats=True)
847 + if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):
848 + self._section_title("Répartitions, distributions & géographie")
849 + self._all_breakdowns()
533 850 for t in self.d.get("tables") or []:
534 851 self._table(t)
535 852 self._records()
536 853 self._final_page()
537 − # sommaire écrit sur la page réservée (page 2)
854 + # sommaire écrit sur la page réservée
855 + if toc_page_no is not None:
538 856 last_page = p.page
539 857 p.page = toc_page_no
540 858 p.set_y(22)
@@ -553,6 +871,7 @@ class GroupeKAReport:
553 871 return bytes(p.output())
554 872
555 873
556 −def filename(platform_id: str, period: str) -> str:
874 +def filename(platform_id: str, period: str, mode: str = "complet") -> str:
557 875 today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
558 − return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
876 + suffix = "" if mode in ("", "complet") else f"_{mode}"
877 + return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"
modified immoka/normalize.py +52 −4
@@ -18,6 +18,7 @@ __all__ = [
18 18 "strip_accents", "clean_address", "parse_price", "price_is_from",
19 19 "parse_int", "parse_float", "parse_area_sqft", "parse_lot_sqft",
20 20 "parse_year", "normalize_property_type", "extract_bedrooms_bathrooms",
21 + "clean_title", "clean_description",
21 22 ]
22 23
23 24
@@ -26,6 +27,43 @@ def strip_accents(text: str) -> str:
26 27 if unicodedata.category(c) != "Mn")
27 28
28 29
30 +_SMALL_WORDS = {"a", "à", "au", "aux", "avec", "de", "des", "du", "en", "et",
31 + "la", "le", "les", "ou", "pour", "sur", "sous", "un", "une"}
32 +
33 +
34 +def clean_title(text: str) -> str:
35 + """Titre propre : espaces normalisés, et les titres CRIÉS EN MAJUSCULES
36 + (fréquents chez certaines sources) ramenés en casse naturelle — chaque mot
37 + capitalisé sauf les mots-outils (les noms de villes restent capitalisés)."""
38 + t = re.sub(r"\s+", " ", (text or "")).strip()
39 + letters = [c for c in t if c.isalpha()]
40 + if len(letters) >= 8 and sum(c.isupper() for c in letters) / len(letters) > 0.85:
41 + words = []
42 + for i, w in enumerate(t.lower().split(" ")):
43 + words.append(w if (i and w in _SMALL_WORDS) else w[:1].upper() + w[1:])
44 + t = " ".join(words)
45 + return t
46 +
47 +
48 +_TAG_RE = re.compile(r"<[^>]+>")
49 +_BR_RE = re.compile(r"<br\s*/?>|</p>|</div>|</li>", re.I)
50 +
51 +
52 +def clean_description(text: str) -> str:
53 + """Description sans HTML brut visible : balises retirées (sauts de ligne
54 + préservés), entités décodées, espaces/blancs normalisés."""
55 + import html as _html
56 + t = text or ""
57 + if "<" in t and ">" in t:
58 + t = _BR_RE.sub("\n", t)
59 + t = _TAG_RE.sub(" ", t)
60 + t = _html.unescape(t)
61 + t = re.sub(r"[ \t]+", " ", t)
62 + t = re.sub(r" ?\n ?", "\n", t)
63 + t = re.sub(r"\n{3,}", "\n\n", t)
64 + return t.strip()
65 +
66 +
29 67 def clean_address(text: str) -> str:
30 68 """Nettoie une adresse civique (espaces, virgules doublées, apostrophes)."""
31 69 t = re.sub(r"\s+", " ", (text or "").replace("’", "'")).strip()
@@ -168,19 +206,29 @@ _TYPE_MAP = [
168 206 (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau",),
169 207 "Chalet"),
170 208 (("terre", "terrain", "lot ", "land",), "Terrain"),
171 − (("ferme", "fermette", "agricole", "hobby farm",), "Fermette/Agricole"),
172 − (("commercial", "commerce", "industriel", "bureau", "local",), "Commercial"),
209 + (("ferme", "fermette", "agricole", "agriculture", "hobby farm",),
210 + "Fermette/Agricole"),
211 + # vocabulaire des fiches enrichies : « 4 logements », « propriété à revenu »
212 + (("logements", "logement/", "unites et +", "revenu",), "Multiplex"),
213 + (("bi generation", "bi-generation", "bigeneration", "intergeneration",),
214 + "Maison"),
215 + (("domaine et villa", "villa", "domaine",), "Maison"),
216 + (("commercial", "commerce", "industriel", "industrie", "bureau", "local",
217 + "entreprise", "batisse", "restaurant", "depanneur", "hotel", "motel",
218 + "garage/", "concessionnaire", "coiffure", "esthetique", "camping",),
219 + "Commercial"),
173 220 ]
174 221
175 222
176 223 def normalize_property_type(text: str) -> str:
177 − key = strip_accents((text or "").strip().lower())
224 + import html as _html
225 + key = strip_accents(_html.unescape(text or "").strip().lower())
178 226 if not key:
179 227 return ""
180 228 for keywords, canon in _TYPE_MAP:
181 229 if any(k in key for k in keywords):
182 230 return canon
183 − return text.strip().capitalize()
231 + return _html.unescape(text).strip().capitalize()
184 232
185 233
186 234 # ---------------------------------------------------------------------------
added immoka/quality.py +254 −0
@@ -0,0 +1,254 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# quality.py : contrôle qualité des annonces — score de complétude, contrôles de
5 +# cohérence immobiliers, seuil de publication (quarantaine sous le seuil).
6 +#
7 +# `refresh(con)` recalcule pour toutes les annonces actives :
8 +# - quality_score : complétude 0-100 (pondération des champs décisifs)
9 +# - quality_issues : JSON (anomalies détectées, dures ou informatives)
10 +# - published : 1 = affichable sur le site, 0 = quarantaine
11 +# et les champs dérivés dans details : prix_pi2 (prix/superficie) et
12 +# transaction (vente | location, détectée du libellé de prix).
13 +#
14 +# Règle de publication : prix plausible + ville + type de bien + au moins une
15 +# image + contenu exploitable (description ou caractéristiques). Une annonce
16 +# sous le seuil reste en base (re-synchronisée/enrichie aux prochains cycles)
17 +# mais n'est pas affichée — elle sort de quarantaine dès qu'elle est complétée.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import json
22 +import re
23 +import sqlite3
24 +import time
25 +
26 +from .normalize import strip_accents
27 +
28 +# bornes de plausibilité (marché québécois)
29 +PRICE_SALE_MIN, PRICE_SALE_MAX = 20_000, 80_000_000
30 +PRICE_RENT_MIN, PRICE_RENT_MAX = 300, 25_000
31 +AREA_MIN, AREA_MAX = 120, 25_000 # superficie habitable (pi²)
32 +LOT_MAX = 200_000_000 # terrain (pi²) — grandes terres
33 +YEAR_MIN = 1600
34 +
35 +_RENT_RE = re.compile(r"/\s*mois|par mois|/\s*mth|/\s*month|\bmensuel|\ba louer\b|"
36 + r"\blouer\b|\blocation\b|\blease\b|\bfor rent\b")
37 +
38 +
39 +def _transaction(price, price_label: str, title: str) -> str:
40 + """vente | location — détectée du libellé (jamais confondre loyer et prix)."""
41 + key = strip_accents(f"{price_label} {title}".lower())
42 + if _RENT_RE.search(key):
43 + return "location"
44 + if price is not None and price < PRICE_RENT_MAX:
45 + # montant de loyer sans mot-clé : trop bas pour une vente au Québec
46 + return "location"
47 + return "vente"
48 +
49 +
50 +def assess(row: dict) -> tuple[int, list[str], int, dict]:
51 + """(score 0-100, anomalies, publiable 0/1, champs dérivés) pour une annonce.
52 +
53 + `row` : dict aux clés des colonnes listings (features/details/images
54 + peuvent être des chaînes JSON ou déjà décodés)."""
55 +
56 + def _json(v, default):
57 + if isinstance(v, (list, dict)):
58 + return v
59 + try:
60 + return json.loads(v) if v else default
61 + except (TypeError, ValueError):
62 + return default
63 +
64 + images = _json(row.get("images"), [])
65 + features = _json(row.get("features"), [])
66 + details = _json(row.get("details"), {})
67 + desc = (row.get("description") or "").strip()
68 + price = row.get("price")
69 + issues: list[str] = []
70 + derived: dict = {}
71 +
72 + tx = _transaction(price, row.get("price_label") or "", row.get("title") or "")
73 + derived["transaction"] = tx
74 +
75 + # -- cohérence : prix plausible pour le type de transaction ----------------
76 + price_ok = price is not None and price > 0
77 + if price_ok:
78 + lo, hi = ((PRICE_RENT_MIN, PRICE_RENT_MAX) if tx == "location"
79 + else (PRICE_SALE_MIN, PRICE_SALE_MAX))
80 + if not (lo <= price <= hi):
81 + issues.append(f"prix_hors_bornes:{price:.0f}$ ({tx})")
82 + price_ok = False
83 +
84 + # -- cohérence : superficies plausibles -------------------------------------
85 + area = row.get("area_sqft")
86 + area_ok = area is not None and AREA_MIN <= area <= AREA_MAX
87 + if area is not None and not area_ok:
88 + issues.append(f"superficie_improbable:{area:.0f}pi2")
89 + lot = row.get("lot_sqft")
90 + if lot is not None and not (0 < lot <= LOT_MAX):
91 + issues.append(f"terrain_improbable:{lot:.0f}pi2")
92 +
93 + # -- cohérence : pièces / chambres ------------------------------------------
94 + beds, baths = row.get("bedrooms"), row.get("bathrooms")
95 + if beds is not None and not (0 <= beds <= 30):
96 + issues.append(f"chambres_improbables:{beds}")
97 + beds = None
98 + if baths is not None and not (0 <= baths <= 20):
99 + issues.append(f"sdb_improbables:{baths}")
100 + baths = None
101 + ptype = (row.get("property_type") or "").strip()
102 + if beds and ptype == "Condo" and beds > 8:
103 + issues.append(f"chambres_vs_type:{beds}ch_condo")
104 +
105 + # -- cohérence : année de construction ---------------------------------------
106 + year = row.get("year_built")
107 + current_year = time.gmtime().tm_year
108 + if year is not None and not (YEAR_MIN <= year <= current_year + 3):
109 + issues.append(f"annee_invalide:{year}")
110 +
111 + # -- champ dérivé : prix au pi² (et m²) ---------------------------------------
112 + if price_ok and area_ok and tx == "vente":
113 + ppsf = price / area
114 + derived["prix_pi2"] = round(ppsf)
115 + derived["prix_m2"] = round(ppsf * 10.7639)
116 + if not (30 <= ppsf <= 3500):
117 + issues.append(f"prix_pi2_extreme:{ppsf:.0f}")
118 +
119 + # -- score de complétude (0-100) ----------------------------------------------
120 + n_img = len(images)
121 + has_contact = bool(row.get("broker_name") or row.get("broker_phone"))
122 + score = 0
123 + score += 15 if price_ok else 0
124 + score += 8 if (row.get("city") or "").strip() else 0
125 + score += 7 if (row.get("address") or "").strip() else 0
126 + score += 8 if ptype else 0
127 + score += 10 if n_img >= 1 else 0
128 + score += 5 if n_img >= 8 else 0
129 + score += 12 if len(desc) >= 300 else 8 if len(desc) >= 80 else 4 if desc else 0
130 + score += 6 if beds is not None else 0
131 + score += 5 if baths is not None else 0
132 + score += 8 if area_ok else 0
133 + score += 4 if year is not None else 0
134 + score += 7 if row.get("lat") is not None else 0
135 + score += 3 if has_contact else 0
136 + score += 2 if lot is not None else 0
137 +
138 + # -- seuil de publication ---------------------------------------------------
139 + # (une annonce SANS image reste publiable : le frontend applique l'image de
140 + # secours par type de bien ; le drapeau sans_image la marque à re-vérifier)
141 + if n_img < 1:
142 + issues.append("sans_image")
143 + # vendue/louée à la source : archivée (plus jamais affichée en résultats)
144 + statut = (row.get("status") or "").strip().lower()
145 + vendue = statut in ("vendu", "vendue", "loue", "louee", "loué", "louée",
146 + "sold", "rented", "retire", "retiré")
147 + if vendue:
148 + issues.append(f"statut:{statut}")
149 + publishable = (
150 + not vendue
151 + and price_ok
152 + and bool((row.get("city") or "").strip())
153 + and bool(ptype)
154 + and (len(desc) >= 50 or len(features) >= 3)
155 + )
156 + if not publishable:
157 + why = []
158 + if not price_ok:
159 + why.append("prix")
160 + if not (row.get("city") or "").strip():
161 + why.append("ville")
162 + if not ptype:
163 + why.append("type")
164 + if len(desc) < 50 and len(features) < 3:
165 + why.append("contenu")
166 + issues.append("quarantaine:" + "+".join(why))
167 +
168 + return score, issues, int(publishable), derived
169 +
170 +
171 +def refresh(con: sqlite3.Connection, sources: list[str] | None = None) -> dict:
172 + """Recalcule score/anomalies/publication pour les annonces actives.
173 +
174 + Appelé après chaque synchronisation (ingest.run) — quelques secondes pour
175 + ~80 k lignes. Retourne un résumé {actives, publiees, quarantaine}."""
176 + _ensure_columns(con)
177 + sql = ("SELECT uid, title, price, price_label, city, address, property_type,"
178 + " bedrooms, bathrooms, area_sqft, lot_sqft, year_built, lat, status,"
179 + " broker_name, broker_phone, description, features, details, images,"
180 + " quality_score, quality_issues, published"
181 + " FROM listings WHERE active=1")
182 + args: list = []
183 + if sources:
184 + sql += f" AND source IN ({','.join('?' * len(sources))})"
185 + args = list(sources)
186 + updates = []
187 + n = pub = 0
188 + for r in con.execute(sql, args):
189 + row = dict(r)
190 + score, issues, publishable, derived = assess(row)
191 + n += 1
192 + pub += publishable
193 + details = {}
194 + try:
195 + details = json.loads(row.get("details") or "{}")
196 + except ValueError:
197 + pass
198 + changed_details = any(details.get(k) != v for k, v in derived.items())
199 + issues_json = json.dumps(issues, ensure_ascii=False) if issues else None
200 + if (score != row.get("quality_score") or publishable != row.get("published")
201 + or issues_json != row.get("quality_issues") or changed_details):
202 + details.update(derived)
203 + updates.append((score, issues_json, publishable,
204 + json.dumps(details, ensure_ascii=False), row["uid"]))
205 + if updates:
206 + con.executemany(
207 + "UPDATE listings SET quality_score=?, quality_issues=?, published=?,"
208 + " details=? WHERE uid=?", updates)
209 + con.commit()
210 + return {"actives": n, "publiees": pub, "quarantaine": n - pub,
211 + "recalculees": len(updates)}
212 +
213 +
214 +def _ensure_columns(con: sqlite3.Connection) -> None:
215 + cols = {r["name"] for r in con.execute("PRAGMA table_info(listings)")}
216 + if "quality_score" not in cols:
217 + con.execute("ALTER TABLE listings ADD COLUMN quality_score INTEGER")
218 + if "quality_issues" not in cols:
219 + con.execute("ALTER TABLE listings ADD COLUMN quality_issues TEXT")
220 + if "published" not in cols:
221 + # 1 par défaut : la 1re passe refresh() met la vraie valeur partout
222 + con.execute("ALTER TABLE listings ADD COLUMN published INTEGER DEFAULT 1")
223 + con.execute("CREATE INDEX IF NOT EXISTS idx_listings_published"
224 + " ON listings(published)")
225 + con.commit()
226 +
227 +
228 +def summary(con: sqlite3.Connection) -> dict:
229 + """Statistiques qualité pour /api/stats : complétude, quarantaine, anomalies."""
230 + _ensure_columns(con)
231 + row = con.execute(
232 + "SELECT COUNT(*) actives, SUM(published) publiees,"
233 + " ROUND(AVG(quality_score),1) completude_moyenne"
234 + " FROM listings WHERE active=1 AND dup_hidden=0").fetchone()
235 + per_source = [dict(r) for r in con.execute(
236 + "SELECT source, COUNT(*) n, SUM(published) publiees,"
237 + " ROUND(AVG(quality_score),1) completude,"
238 + " SUM(CASE WHEN quality_issues IS NOT NULL THEN 1 ELSE 0 END) anomalies"
239 + " FROM listings WHERE active=1 AND dup_hidden=0"
240 + " GROUP BY source ORDER BY n DESC")]
241 + anomalies: dict[str, int] = {}
242 + for r in con.execute(
243 + "SELECT quality_issues FROM listings WHERE active=1 AND dup_hidden=0"
244 + " AND quality_issues IS NOT NULL"):
245 + try:
246 + for issue in json.loads(r["quality_issues"]):
247 + anomalies[issue.split(":")[0]] = anomalies.get(issue.split(":")[0], 0) + 1
248 + except ValueError:
249 + continue
250 + d = dict(row)
251 + d["quarantaine"] = (d.get("actives") or 0) - (d.get("publiees") or 0)
252 + d["anomalies"] = dict(sorted(anomalies.items(), key=lambda kv: -kv[1]))
253 + d["par_source"] = per_source
254 + return d
modified immoka/schema.py +14 −1
@@ -20,6 +20,8 @@ from dataclasses import dataclass, field, asdict
20 20
21 21 from .normalize import (
22 22 clean_address,
23 + clean_description,
24 + clean_title,
23 25 extract_bedrooms_bathrooms,
24 26 normalize_property_type,
25 27 parse_area_sqft,
@@ -84,10 +86,16 @@ class PropertyListing:
84 86
85 87 Idempotent ; ne remplace jamais une valeur explicite du connecteur.
86 88 """
87 − self.title = (self.title or "").strip()
89 + self.title = clean_title(self.title)
88 90 self.address = clean_address(self.address)
89 91 self.city = (self.city or "").strip()
90 92 self.property_type = normalize_property_type(self.property_type)
93 + self.description = clean_description(self.description)
94 +
95 + # galerie : URLs valides seulement, placeholders retirés, doublons
96 + # (même photo en deux tailles) dédupliqués — voir immoka/imgaudit.py
97 + from .imgaudit import clean_gallery
98 + self.images = clean_gallery(self.images)
91 99
92 100 if self.price is None:
93 101 self.price = parse_price(self.price_label)
@@ -96,6 +104,11 @@ class PropertyListing:
96 104
97 105 # promotion details -> colonnes : les champs structurés de la fiche
98 106 # (tableau Centris) priment sur l'extraction de texte libre ci-dessous
107 + if not self.property_type:
108 + for k in ("Type de propriété", "Genre de propriété"):
109 + if self.details.get(k):
110 + self.property_type = normalize_property_type(str(self.details[k]))
111 + break
99 112 if self.year_built is None and self.details.get("Année de construction"):
100 113 self.year_built = parse_year(self.details["Année de construction"])
101 114 if self.area_sqft is None and self.details.get("Superficie habitable"):
modified immoka/seo.py +1 −1
@@ -41,7 +41,7 @@ BASE_URL = os.environ.get("IMMOKA_BASE_URL", "https://www.immo-ka.com").rstrip("
41 41 SITE_NAME = "Immo-Ka"
42 42
43 43 # même règle de visibilité que /api/listings (dédup Centris + prix affichable)
44 −VISIBLE = "active=1 AND dup_hidden=0 AND price IS NOT NULL"
44 +VISIBLE = "active=1 AND dup_hidden=0 AND published=1"
45 45 MIN_LISTINGS = 3 # seuil de qualité : pas de page ville/type quasi vide
46 46 PAGE_SIZE = 48 # annonces par page sur les pages programmatiques
47 47
modified immoka/stats.py +596 −24
@@ -2,28 +2,47 @@
2 2 # Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # stats.py : tableau de bord analytique /api/stats/dashboard + rapport PDF
5 −# /api/stats/report (module Stats commun Groupe KA — voir
5 +# /api/stats/report (module Stats commun Groupe KA v2 — voir
6 6 # frontend/src/ka/stats/SPEC.md). Toutes les valeurs viennent de la base
7 7 # (listings, price_log, sync_log) — AUCUNE statistique inventée : une
8 8 # mesure indisponible est simplement omise (le front affiche un état vide).
9 +# v2 : sparklines KPI, jauges (géolocalisation, photos, publiable),
10 +# multi-courbes (prix médian par type / grande ville), barres empilées
11 +# (nouvelles inscriptions par bannière), distributions (prix, superficie,
12 +# année de construction), heatmap horaire 7×24, tableaux quarantaine &
13 +# bannières, records enrichis.
9 14 # -----------------------------------------------------------------------------
10 15 from __future__ import annotations
11 16
17 +import json
12 18 import statistics
13 19 import threading
14 20 import time
15 21 import unicodedata
16 22 from datetime import date, datetime, timedelta
23 +from pathlib import Path
17 24 from zoneinfo import ZoneInfo
18 25
19 26 from . import db
20 27
21 28 TZ = ZoneInfo("America/Toronto")
22 29 SQFT_PER_M2 = 10.7639104
30 +ROOT = Path(__file__).resolve().parent.parent
31 +SOURCES_PATH = ROOT / "data" / "sources.json"
32 +
33 +# Position vs estimation Vrai-Prix (mêmes seuils que le fair value Lou-Ka) :
34 +# sous le marché si écart <= -8 %, au-dessus si >= +8 % ; les écarts hors
35 +# (-50 %, +100 %) sont presque toujours des erreurs de lecture -> ignorés.
36 +FV_SEUIL_SOUS = -0.08
37 +FV_SEUIL_SUR = 0.08
38 +FV_DEV_BOUNDS = (-0.50, 1.00)
39 +# bornes de plausibilité des prix de vente résidentiels (journal de prix) :
40 +# les écarts extrêmes sont des erreurs de source, pas de vraies baisses.
41 +PRICE_MIN, PRICE_MAX = 25_000, 50_000_000
23 42
24 43 # Même règle de visibilité que le reste de l'API (web.DEDUP_CLAUSE) :
25 44 # doublons de sous-agences masqués + « Prix sur demande » exclus.
26 −VISIBLE = " AND dup_hidden=0 AND price IS NOT NULL"
45 +VISIBLE = " AND dup_hidden=0 AND published=1"
27 46
28 47 PERIOD_LABELS = {
29 48 "auj": "Aujourd'hui", "7j": "7 jours", "30j": "30 jours",
@@ -105,6 +124,59 @@ def _daterange(a: date, b: date):
105 124 d += timedelta(days=1)
106 125
107 126
127 +def _source_names() -> dict[str, str]:
128 + """id -> nom lisible depuis data/sources.json (repli : id brut)."""
129 + try:
130 + reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
131 + return {s["id"]: s.get("name") or s["id"] for s in reg}
132 + except (OSError, ValueError, KeyError, TypeError):
133 + return {}
134 +
135 +
136 +# Familles de connecteurs (mêmes règles que web._FRANCHISES — dupliquées ici
137 +# pour éviter l'import circulaire web ⇄ stats)
138 +_FAMILLES = [
139 + ("RE/MAX", lambda s: s == "remax_quebec" or s.startswith("remax_ag_")),
140 + ("Via Capitale", lambda s: s == "via_capitale" or s.startswith("via_ag_")),
141 + ("Century 21", lambda s: s == "century21" or s.startswith("c21_ag_")),
142 + ("Royal LePage", lambda s: s == "royal_lepage"),
143 + ("Groupe Sutton", lambda s: s == "sutton"),
144 + ("Keller Williams", lambda s: s.startswith("kw_")),
145 + ("DuProprio", lambda s: s == "duproprio"),
146 + ("Vendre.ca", lambda s: s == "vendre_ag_ca"),
147 +]
148 +
149 +
150 +def _famille_of(source: str, names: dict[str, str]) -> str:
151 + for name, match in _FAMILLES:
152 + if match(source):
153 + return name
154 + return names.get(source, source)
155 +
156 +
157 +def _downsample(pts: list[dict], keep: int = 40) -> list[dict]:
158 + """Réduit une série de points {t,v} à <= keep points (sparklines)."""
159 + if len(pts) <= keep:
160 + return pts
161 + step = (len(pts) - 1) / (keep - 1)
162 + return [pts[round(i * step)] for i in range(keep)]
163 +
164 +
165 +# Libellés lisibles des motifs de quarantaine/anomalies (quality.py)
166 +_MOTIFS_QUALITE = {
167 + "quarantaine": "Sous le seuil de publication",
168 + "sans_image": "Sans image (secours affiché)",
169 + "prix_hors_bornes": "Prix hors bornes",
170 + "superficie_improbable": "Superficie improbable",
171 + "terrain_improbable": "Terrain improbable",
172 + "chambres_improbables": "Chambres improbables",
173 + "sdb_improbables": "Salles de bain improbables",
174 + "chambres_vs_type": "Chambres vs type incohérents",
175 + "annee_invalide": "Année de construction invalide",
176 + "prix_pi2_extreme": "Prix au pi² extrême",
177 +}
178 +
179 +
108 180 # --- calcul du tableau de bord ------------------------------------------------
109 181 def _compute(frm_q: str | None, to_q: str | None, period: str | None) -> dict:
110 182 con = db.connect()
@@ -212,6 +284,80 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
212 284 kpis.append(kpi("prix_m2",
213 285 f"Prix médian au m² ({n_ppm2:,} annonces avec superficie)".replace(",", " "),
214 286 round(med_ppm2), "$/m²"))
287 + # prix au pi² déclaré à la source (details.prix_pi2) — médiane
288 + ppi2 = [r["v"] for r in con.execute(
289 + "SELECT CAST(json_extract(details,'$.prix_pi2') AS REAL) v"
290 + " FROM listings WHERE active=1" + VISIBLE +
291 + " AND CAST(json_extract(details,'$.prix_pi2') AS REAL)"
292 + " BETWEEN 30 AND 10000")]
293 + med_ppi2 = _median(ppi2)
294 + if med_ppi2 and len(ppi2) >= 100:
295 + kpis.append(kpi(
296 + "prix_pi2",
297 + f"Prix médian au pi² ({len(ppi2):,} annonces le déclarant)".replace(",", " "),
298 + round(med_ppi2), "$/pi²"))
299 + # jours sur le marché (annonces actives) — médiane depuis first_seen
300 + now_ts = time.time()
301 + dom = [max((now_ts - r["fs"]) / 86400.0, 0.0) for r in con.execute(
302 + "SELECT first_seen fs FROM listings WHERE active=1" + VISIBLE)]
303 + med_dom = _median(dom)
304 + if med_dom is not None and dom:
305 + kpis.append(kpi("jours_marche", "Jours sur le marché (médiane, actives)",
306 + round(med_dom, 1), "j"))
307 + # baisses de prix observées dans la période (journal price_log) —
308 + # une entrée par annonce, bornes de plausibilité (voir en tête de fichier)
309 + drops = con.execute(
310 + """SELECT l.city city, MAX(p1.price - p2.price) amt,
311 + date(MAX(p2.ts),'unixepoch','localtime') dt
312 + FROM price_log p1
313 + JOIN price_log p2 ON p2.uid = p1.uid AND p2.ts > p1.ts
314 + JOIN listings l ON l.uid = p1.uid
315 + WHERE p2.ts>=? AND p2.ts<? AND p2.price < p1.price
316 + AND p1.price BETWEEN ? AND ? AND p2.price BETWEEN ? AND ?
317 + AND p2.price >= p1.price * 0.5 AND l.dup_hidden=0 AND l.published=1
318 + GROUP BY l.uid ORDER BY amt DESC""",
319 + (ep_frm, ep_to, PRICE_MIN, PRICE_MAX, PRICE_MIN, PRICE_MAX)).fetchall()
320 + kpis.append(kpi("baisses_prix", "Baisses de prix observées (période)",
321 + len(drops)))
322 + # qualité des données (quality.py) : score moyen + quarantaine
323 + qual = con.execute(
324 + "SELECT ROUND(AVG(quality_score),1) c FROM listings WHERE active=1"
325 + + VISIBLE).fetchone()
326 + quar = con.execute(
327 + "SELECT COUNT(*) n FROM listings"
328 + " WHERE active=1 AND dup_hidden=0 AND published=0").fetchone()["n"]
329 + if qual["c"] is not None:
330 + kpis.append(kpi("qualite", "Score de qualité moyen des fiches",
331 + qual["c"], "/100"))
332 + kpis.append(kpi("quarantaine", "Annonces en quarantaine (qualité)",
333 + quar))
334 + # position des prix vs estimation Vrai-Prix (juste valeur) — un seul
335 + # balayage réutilisé par le KPI, l'anneau et le tableau par ville
336 + b_lo, b_hi = FV_DEV_BOUNDS
337 + fv_rows = con.execute(
338 + "SELECT city, (price - CAST(json_extract(vraiprix,'$.value') AS REAL))"
339 + " / CAST(json_extract(vraiprix,'$.value') AS REAL) dev"
340 + " FROM listings WHERE active=1" + VISIBLE +
341 + " AND CAST(json_extract(vraiprix,'$.value') AS REAL) > 0"
342 + " AND price BETWEEN ? AND ?", (PRICE_MIN, PRICE_MAX)).fetchall()
343 + fv_sous = fv_marche = fv_sur = 0
344 + fv_city: dict[str, list[float]] = {}
345 + for r in fv_rows:
346 + dev = r["dev"]
347 + if dev is None or not (b_lo < dev < b_hi):
348 + continue
349 + if dev <= FV_SEUIL_SOUS:
350 + fv_sous += 1
351 + elif dev >= FV_SEUIL_SUR:
352 + fv_sur += 1
353 + else:
354 + fv_marche += 1
355 + if r["city"]:
356 + fv_city.setdefault(r["city"], []).append(dev)
357 + fv_n = fv_sous + fv_marche + fv_sur
358 + if fv_n:
359 + kpis.append(kpi("sous_marche", "Annonces sous l'estimation Vrai-Prix",
360 + fv_sous))
215 361 kpis.append(kpi("villes", "Villes couvertes", snap["cities"]))
216 362 kpis.append(kpi("connecteurs", "Connecteurs actifs (période)", conn_cur))
217 363 # indice de tension : retraits / nouvelles entrées (mesuré, pas modélisé)
@@ -219,6 +365,44 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
219 365 kpis.append(kpi("tension", "Tension — retraits / nouvelles",
220 366 round(100.0 * gone_cur / new_cur, 1), "%"))
221 367
368 + # ---- jauges (v2) : couvertures mesurées sur les annonces publiées --------
369 + gauges: list[dict] = []
370 + if act_now:
371 + g_geo = con.execute(
372 + "SELECT COUNT(*) n FROM listings WHERE active=1"
373 + " AND lat IS NOT NULL AND lng IS NOT NULL" + VISIBLE).fetchone()["n"]
374 + g_photo = con.execute(
375 + "SELECT COUNT(*) n FROM listings WHERE active=1"
376 + " AND images IS NOT NULL AND images<>'' AND images<>'[]'"
377 + + VISIBLE).fetchone()["n"]
378 + g_vp = con.execute(
379 + "SELECT COUNT(*) n FROM listings WHERE active=1" + VISIBLE +
380 + " AND CAST(json_extract(vraiprix,'$.value') AS REAL) > 0"
381 + ).fetchone()["n"]
382 + gauges.append({"id": "geoloc", "label": "Fiches géolocalisées",
383 + "value": round(100.0 * g_geo / act_now, 1),
384 + "max": 100, "unit": "%"})
385 + gauges.append({"id": "photos", "label": "Fiches avec photos",
386 + "value": round(100.0 * g_photo / act_now, 1),
387 + "max": 100, "unit": "%"})
388 + if g_vp:
389 + gauges.append({"id": "vraiprix",
390 + "label": "Fiches avec estimation Vrai-Prix",
391 + "value": round(100.0 * g_vp / act_now, 1),
392 + "max": 100, "unit": "%"})
393 + act_all = con.execute(
394 + "SELECT COUNT(*) n FROM listings WHERE active=1 AND dup_hidden=0"
395 + ).fetchone()["n"]
396 + if act_all:
397 + gauges.append({"id": "publiable",
398 + "label": "Hors quarantaine (qualité publiable)",
399 + "value": round(100.0 * (act_all - quar) / act_all, 1),
400 + "max": 100, "unit": "%"})
401 + if qual["c"] is not None:
402 + gauges.append({"id": "completude",
403 + "label": "Complétude moyenne des fiches (0–100)",
404 + "value": qual["c"], "max": 100})
405 +
222 406 # ---- séries quotidiennes ---------------------------------------------------
223 407 days = [_iso(d) for d in _daterange(s_frm, s_to)]
224 408 new_by_day = {r["d"]: r["n"] for r in con.execute(
@@ -236,21 +420,210 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
236 420 "unit": "annonces", "kind": "line",
237 421 "points": [{"t": d, "v": actives_by_day.get(d, 0)} for d in days]},
238 422 {"id": "nouvelles", "title": "Nouvelles annonces par jour",
239 − "unit": "annonces", "kind": "line",
423 + "unit": "annonces", "kind": "bar",
240 424 "points": [{"t": d, "v": new_by_day.get(d, 0)} for d in days]},
241 425 {"id": "retraits", "title": "Retraits (vendues / retirées) par jour",
242 − "unit": "annonces", "kind": "line",
426 + "unit": "annonces", "kind": "bar",
243 427 "points": [{"t": d, "v": gone_by_day.get(d, 0)} for d in days]},
244 428 ]
245 − # comparaison N-1 : seulement si l'an dernier a réellement été observé
246 − y_frm, y_to = s_frm - timedelta(days=365), s_to - timedelta(days=365)
247 − if y_frm >= data_start:
429 + # prix médian demandé des nouvelles inscriptions (jours à >= 3 entrées
430 + # seulement — rien d'interpolé, l'axe saute les jours creux)
431 + med_day: list[dict] = []
432 + day_prices: dict[str, list[float]] = {}
433 + for r in con.execute(
434 + "SELECT date(first_seen,'unixepoch','localtime') d, price"
435 + " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +
436 + " AND price BETWEEN ? AND ?",
437 + (ep_frm, ep_to, PRICE_MIN, PRICE_MAX)):
438 + day_prices.setdefault(r["d"], []).append(r["price"])
439 + for d in days:
440 + ps = day_prices.get(d)
441 + if ps and len(ps) >= 3:
442 + med_day.append({"t": d, "v": round(statistics.median(ps))})
443 + if len(med_day) >= 5:
444 + series.append({
445 + "id": "prix_median_nouvelles",
446 + "title": "Prix médian demandé des nouvelles inscriptions"
447 + " (jours à ≥ 3 entrées)",
448 + "unit": "$", "kind": "area", "points": med_day})
449 + # comparaison période précédente (même longueur) : seulement si elle
450 + # a réellement été observée en entier (rien d'extrapolé)
451 + if prev_ok:
452 + pdays = [_iso(d) for d in _daterange(p_frm, p_to)]
453 + series[0]["compare"] = [{"t": d, "v": actives_by_day.get(d, 0)}
454 + for d in pdays]
248 455 cmp_new = {r["d"]: r["n"] for r in con.execute(
249 456 "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"
250 457 " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +
251 − " GROUP BY d", (_epoch(y_frm), _epoch(y_to + timedelta(days=1))))}
252 − ydays = [_iso(d) for d in _daterange(y_frm, y_to)]
253 − series[1]["compare"] = [{"t": d, "v": cmp_new.get(d, 0)} for d in ydays]
458 + " GROUP BY d", (ep_pfrm, ep_pto))}
459 + series[1]["compare"] = [{"t": d, "v": cmp_new.get(d, 0)}
460 + for d in pdays]
461 +
462 + # ---- sparklines des KPI (v2) — mêmes données que les séries ---------------
463 + if len(days) >= 2:
464 + conn_day = {r["d"]: r["n"] for r in con.execute(
465 + "SELECT date(ts,'unixepoch','localtime') d,"
466 + " COUNT(DISTINCT source) n FROM sync_log"
467 + " WHERE ok=1 AND ts>=? AND ts<? GROUP BY d", (ep_frm, ep_to))}
468 + sparks = {
469 + "actives": [{"t": d, "v": actives_by_day.get(d, 0)} for d in days],
470 + "nouvelles": [{"t": d, "v": new_by_day.get(d, 0)} for d in days],
471 + "retirees": [{"t": d, "v": gone_by_day.get(d, 0)} for d in days],
472 + "connecteurs": [{"t": d, "v": conn_day.get(d, 0)} for d in days],
473 + }
474 + for k in kpis:
475 + sp = sparks.get(k["id"])
476 + if sp and any(p["v"] for p in sp):
477 + k["spark"] = _downsample(sp)
478 +
479 + # ---- multi-courbes (v2) : prix médian des nouvelles inscriptions ----------
480 + # Buckets quotidiens (<= 45 j) ou hebdomadaires ; un bucket n'est gardé que
481 + # si CHAQUE groupe y compte >= 3 inscriptions (axes alignés, rien d'inventé).
482 + def _multiserie(id_, title, group_sql, top_n):
483 + weekly = ndays > 45
484 + bucket_sql = ("strftime('%Y-%m-%d', first_seen, 'unixepoch',"
485 + " 'localtime', 'weekday 1', '-6 days')" if weekly else
486 + "date(first_seen,'unixepoch','localtime')")
487 + rows = con.execute(
488 + f"SELECT {group_sql} g, {bucket_sql} b, price FROM listings"
489 + " WHERE first_seen>=? AND first_seen<?" + VISIBLE +
490 + f" AND price BETWEEN ? AND ? AND {group_sql} <> ''",
491 + (ep_frm, ep_to, PRICE_MIN, PRICE_MAX)).fetchall()
492 + vol: dict[str, int] = {}
493 + data: dict[str, dict[str, list[float]]] = {}
494 + for r in rows:
495 + if "/" in r["g"]: # libellés composites de sources ("Laval /
496 + continue # North Shore") — bruit, pas une vraie ville
497 + vol[r["g"]] = vol.get(r["g"], 0) + 1
498 + data.setdefault(r["g"], {}).setdefault(r["b"], []).append(r["price"])
499 + groups = [g for g, _ in sorted(vol.items(), key=lambda kv: -kv[1])[:top_n]]
500 + if len(groups) < 2:
501 + return None
502 + buckets = sorted({b for g in groups for b in data[g]
503 + if all(len(data[gg].get(b, [])) >= 3 for gg in groups)})
504 + if len(buckets) < 4:
505 + return None
506 + return {"id": id_, "title": title + (" (semaines)" if weekly else ""),
507 + "unit": "$",
508 + "series": [{"label": g, "points": [
509 + {"t": b, "v": round(statistics.median(data[g][b]))}
510 + for b in buckets]} for g in groups]}
511 +
512 + multiseries = []
513 + ms_type = _multiserie(
514 + "prix_type", "Prix médian des nouvelles inscriptions par type",
515 + "property_type", 3)
516 + if ms_type:
517 + multiseries.append(ms_type)
518 + ms_ville = _multiserie(
519 + "prix_ville", "Prix médian des nouvelles inscriptions — grandes villes",
520 + "city", 4)
521 + if ms_ville:
522 + multiseries.append(ms_ville)
523 +
524 + # ---- barres empilées (v2) : nouvelles inscriptions par bannière -----------
525 + names = _source_names()
526 + stacked = []
527 + if len(days) >= 2:
528 + weekly_st = ndays > 60
529 + bucket_st = ("strftime('%Y-%m-%d', first_seen, 'unixepoch',"
530 + " 'localtime', 'weekday 1', '-6 days')" if weekly_st else
531 + "date(first_seen,'unixepoch','localtime')")
532 + fam_day: dict[str, dict[str, int]] = {}
533 + fam_tot: dict[str, int] = {}
534 + for r in con.execute(
535 + f"SELECT source s, {bucket_st} b, COUNT(*) n FROM listings"
536 + " WHERE first_seen>=? AND first_seen<?" + VISIBLE +
537 + " GROUP BY source, b", (ep_frm, ep_to)):
538 + fam = _famille_of(r["s"], names)
539 + fam_day.setdefault(fam, {})
540 + fam_day[fam][r["b"]] = fam_day[fam].get(r["b"], 0) + r["n"]
541 + fam_tot[fam] = fam_tot.get(fam, 0) + r["n"]
542 + if fam_tot:
543 + top_fams = [f for f, _ in
544 + sorted(fam_tot.items(), key=lambda kv: -kv[1])[:5]]
545 + others = [f for f in fam_day if f not in top_fams]
546 + keys = top_fams + (["Autres"] if others else [])
547 + buckets_st = sorted({b for d_ in fam_day.values() for b in d_})
548 + pts = []
549 + for b in buckets_st:
550 + vals = [fam_day[f].get(b, 0) for f in top_fams]
551 + if others:
552 + vals.append(sum(fam_day[f].get(b, 0) for f in others))
553 + pts.append({"t": b, "values": vals})
554 + if len(pts) >= 2:
555 + stacked.append({
556 + "id": "ajouts_bannieres",
557 + "title": "Nouvelles inscriptions par bannière"
558 + + (" (semaines)" if weekly_st else ""),
559 + "unit": "inscriptions", "keys": keys, "points": pts})
560 +
561 + # ---- distributions (v2) : prix, superficie, année de construction ---------
562 + distributions = []
563 + price_bins = [("< 100 k$", 0, 100e3)] + [
564 + (f"{i}00–{i+1}00 k$", i * 100e3, (i + 1) * 100e3) for i in range(1, 10)
565 + ] + [("1–1,5 M$", 1e6, 1.5e6), ("1,5–2 M$", 1.5e6, 2e6),
566 + ("2 M$ +", 2e6, None)]
567 + bins_p = []
568 + for lbl, lo, hi in price_bins:
569 + q = ("SELECT COUNT(*) n FROM listings WHERE active=1 AND price>=?"
570 + + VISIBLE)
571 + args: list = [lo]
572 + if hi is not None:
573 + q += " AND price<?"
574 + args.append(hi)
575 + bins_p.append({"label": lbl,
576 + "value": con.execute(q, args).fetchone()["n"]})
577 + if sum(b["value"] for b in bins_p):
578 + distributions.append({
579 + "id": "prix", "unit": "annonces",
580 + "title": "Distribution des prix demandés (annonces actives)",
581 + "bins": bins_p})
582 + area_bins = [("< 500", 100, 500), ("500–1 000", 500, 1000),
583 + ("1 000–1 500", 1000, 1500), ("1 500–2 000", 1500, 2000),
584 + ("2 000–2 500", 2000, 2500), ("2 500–3 000", 2500, 3000),
585 + ("3 000–4 000", 3000, 4000), ("4 000 +", 4000, 20000)]
586 + bins_a = [{"label": f"{lbl} pi²",
587 + "value": con.execute(
588 + "SELECT COUNT(*) n FROM listings WHERE active=1"
589 + " AND area_sqft>=? AND area_sqft<?" + VISIBLE,
590 + (lo, hi)).fetchone()["n"]}
591 + for lbl, lo, hi in area_bins]
592 + if sum(b["value"] for b in bins_a) >= 100:
593 + distributions.append({
594 + "id": "superficie", "unit": "annonces",
595 + "title": "Distribution des superficies habitables (renseignées)",
596 + "bins": bins_a})
597 + yr_now = today.year
598 + year_bins = ([("< 1900", 1600, 1900), ("1900–1949", 1900, 1950)] +
599 + [(f"{d}–{d+9}", d, d + 10) for d in range(1950, 2020, 10)] +
600 + [("2020 +", 2020, yr_now + 2)])
601 + bins_y = [{"label": lbl,
602 + "value": con.execute(
603 + "SELECT COUNT(*) n FROM listings WHERE active=1"
604 + " AND year_built>=? AND year_built<?" + VISIBLE,
605 + (lo, hi)).fetchone()["n"]}
606 + for lbl, lo, hi in year_bins]
607 + if sum(b["value"] for b in bins_y) >= 100:
608 + distributions.append({
609 + "id": "annee", "unit": "annonces",
610 + "title": "Distribution des années de construction (renseignées)",
611 + "bins": bins_y})
612 +
613 + # ---- heatmap horaire (v2) : détection des nouvelles annonces (7×24) -------
614 + # first_seen = moment où la synchronisation a détecté l'annonce — c'est le
615 + # rythme réel d'alimentation de la plateforme (8 dernières semaines).
616 + h56 = _epoch(max(data_start, s_to - timedelta(days=55)))
617 + hourly_cells = [
618 + {"dow": (int(r["w"]) + 6) % 7, "hour": int(r["h"]), "value": r["n"]}
619 + for r in con.execute(
620 + "SELECT strftime('%w', first_seen,'unixepoch','localtime') w,"
621 + " strftime('%H', first_seen,'unixepoch','localtime') h, COUNT(*) n"
622 + " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +
623 + " GROUP BY w, h", (h56, _epoch(s_to + timedelta(days=1))))]
624 + hourly = ({"title": "Détection de nouvelles annonces par heure"
625 + " (8 dernières semaines)", "cells": hourly_cells}
626 + if len(hourly_cells) >= 12 else None)
254 627
255 628 # ---- répartitions (photo des annonces actives) -----------------------------
256 629 types = [{"label": r["t"] or "Autre / non précisé", "value": r["n"]}
@@ -271,6 +644,25 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
271 644 args.append(hi)
272 645 price_items.append({"label": lbl,
273 646 "value": con.execute(q, args).fetchone()["n"]})
647 + # nouvelles inscriptions de la période par fourchette + delta honnête
648 + # (vs période précédente entièrement observée seulement)
649 + new_price_items = []
650 + for lbl, lo, hi in ranges:
651 + base = ("SELECT COUNT(*) n FROM listings WHERE first_seen>=?"
652 + " AND first_seen<? AND price>=?" + VISIBLE)
653 + args_c: list = [ep_frm, ep_to, lo]
654 + args_p: list = [ep_pfrm, ep_pto, lo]
655 + if hi is not None:
656 + base += " AND price<?"
657 + args_c.append(hi)
658 + args_p.append(hi)
659 + n_c = con.execute(base, args_c).fetchone()["n"]
660 + item = {"label": lbl, "value": n_c}
661 + if prev_ok:
662 + n_p = con.execute(base, args_p).fetchone()["n"]
663 + if n_p:
664 + item["delta_pct"] = _fmt_pct(n_c, n_p)
665 + new_price_items.append(item)
274 666 beds = [{"label": ("8 chambres et +" if r["b"] >= 8
275 667 else f"{int(r['b'])} chambre" + ("s" if r["b"] > 1 else "")),
276 668 "value": r["n"]}
@@ -278,16 +670,40 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
278 670 "SELECT MIN(bedrooms,8) b, COUNT(*) n FROM listings"
279 671 " WHERE active=1 AND bedrooms IS NOT NULL" + VISIBLE +
280 672 " GROUP BY MIN(bedrooms,8) ORDER BY b")]
281 − breakdowns = [
673 + names = _source_names()
674 + by_source = [{"label": names.get(r["s"], r["s"]), "value": r["n"]}
675 + for r in con.execute(
676 + "SELECT source s, COUNT(*) n FROM listings"
677 + " WHERE active=1" + VISIBLE +
678 + " GROUP BY source ORDER BY n DESC LIMIT 12")]
679 + breakdowns = []
680 + if fv_n:
681 + breakdowns.append({
682 + "id": "fairvalue",
683 + "title": "Position des prix demandés vs estimation Vrai-Prix",
684 + "kind": "donut", "items": [
685 + {"label": "Sous le marché", "value": fv_sous},
686 + {"label": "Dans le marché", "value": fv_marche},
687 + {"label": "Au-dessus du marché", "value": fv_sur}]})
688 + breakdowns += [
282 689 {"id": "types", "title": "Répartition par type de propriété",
283 690 "kind": "donut", "items": types},
284 691 {"id": "prix", "title": "Répartition par fourchette de prix demandé",
285 692 "kind": "bar", "items": price_items},
286 693 ]
694 + if sum(i["value"] for i in new_price_items):
695 + breakdowns.append({
696 + "id": "prix_nouvelles",
697 + "title": "Nouvelles inscriptions par fourchette de prix (période)",
698 + "kind": "bar", "items": new_price_items})
287 699 if beds:
288 700 breakdowns.append({"id": "chambres",
289 701 "title": "Répartition par nombre de chambres (renseignées)",
290 702 "kind": "bar", "items": beds})
703 + if by_source:
704 + breakdowns.append({"id": "sources",
705 + "title": "Top sources (annonces actives)",
706 + "kind": "bar", "items": by_source})
291 707
292 708 # ---- géographie : par région (fusion accents/casse, libellé le + fréquent)
293 709 reg_counts: dict[str, dict[str, int]] = {}
@@ -331,24 +747,61 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
331 747 "SELECT city, COUNT(*) n FROM listings WHERE city<>''"
332 748 " AND first_seen>=? AND first_seen<?" + VISIBLE + " GROUP BY city",
333 749 (ep_pfrm, ep_pto))} if prev_ok else {}
750 + gone_city = {r["city"]: r["n"] for r in con.execute(
751 + "SELECT city, COUNT(*) n FROM listings WHERE active=0 AND city<>''"
752 + " AND last_seen>=? AND last_seen<?" + VISIBLE + " GROUP BY city",
753 + (ep_frm, ep_to))}
334 754 top = sorted(city_prices.items(), key=lambda kv: -len(kv[1]))[:50]
335 755 top_rows = []
336 756 for city, ps in top:
337 757 n_new = new_city.get(city, 0)
338 758 n_prev = new_city_prev.get(city, 0)
759 + net = n_new - gone_city.get(city, 0)
339 760 d = _fmt_pct(n_new, n_prev) if prev_ok and n_prev else None
340 761 top_rows.append([
341 762 city, len(ps), _fmt_money(sum(ps) / len(ps)),
342 763 _fmt_money(statistics.median(ps)), n_new,
764 + f"{'+' if net >= 0 else ''}{net}",
343 765 (f"{'+' if d >= 0 else ''}{str(d).replace('.', ',')} %"
344 766 if d is not None else "—"),
345 767 ])
346 768 tables = [{
347 769 "id": "top_villes", "title": "Top villes",
348 770 "columns": ["Ville", "Actives", "Prix moyen", "Prix médian",
349 − "Nouvelles (période)", "Var. nouvelles"],
771 + "Nouvelles (période)", "Δ net (période)", "Var. nouvelles"],
350 772 "rows": top_rows,
351 773 }]
774 + # Top sources : actives, prix moyen, nouvelles, qualité, quarantaine, synchro
775 + top_srcs = con.execute(
776 + """SELECT source s, COUNT(*) n,
777 + AVG(CASE WHEN price>0 THEN price END) avg_p,
778 + SUM(CASE WHEN first_seen>=? AND first_seen<? THEN 1 ELSE 0 END) new_n,
779 + ROUND(AVG(quality_score),0) qual
780 + FROM listings WHERE active=1""" + VISIBLE +
781 + " GROUP BY source ORDER BY n DESC LIMIT 50", (ep_frm, ep_to)).fetchall()
782 + quar_src = {r["s"]: r["n"] for r in con.execute(
783 + "SELECT source s, COUNT(*) n FROM listings"
784 + " WHERE active=1 AND dup_hidden=0 AND published=0 GROUP BY source")}
785 + last_sync = {r["source"]: r["ts"] for r in con.execute(
786 + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}
787 + if top_srcs:
788 + src_rows = []
789 + for r in top_srcs:
790 + ls = last_sync.get(r["s"])
791 + src_rows.append([
792 + names.get(r["s"], r["s"]), r["n"],
793 + _fmt_money(r["avg_p"]) if r["avg_p"] else "—",
794 + r["new_n"],
795 + f"{r['qual']:.0f} /100" if r["qual"] is not None else "—",
796 + quar_src.get(r["s"], 0),
797 + (datetime.fromtimestamp(ls, TZ).strftime("%Y-%m-%d %H:%M")
798 + if ls else "—")])
799 + tables.append({
800 + "id": "top_sources", "title": "Top sources & courtiers",
801 + "columns": ["Source", "Annonces actives", "Prix moyen",
802 + "Nouvelles (période)", "Qualité", "Quarantaine",
803 + "Dernière synchro"],
804 + "rows": src_rows})
352 805 # Délai de présence (retirées de la période) par ville
353 806 dur_city: dict[str, list[float]] = {}
354 807 for r in con.execute(
@@ -372,6 +825,80 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
372 825 "columns": ["Ville", "Retirées", "Délai moyen (j)", "Délai médian (j)"],
373 826 "rows": dur_rows,
374 827 })
828 + # Écart moyen à l'estimation Vrai-Prix par ville (volume suffisant)
829 + fv_rows_city = []
830 + for city, devs in sorted(fv_city.items(), key=lambda kv: -len(kv[1]))[:25]:
831 + if len(devs) < 50:
832 + continue
833 + dev_pct = round(100.0 * sum(devs) / len(devs), 1)
834 + fv_rows_city.append([
835 + city, len(devs),
836 + f"{'+' if dev_pct >= 0 else ''}{str(dev_pct).replace('.', ',')} %",
837 + sum(1 for d in devs if d <= FV_SEUIL_SOUS)])
838 + if fv_rows_city:
839 + tables.append({
840 + "id": "fv_villes",
841 + "title": "Écart à l'estimation Vrai-Prix par ville",
842 + "columns": ["Ville", "Annonces évaluées", "Écart moyen",
843 + "Sous le marché"],
844 + "rows": fv_rows_city})
845 + # Couche qualité : anomalies & quarantaine par motif (quality.py)
846 + anomalies: dict[str, int] = {}
847 + for r in con.execute(
848 + "SELECT quality_issues FROM listings WHERE active=1"
849 + " AND dup_hidden=0 AND quality_issues IS NOT NULL"):
850 + try:
851 + for issue in json.loads(r["quality_issues"]):
852 + key = issue.split(":")[0]
853 + anomalies[key] = anomalies.get(key, 0) + 1
854 + except ValueError:
855 + continue
856 + if anomalies and act_all:
857 + tables.append({
858 + "id": "quarantaine_motifs",
859 + "title": "Couche qualité — anomalies et quarantaine par motif",
860 + "columns": ["Motif", "Annonces touchées", "% des actives"],
861 + "rows": [[_MOTIFS_QUALITE.get(k, k), v,
862 + str(round(100.0 * v / act_all, 1)).replace(".", ",") + " %"]
863 + for k, v in sorted(anomalies.items(), key=lambda kv: -kv[1])],
864 + })
865 + # Bannières / familles de connecteurs : volume, nouvelles, prix, fraîcheur
866 + fam_info: dict[str, dict] = {}
867 + for r in con.execute(
868 + "SELECT source s, price p FROM listings WHERE active=1" + VISIBLE):
869 + e = fam_info.setdefault(_famille_of(r["s"], names),
870 + {"srcs": set(), "prices": [], "new": 0,
871 + "sync": None})
872 + e["srcs"].add(r["s"])
873 + e["prices"].append(r["p"])
874 + for r in con.execute(
875 + "SELECT source s, COUNT(*) n FROM listings WHERE first_seen>=?"
876 + " AND first_seen<?" + VISIBLE + " GROUP BY source",
877 + (ep_frm, ep_to)):
878 + fam = _famille_of(r["s"], names)
879 + if fam in fam_info:
880 + fam_info[fam]["new"] += r["n"]
881 + for src, ts in last_sync.items():
882 + fam = _famille_of(src, names)
883 + if fam in fam_info:
884 + e = fam_info[fam]
885 + e["sync"] = max(e["sync"] or 0, ts)
886 + if fam_info:
887 + fam_rows = []
888 + for fam, e in sorted(fam_info.items(),
889 + key=lambda kv: -len(kv[1]["prices"]))[:30]:
890 + fam_rows.append([
891 + fam, len(e["srcs"]), len(e["prices"]), e["new"],
892 + _fmt_money(statistics.median(e["prices"])) if e["prices"] else "—",
893 + (datetime.fromtimestamp(e["sync"], TZ).strftime("%Y-%m-%d %H:%M")
894 + if e["sync"] else "—")])
895 + tables.append({
896 + "id": "familles",
897 + "title": "Bannières & familles de connecteurs",
898 + "columns": ["Bannière / famille", "Connecteurs", "Annonces actives",
899 + "Nouvelles (période)", "Prix médian",
900 + "Dernière synchro"],
901 + "rows": fam_rows})
375 902
376 903 # ---- records & faits marquants ---------------------------------------------
377 904 records = []
@@ -380,6 +907,11 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
380 907 records.append({"label": "Jour record de nouvelles annonces",
381 908 "value": f"{best[1]:,} annonces".replace(",", " "),
382 909 "date": best[0]})
910 + if gone_by_day:
911 + worst = max(gone_by_day.items(), key=lambda kv: kv[1])
912 + records.append({"label": "Jour record de retraits",
913 + "value": f"{worst[1]:,} annonces".replace(",", " "),
914 + "date": worst[0]})
383 915 fast = con.execute(
384 916 "SELECT city, address, (last_seen-first_seen)/86400.0 d,"
385 917 " date(last_seen,'unixepoch','localtime') dt FROM listings"
@@ -393,25 +925,55 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
393 925 records.append({"label": "Retrait le plus rapide (mise en ligne → retrait)",
394 926 "value": val + (f" · {fast['city']}" if fast["city"] else ""),
395 927 "date": fast["dt"]})
396 − drop = con.execute(
397 − """SELECT l.city, MAX(p1.price - p2.price) drop_amt,
398 − date(MAX(p2.ts),'unixepoch','localtime') dt, l.uid
399 − FROM price_log p1
400 − JOIN price_log p2 ON p2.uid = p1.uid AND p2.ts > p1.ts
401 − JOIN listings l ON l.uid = p1.uid
402 − WHERE p2.ts>=? AND p2.ts<? AND p1.price > p2.price
403 − AND l.dup_hidden=0
404 − GROUP BY l.uid ORDER BY drop_amt DESC LIMIT 1""",
405 − (ep_frm, ep_to)).fetchone()
406 − if drop and drop["drop_amt"]:
928 + if drops: # balayage price_log fait plus haut (KPI baisses_prix)
929 + drop = drops[0]
407 930 records.append({"label": "Plus forte baisse de prix demandé",
408 − "value": "−" + _fmt_money(drop["drop_amt"]) +
931 + "value": "−" + _fmt_money(drop["amt"]) +
409 932 (f" · {drop['city']}" if drop["city"] else ""),
410 933 "date": drop["dt"]})
411 934 if new_city:
412 935 c, n = max(new_city.items(), key=lambda kv: kv[1])
413 936 records.append({"label": "Ville la plus active (nouvelles annonces)",
414 937 "value": f"{c} — {n:,} annonces".replace(",", " ")})
938 + if top_srcs:
939 + src = max(top_srcs, key=lambda r: r["new_n"])
940 + if src["new_n"]:
941 + records.append({"label": "Source la plus active (nouvelles annonces)",
942 + "value": f"{names.get(src['s'], src['s'])}"
943 + f" — {src['new_n']:,}".replace(",", " ")})
944 + top_price = con.execute(
945 + "SELECT city, price FROM listings WHERE active=1" + VISIBLE +
946 + " AND price BETWEEN ? AND ? ORDER BY price DESC LIMIT 1",
947 + (PRICE_MIN, PRICE_MAX)).fetchone()
948 + if top_price:
949 + records.append({"label": "Inscription active la plus chère",
950 + "value": _fmt_money(top_price["price"]) +
951 + (f" · {top_price['city']}"
952 + if top_price["city"] else "")})
953 + med_cities = {c: statistics.median(ps) for c, ps in city_prices.items()
954 + if len(ps) >= 30}
955 + if med_cities:
956 + c_hi = max(med_cities, key=med_cities.get)
957 + c_lo = min(med_cities, key=med_cities.get)
958 + records.append({"label": "Ville la plus chère (prix médian, ≥ 30 annonces)",
959 + "value": f"{c_hi} — {_fmt_money(med_cities[c_hi])}"})
960 + records.append({"label": "Ville la plus abordable (prix médian, ≥ 30 annonces)",
961 + "value": f"{c_lo} — {_fmt_money(med_cities[c_lo])}"})
962 + big_area = con.execute(
963 + "SELECT city, area_sqft a FROM listings WHERE active=1" + VISIBLE +
964 + " AND area_sqft BETWEEN 100 AND 50000"
965 + " ORDER BY area_sqft DESC LIMIT 1").fetchone()
966 + if big_area:
967 + records.append({"label": "Plus grande superficie habitable (plausible)",
968 + "value": f"{round(big_area['a']):,} pi²".replace(",", " ") +
969 + (f" · {big_area['city']}"
970 + if big_area["city"] else "")})
971 + if fam_info:
972 + fam_big = max(fam_info.items(), key=lambda kv: len(kv[1]["srcs"]))
973 + if len(fam_big[1]["srcs"]) > 1:
974 + records.append({"label": "Bannière au plus grand réseau agrégé",
975 + "value": f"{fam_big[0]} — "
976 + f"{len(fam_big[1]['srcs'])} connecteurs"})
415 977
416 978 out = {
417 979 "updated": datetime.now(TZ).isoformat(timespec="seconds"),
@@ -423,10 +985,20 @@ def _compute_con(con, frm_q, to_q, period) -> dict:
423 985 "tables": tables,
424 986 "records": records,
425 987 }
988 + if gauges:
989 + out["gauges"] = gauges
990 + if multiseries:
991 + out["multiseries"] = multiseries
992 + if stacked:
993 + out["stacked"] = stacked
994 + if distributions:
995 + out["distributions"] = distributions
426 996 if geo:
427 997 out["geo"] = geo
428 998 if heatmap:
429 999 out["heatmap"] = heatmap
1000 + if hourly:
1001 + out["hourly"] = hourly
430 1002 return out
431 1003
432 1004
modified immoka/web.py +25 −12
@@ -49,10 +49,11 @@ _sync_lock = threading.Lock()
49 49 # db.refresh_dedup après chaque sync) marque les doublons de sous-agences. La
50 50 # lecture est ainsi instantanée (index) au lieu d'un sous-select corrélé par
51 51 # ligne (~300 s sur 75 k lignes). Voir db.refresh_dedup pour la règle.
52 −# + `price IS NOT NULL` : on masque partout les annonces « Prix sur demande »
53 −# (sans prix) — grille, carte, stats, sources. La fiche détail par uid reste
54 −# accessible en direct.
55 −DEDUP_CLAUSE = " AND dup_hidden=0 AND price IS NOT NULL"
52 +# + `published=1` : seuil de qualité minimum (voir immoka/quality.py) — une
53 +# annonce sans prix plausible, sans ville, sans type de bien ou sans contenu
54 +# exploitable part en QUARANTAINE (grille, carte, stats, sources) jusqu'à
55 +# complétion par les prochains cycles. La fiche détail par uid reste accessible.
56 +DEDUP_CLAUSE = " AND dup_hidden=0 AND published=1"
56 57
57 58
58 59 def _row_to_dict(row) -> dict:
@@ -189,7 +190,8 @@ def listings_geojson(
189 190 """
190 191 con = db.connect()
191 192 sql = ("SELECT uid, title, address, price, price_label, property_type,"
192 − " bedrooms, bathrooms, source, city, sector, images, lat, lng, vraiprix"
193 + " bedrooms, bathrooms, source, city, sector, images, lat, lng, vraiprix,"
194 + " json_extract(COALESCE(details,'{}'),'$.cover_thumb') AS cover_thumb"
193 195 " FROM listings WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")
194 196 args: list = []
195 197 if bbox:
@@ -252,7 +254,8 @@ def listings_geojson(
252 254 "property_type": r["property_type"], "bedrooms": r["bedrooms"],
253 255 "bathrooms": r["bathrooms"], "source": r["source"],
254 256 "city": r["city"], "sector": r["sector"],
255 − "image": images[0] if images else None,
257 + # miniature légère pour le popup mobile si le connecteur en a une
258 + "image": r["cover_thumb"] or (images[0] if images else None),
256 259 "vp": vp_val,
257 260 },
258 261 })
@@ -406,7 +409,15 @@ def stats():
406 409 (_agg(k, v) for k, v in deltas.items() if len(v) >= 30),
407 410 key=lambda x: -x["median_delta_pct"]),
408 411 }
409 − return {**dict(row), "vraiprix": vraiprix, "recent_syncs": log}
412 + # qualité des données : complétude, quarantaine, anomalies (par connecteur)
413 + from . import quality
414 + con2 = db.connect()
415 + try:
416 + qualite = quality.summary(con2)
417 + finally:
418 + con2.close()
419 + return {**dict(row), "vraiprix": vraiprix, "qualite": qualite,
420 + "recent_syncs": log}
410 421
411 422
412 423 # --- Module Stats commun Groupe KA (voir frontend/src/ka/stats/SPEC.md) ------
@@ -427,7 +438,10 @@ def stats_report(
427 438 to: str | None = Query(None),
428 439 mode: str = Query("complet"),
429 440 ):
430 − """Rapport PDF estampillé Groupe-KA (gabarit commun immoka/kapdf.py)."""
441 + """Rapport PDF estampillé Groupe-KA (gabarit commun immoka/kapdf.py v2).
442 +
443 + 5 modes : complet | synthese | tendances | repartitions | donnees —
444 + un mode inconnu retombe sur `complet` (rétrocompatible v1)."""
431 445 from . import kapdf
432 446 dash = kastats.dashboard(period, from_, to)
433 447 site = {
@@ -436,13 +450,12 @@ def stats_report(
436 450 "domain": "www.immo-ka.com",
437 451 "tagline": "Agrégateur de propriétés à vendre — province de Québec",
438 452 }
439 − pdf = kapdf.GroupeKAReport(
440 − site=site, dashboard=dash,
441 − mode="synthese" if mode == "synthese" else "complet").build()
453 + mode = mode if mode in kapdf.REPORT_MODES else "complet"
454 + pdf = kapdf.GroupeKAReport(site=site, dashboard=dash, mode=mode).build()
442 455 return Response(
443 456 content=pdf, media_type="application/pdf",
444 457 headers={"Content-Disposition":
445 − f'attachment; filename="{kapdf.filename("immo-ka", period)}"'})
458 + f'attachment; filename="{kapdf.filename("immo-ka", period, mode)}"'})
446 459
447 460
448 461 @app.post("/api/sync")
added scripts/valider_rendu.py +166 −0
@@ -0,0 +1,166 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# scripts/valider_rendu.py : validation automatisée du rendu d'affichage.
5 +#
6 +# Matrice largeurs × pages (Playwright/Chromium) : pour chaque combinaison —
7 +# · AUCUN débordement horizontal (scrollWidth <= largeur de la fenêtre)
8 +# · aucune image cassée parmi les images chargées (naturalWidth > 0)
9 +# · cibles tactiles : liens/boutons visibles < 40 px de haut signalés
10 +# · erreurs console JavaScript
11 +# · capture d'écran pleine page (régression visuelle : comparer les runs)
12 +#
13 +# Usage : python3 scripts/valider_rendu.py [BASE_URL] [DOSSIER_SORTIE]
14 +# Par défaut : http://localhost:18096 (tunnel SSH vers M4M64a:8096) et
15 +# docs/screenshots/validation-<date>/ . Code retour 1 si un échec bloquant.
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import json
20 +import sys
21 +import time
22 +import urllib.request
23 +from datetime import date
24 +from pathlib import Path
25 +
26 +from playwright.sync_api import sync_playwright
27 +
28 +BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:18096"
29 +OUT = Path(sys.argv[2] if len(sys.argv) > 2 else
30 + f"docs/screenshots/validation-{date.today().isoformat()}")
31 +
32 +# matrice de largeurs : smartphones (portrait), paysage mobile, tablette, desktop
33 +VIEWPORTS = [
34 + ("mobile-360", 360, 780, True),
35 + ("mobile-390", 390, 844, True),
36 + ("mobile-414", 414, 896, True),
37 + ("paysage-844", 844, 390, True),
38 + ("tablette-768", 768, 1024, True),
39 + ("desktop-1024", 1024, 768, False),
40 + ("desktop-1440", 1440, 900, False),
41 +]
42 +
43 +
44 +def _api(path: str):
45 + with urllib.request.urlopen(BASE + path, timeout=30) as r:
46 + return json.loads(r.read().decode())
47 +
48 +
49 +def pick_cases() -> list[tuple[str, str]]:
50 + """Pages à tester — routes fixes + cas limites réels choisis dans l'API."""
51 + pages: list[tuple[str, str]] = [
52 + ("accueil", "/"),
53 + ("recherche-carte", "/?view=carte"),
54 + ("recherche-sans-resultat", "/?q=zzzz-introuvable-zzz"),
55 + ("agences", "/agences"),
56 + ("stats", "/stats"),
57 + ("contact", "/contact"),
58 + ]
59 + try:
60 + ls = _api("/api/listings?limit=500")["listings"]
61 + by_imgs = sorted(ls, key=lambda l: len(l.get("images") or []))
62 + cases = {
63 + "fiche-tres-photographiee": by_imgs[-1],
64 + "fiche-1-photo": next((l for l in by_imgs if len(l.get("images") or []) <= 1), None),
65 + "fiche-desc-longue": max(ls, key=lambda l: len(l.get("description") or "")),
66 + "fiche-prix-eleve": max(ls, key=lambda l: l.get("price") or 0),
67 + "fiche-sans-superficie": next((l for l in ls if l.get("area_sqft") is None), None),
68 + }
69 + from urllib.parse import quote
70 + for name, l in cases.items():
71 + if l:
72 + pages.append((name, f"/propriete/{quote(l['uid'], safe='')}"))
73 + except Exception as exc:
74 + print(f"⚠ cas limites non échantillonnés ({exc}) — routes fixes seulement")
75 + return pages
76 +
77 +
78 +def main() -> int:
79 + OUT.mkdir(parents=True, exist_ok=True)
80 + pages = pick_cases()
81 + rows = []
82 + failures = 0
83 + with sync_playwright() as p:
84 + # --use-gl=angle : WebGL2 requis par la carte Mapbox (Ka Maps) en headless
85 + browser = p.chromium.launch(args=["--use-gl=angle"])
86 + for vp_name, w, h, mobile in VIEWPORTS:
87 + ctx = browser.new_context(
88 + viewport={"width": w, "height": h},
89 + is_mobile=mobile, has_touch=mobile,
90 + device_scale_factor=2 if mobile else 1,
91 + user_agent=("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
92 + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 "
93 + "Mobile/15E148 Safari/604.1") if mobile else None,
94 + )
95 + page = ctx.new_page()
96 + console_errors: list[str] = []
97 + page.on("console", lambda m: console_errors.append(m.text)
98 + if m.type == "error" else None)
99 + for page_name, path in pages:
100 + console_errors.clear()
101 + try:
102 + page.goto(BASE + path, wait_until="networkidle", timeout=45000)
103 + except Exception:
104 + pass # networkidle peut expirer (carte) — on évalue quand même
105 + time.sleep(1.2)
106 + m = page.evaluate("""() => {
107 + const d = document.documentElement;
108 + const overflow = Math.max(d.scrollWidth, document.body.scrollWidth)
109 + - (window.innerWidth + 1);
110 + let broken = 0, loaded = 0;
111 + for (const img of document.images) {
112 + if (!img.complete) continue;
113 + loaded++;
114 + if (img.naturalWidth === 0 && img.src.startsWith('http')) broken++;
115 + }
116 + let smallTargets = 0;
117 + for (const el of document.querySelectorAll('a,button')) {
118 + const r = el.getBoundingClientRect();
119 + if (r.width > 0 && r.height > 0 && r.height < 40 && r.width < 40)
120 + smallTargets++;
121 + }
122 + return {overflow: Math.max(0, overflow), broken, loaded, smallTargets};
123 + }""")
124 + shot = OUT / f"{page_name}--{vp_name}.png"
125 + try:
126 + page.screenshot(path=str(shot), full_page=(page_name != "recherche-carte"))
127 + except Exception:
128 + page.screenshot(path=str(shot))
129 + errs = [e for e in console_errors
130 + if "favicon" not in e and "maplibre" not in e.lower()]
131 + ok = m["overflow"] == 0 and m["broken"] == 0
132 + if not ok:
133 + failures += 1
134 + rows.append({
135 + "page": page_name, "viewport": vp_name, "ok": ok,
136 + "debordement_px": m["overflow"], "img_cassees": m["broken"],
137 + "img_chargees": m["loaded"], "cibles_petites": m["smallTargets"],
138 + "erreurs_js": len(errs),
139 + })
140 + flag = "OK " if ok else "ÉCHEC"
141 + print(f"[{flag}] {page_name:28s} {vp_name:12s} "
142 + f"overflow={m['overflow']}px imgs cassées={m['broken']} "
143 + f"petites cibles={m['smallTargets']} jsErr={len(errs)}")
144 + ctx.close()
145 + browser.close()
146 +
147 + # rapport markdown
148 + md = ["# Rapport de validation du rendu — Immo-Ka",
149 + f"\nDate : {date.today().isoformat()} · Base : {BASE}",
150 + f"\nCombinaisons testées : {len(rows)} — échecs : {failures}\n",
151 + "| Page | Largeur | Statut | Débordement | Img cassées | Petites cibles | Erreurs JS |",
152 + "|---|---|---|---|---|---|---|"]
153 + for r in rows:
154 + md.append(f"| {r['page']} | {r['viewport']} | "
155 + f"{'✅ OK' if r['ok'] else '❌ ÉCHEC'} | {r['debordement_px']} px | "
156 + f"{r['img_cassees']}/{r['img_chargees']} | {r['cibles_petites']} | "
157 + f"{r['erreurs_js']} |")
158 + (OUT / "rapport-validation.md").write_text("\n".join(md), encoding="utf-8")
159 + (OUT / "rapport-validation.json").write_text(json.dumps(rows, indent=1),
160 + encoding="utf-8")
161 + print(f"\nRapport : {OUT}/rapport-validation.md — {failures} échec(s)")
162 + return 1 if failures else 0
163 +
164 +
165 +if __name__ == "__main__":
166 + sys.exit(main())
167