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)
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, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 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*>|^\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 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("&", "&")).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 | ||