SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%

Lou-Ka — agrégateur de logements à louer (Québec, Lévis, Grand Montréal)

Plateforme complète : 68 connecteurs de gestionnaires immobiliers, schéma
standardisé avec détection de changements (hash), API FastAPI, frontend
React/Vite PWA (design éditorial sharp, thème clair), déployée sur
www.lou-ka.com avec resynchronisation horaire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 4 days ago (Aug 6, 2026)

Showing 96 changed files with +16,690 and −0

added .gitignore +8 −0
@@ -0,0 +1,8 @@
1 +.env
2 +.venv/
3 +__pycache__/
4 +*.pyc
5 +data/louka.db
6 +frontend/node_modules/
7 +frontend/dist/
8 +.DS_Store
added README.md +179 −0
@@ -0,0 +1,179 @@
1 +<div align="center">
2 +
3 +# Lou·Ka
4 +
5 +### Tous les logements à louer du Québec. Un seul endroit.
6 +
7 +**[www.lou-ka.com](https://www.lou-ka.com)**
8 +
9 +![Python](https://img.shields.io/badge/Python-3.14-141814?style=for-the-badge&logo=python&logoColor=d9f26b)
10 +![FastAPI](https://img.shields.io/badge/FastAPI-API-141814?style=for-the-badge&logo=fastapi&logoColor=d9f26b)
11 +![React](https://img.shields.io/badge/React_18-Vite_+_TS-141814?style=for-the-badge&logo=react&logoColor=d9f26b)
12 +![SQLite](https://img.shields.io/badge/SQLite-storage-141814?style=for-the-badge&logo=sqlite&logoColor=d9f26b)
13 +![PWA](https://img.shields.io/badge/PWA-mobile_ready-141814?style=for-the-badge&logoColor=d9f26b)
14 +
15 +![Sources](https://img.shields.io/badge/sources_recens%C3%A9es-74-1c5c41?style=flat-square)
16 +![Connecteurs](https://img.shields.io/badge/connecteurs_actifs-68-1c5c41?style=flat-square)
17 +![Annonces](https://img.shields.io/badge/annonces_agr%C3%A9g%C3%A9es-4000%2B-1c5c41?style=flat-square)
18 +![Couverture](https://img.shields.io/badge/couverture-Qu%C3%A9bec_·_L%C3%A9vis_·_Grand_Montr%C3%A9al-1c5c41?style=flat-square)
19 +
20 +*Agrégateur indépendant de logements locatifs — chaque annonce avec toutes ses photos,
21 +ses détails standardisés, et un lien direct vers l'annonce originale du gestionnaire.
22 +Toujours à jour, automatiquement.*
23 +
24 +</div>
25 +
26 +---
27 +
28 +## Pourquoi Lou-Ka ?
29 +
30 +Chercher un appartement au Québec, c'est ouvrir 70 sites web différents — chacun avec sa
31 +propre navigation, ses propres filtres, son propre format. **Lou-Ka retourne le problème** :
32 +un connecteur dédié par gestionnaire immobilier visite chaque site, normalise chaque annonce
33 +vers un schéma unique, et détecte les changements en continu.
34 +
35 +> Les sites d'agences n'offrent pas de webhooks. Lou-Ka reproduit l'équivalent :
36 +> **synchronisation périodique + hash de contenu** → ajouts, mises à jour et retraits
37 +> détectés automatiquement. Une annonce qui disparaît du site source disparaît de Lou-Ka.
38 +
39 +## L'architecture en 30 secondes
40 +
41 +```mermaid
42 +flowchart LR
43 + subgraph Sources["74 gestionnaires immobiliers"]
44 + S1["Logisco · Cogir · CAPREIT<br/>Immostar · DMA · Laberge<br/>Akelius · Devimco · Mondev<br/>… 68 connecteurs actifs"]
45 + end
46 + subgraph LouKa["Lou-Ka"]
47 + C["Connecteurs<br/><i>1 adaptateur / site</i>"] --> N["Normalisation<br/><i>schéma Listing unique</i>"]
48 + N --> D[("SQLite<br/>hash + diff")]
49 + D --> A["API FastAPI<br/>/api/listings · /api/facets"]
50 + A --> F["React 18 + Vite<br/>PWA mobile · thème clair"]
51 + end
52 + W["⏱ Watcher horaire<br/>(PM2)"] -.-> C
53 + S1 --> C
54 + F --> U["🔑 Locataire"]
55 +```
56 +
57 +| Couche | Rôle | Fichiers |
58 +|---|---|---|
59 +| **Connecteurs** | 1 module Python par gestionnaire : HTML rendu serveur, API JSON internes (Building Stack, RealVuu, Planpoint, Rentsync, source.immo, JetEngine…), ou Firecrawl pour les sites derrière Cloudflare | `louka/connectors/*.py` |
60 +| **Schéma** | `Listing` standardisé : adresse, secteur, ville, type (3½…), prix, disponibilité, commodités, **toutes les images** | `louka/schema.py` |
61 +| **Diff engine** | Upsert par hash de contenu — nouvelle / modifiée / disparue (désactivée) | `louka/db.py` |
62 +| **API** | Filtres ville / secteur / taille / prix / gestionnaire / recherche, facettes, stats, déclencheur de sync | `louka/web.py` |
63 +| **Frontend** | Design « éditorial sharp » : Space Grotesk, ombres décalées, accent lime, ticker temps réel, bottom sheet mobile, galeries photos, PWA installable | `frontend/` |
64 +
65 +## Démarrage rapide
66 +
67 +```bash
68 +git clone https://github.com/spboucher-ai/lou-ka.git && cd lou-ka
69 +
70 +# Backend
71 +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
72 +
73 +# Frontend
74 +cd frontend && npm install && npm run build && cd ..
75 +
76 +# (optionnel) sites JavaScript/anti-bot
77 +echo "FIRECRAWL_API_KEY=fc-votre-cle" > .env
78 +
79 +# Ingestion puis service
80 +.venv/bin/python run.py sync # toutes les sources (ou: run.py sync logisco msi)
81 +.venv/bin/python run.py serve 8080 # → http://localhost:8080
82 +.venv/bin/python run.py watch 60 # resynchronisation en boucle (minutes)
83 +```
84 +
85 +## Ajouter un gestionnaire (≈ 30 lignes)
86 +
87 +L'enregistrement est **auto-découvrant** : déposez un module dans `louka/connectors/`,
88 +c'est tout — aucun fichier partagé à modifier.
89 +
90 +```python
91 +# louka/connectors/mon_agence.py
92 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
93 +from .base import BaseConnector
94 +
95 +class MonAgenceConnector(BaseConnector):
96 + source_id = "mon_agence"
97 +
98 + def fetch(self) -> list[Listing]:
99 + html = self.get("https://mon-agence.ca/logements").text # throttlé, poli
100 + # ... parser les cartes, les fiches, les photos ...
101 + return [Listing(
102 + source=self.source_id, external_id="123",
103 + url="https://mon-agence.ca/logement/123",
104 + title="555, avenue Exemple", sector="Limoilou",
105 + city=infer_city("Limoilou"), unit_type=normalize_unit_type("4 1/2"),
106 + price=parse_price("1 250 $ / mois"), images=[...],
107 + )]
108 +```
109 +
110 +Puis : `.venv/bin/python run.py sync mon_agence` — et l'annonce apparaît sur le site,
111 +avec sa fiche, sa galerie et son lien source. Ajoutez l'entrée correspondante dans
112 +`data/sources.json` pour la page **Sources**.
113 +
114 +## API
115 +
116 +| Endpoint | Description |
117 +|---|---|
118 +| `GET /api/listings?city=&sector=&unit_type=&source=&price_min=&price_max=&q=` | Recherche filtrée, triée par prix |
119 +| `GET /api/listings/{uid}` | Fiche complète (toutes les images, commodités, source) |
120 +| `GET /api/facets` | Valeurs distinctes pour construire les filtres |
121 +| `GET /api/sources` | Registre des 74 gestionnaires + compteurs + dernière sync |
122 +| `GET /api/stats` | Totaux par région, loyer moyen, journal de synchronisation |
123 +| `POST /api/sync` | Déclenche une synchronisation en arrière-plan |
124 +
125 +## Couverture
126 +
127 +**Ville de Québec & Lévis** — Logisco, Cogir, Groupe Laberge, Immostar, DMA/Locago,
128 +Groupe Dallaire, Trudel, Immeubles Roussin, Immeubles Simard, MSI, Gestipro, Logisma,
129 +Lafrance & Mathieu, SIB, SDG, SGIQ, GIM Côté, Logisbourg, Bribourg, Paul-E. Richard,
130 +Headway, Contraste, Appartements Urbains, Picard, Brochu, GParadis, CAPREIT, Lokalia,
131 +Immoappart, OK Louer, et une douzaine de complexes (Huma, Le Clif, Terra, La Klé,
132 +Sentinelle, Rivero, Viridi, Quartier les Éléments…).
133 +
134 +**Grand Montréal** — Akelius, InterRent, Boardwalk, Minto, MetCap, Realstar, Hazelview,
135 +Groupe Copley, Cromwell, Lynk/Olymbec, Trylon, Plan A, Lofts MTL, Axia, Mondev, Devimco,
136 +Collection Équinoxe (Batimo/EMD), Progim, Rentalys, UTILE, Werkliv, 1 Square Phillips,
137 +Firma, Le Domaine, Beaudoin, Denux, Gestion Montréal, Nid d'Amour, SHDM…
138 +
139 +Chaque source non-connectable est **documentée avec sa raison** dans `data/sources.json`
140 +(ex. : aucun prix affiché, inventaire vide, site placeholder).
141 +
142 +## Production
143 +
144 +Déployé sous **PM2** (3 processus) derrière **ngrok** :
145 +
146 +```
147 +lou-ka-web .venv/bin/python run.py serve 8095 # API + frontend
148 +lou-ka-sync .venv/bin/python run.py watch 60 # resync horaire
149 +lou-ka-ngrok ngrok http --url=www.lou-ka.com 8095 # tunnel
150 +```
151 +
152 +Philosophie d'exploitation : **on ne pousse que le code — le serveur maintient ses
153 +données lui-même.**
154 +
155 +## Principes
156 +
157 +1. **Politesse** — délai ≥ 0,5 s entre requêtes, garde-fous de crawl, User-Agent identifié.
158 +2. **Fidélité** — aucun prix inventé : si la source n'affiche pas de prix, `price = null`.
159 +3. **Traçabilité** — chaque fiche renvoie vers l'annonce originale du gestionnaire.
160 +4. **Robustesse** — un connecteur qui casse n'affecte jamais les autres (auto-découverte
161 + tolérante, try/except par annonce, journal `sync_log`).
162 +
163 +---
164 +
165 +<div align="center">
166 +
167 +## Auteur
168 +
169 +**Simon-Pierre Boucher**
170 +
171 +[![Email](https://img.shields.io/badge/contact@spboucher.ai-141814?style=for-the-badge&logo=minutemailer&logoColor=d9f26b)](mailto:contact@spboucher.ai)
172 +[![GitHub](https://img.shields.io/badge/spboucher--ai-141814?style=for-the-badge&logo=github&logoColor=d9f26b)](https://github.com/spboucher-ai)
173 +
174 +*Conçu, construit et déployé en une journée — de la recherche de marché
175 +(74 gestionnaires recensés et vérifiés) au produit en production.*
176 +
177 +© 2026 Simon-Pierre Boucher — tous droits réservés.
178 +
179 +</div>
added data/sources.json +745 −0
@@ -0,0 +1,745 @@
1 +{
2 + "_comment": "Lou-Ka — Registre des sources (agences/gestionnaires immobiliers, Québec & Lévis). Auteur : Simon-Pierre Boucher — contact@spboucher.ai",
3 + "sources": [
4 + {
5 + "id": "lafrance_mathieu",
6 + "name": "Lafrance & Mathieu",
7 + "url": "https://lafrance-mathieu.com",
8 + "listing_url": "https://lafrance-mathieu.com/louer-appartement-quebec",
9 + "sectors": "Beauport, Charlesbourg, Limoilou, St-Roch, Lévis, Val-Bélair, Loretteville, L'Ancienne-Lorette, St-Augustin",
10 + "connector": "lafrance_mathieu",
11 + "status": "actif",
12 + "region": "Québec"
13 + },
14 + {
15 + "id": "msi",
16 + "name": "MSI Gestion immobilière",
17 + "url": "https://www.msimmobiliers.com",
18 + "listing_url": "https://www.msimmobiliers.com/appartements-a-louer/quebec",
19 + "sectors": "Ville de Québec, Lévis, Limoilou, Beauport, Ste-Foy, Vanier, St-Nicolas",
20 + "connector": "msi",
21 + "status": "actif",
22 + "region": "Québec"
23 + },
24 + {
25 + "id": "logisco",
26 + "name": "Logisco",
27 + "url": "https://logisco.com/fr",
28 + "listing_url": "https://logisco.com/fr/appartements-a-louer",
29 + "sectors": "Québec (Ste-Foy, Val-Bélair, Loretteville, Vanier, St-Augustin) + Lévis (St-Romuald, St-David, St-Nicolas, Desjardins)",
30 + "connector": "logisco",
31 + "status": "actif",
32 + "region": "Québec"
33 + },
34 + {
35 + "id": "laberge",
36 + "name": "Groupe Laberge",
37 + "url": "https://www.laberge.qc.ca",
38 + "listing_url": "https://www.laberge.qc.ca/recherche",
39 + "sectors": "Ste-Foy, Beauport, Vanier, L'Ancienne-Lorette, Limoilou",
40 + "connector": "laberge",
41 + "status": "actif",
42 + "region": "Québec"
43 + },
44 + {
45 + "id": "cogir",
46 + "name": "Cogir Immobilier",
47 + "url": "https://www.cogir.net",
48 + "listing_url": "https://www.cogir.net/gestion-immeubles-residentiels.html",
49 + "sectors": "Québec (Le Bacc, Loretteville, Père-Lelièvre...)",
50 + "connector": "cogir",
51 + "status": "actif",
52 + "region": "Québec"
53 + },
54 + {
55 + "id": "immostar",
56 + "name": "Immostar",
57 + "url": "https://immostar.ca",
58 + "listing_url": "https://immostaralouer.ca",
59 + "sectors": "Ste-Foy/blvd Laurier, Cap-Rouge, Lévis (Loges St-Nicolas), Wendake",
60 + "connector": "immostar",
61 + "status": "actif",
62 + "region": "Québec"
63 + },
64 + {
65 + "id": "dma_locago",
66 + "name": "DMA / Locago (Douville, Moffet & Associés)",
67 + "url": "https://locago.ca",
68 + "listing_url": "https://locago.ca",
69 + "sectors": "Ste-Foy, Vanier, Lebourgneuf, Charlesbourg, Beauport",
70 + "connector": "dma_locago",
71 + "status": "actif",
72 + "region": "Québec"
73 + },
74 + {
75 + "id": "groupe_dallaire",
76 + "name": "Groupe Dallaire (Faubourg du Moulin)",
77 + "url": "https://faubourgdumoulin.ca",
78 + "listing_url": "https://faubourgdumoulin.ca/disponibilite",
79 + "sectors": "Québec (Alizé I-II)",
80 + "connector": "groupe_dallaire",
81 + "status": "actif",
82 + "region": "Québec"
83 + },
84 + {
85 + "id": "trudel",
86 + "name": "Trudel (Fleur de Lys, LGC)",
87 + "url": "https://trudel.ca",
88 + "listing_url": "https://18juillet.trudel.ca",
89 + "sectors": "Vanier/Fleur de Lys, Charlesbourg",
90 + "connector": "trudel",
91 + "status": "actif",
92 + "region": "Québec"
93 + },
94 + {
95 + "id": "roussin",
96 + "name": "Immeubles Roussin",
97 + "url": "https://immeublesroussin.com",
98 + "listing_url": "https://immeublesroussin.com",
99 + "sectors": "Sainte-Foy, Lévis, Beauport",
100 + "connector": "roussin",
101 + "status": "actif",
102 + "region": "Québec"
103 + },
104 + {
105 + "id": "simard",
106 + "name": "Immeubles Simard",
107 + "url": "https://immeublessimard.com",
108 + "listing_url": "https://immeublessimard.com/a-louer/categorie/appartement",
109 + "sectors": "Québec (Grande Allée, centre-ville)",
110 + "connector": "simard",
111 + "status": "actif",
112 + "region": "Québec"
113 + },
114 + {
115 + "id": "gestipro",
116 + "name": "Gestipro",
117 + "url": "https://gestipro.info",
118 + "listing_url": "https://gestipro.info/a-louer",
119 + "sectors": "Québec + Lévis",
120 + "connector": "gestipro",
121 + "status": "actif",
122 + "region": "Québec"
123 + },
124 + {
125 + "id": "logisma",
126 + "name": "Logisma",
127 + "url": "https://logisma.ca",
128 + "listing_url": "https://logisma.ca",
129 + "sectors": "Ville de Québec",
130 + "connector": "logisma",
131 + "status": "actif",
132 + "region": "Québec"
133 + },
134 + {
135 + "id": "sibelanger",
136 + "name": "Société immobilière Bélanger",
137 + "url": "https://sibelanger.com",
138 + "listing_url": "https://sibelanger.com",
139 + "sectors": "Québec + Lévis",
140 + "connector": "sibelanger",
141 + "status": "actif",
142 + "region": "Québec"
143 + },
144 + {
145 + "id": "sdg",
146 + "name": "SDG Immobilier",
147 + "url": "https://www.sdgimmobilier.ca",
148 + "listing_url": "https://www.sdgimmobilier.ca",
149 + "sectors": "Charlesbourg, Ste-Foy, Limoilou, Charny",
150 + "connector": "sdg",
151 + "status": "actif",
152 + "region": "Québec"
153 + },
154 + {
155 + "id": "sgiq",
156 + "name": "SGIQ (Société de gestion immobilière du Québec)",
157 + "url": "https://gestionimmobilierequebec.com",
158 + "listing_url": "https://gestionimmobilierequebec.com/immeubles",
159 + "sectors": "Limoilou, St-Sauveur, Ste-Foy, Sillery",
160 + "connector": "sgiq",
161 + "status": "actif",
162 + "region": "Québec"
163 + },
164 + {
165 + "id": "gimcote",
166 + "name": "GIM Côté",
167 + "url": "https://gimcote.com",
168 + "listing_url": "https://gimcote.com/property-type/appartement",
169 + "sectors": "Ste-Foy, Limoilou/Charlesbourg, Lévis",
170 + "connector": "gimcote",
171 + "status": "actif",
172 + "region": "Québec"
173 + },
174 + {
175 + "id": "logisbourg",
176 + "name": "Logisbourg",
177 + "url": "https://www.logisbourg.com",
178 + "listing_url": "https://www.logisbourg.com/appartements-disponibles.asp",
179 + "sectors": "Charlesbourg, Lebourgneuf, Beauport, Cité-Limoilou, Ste-Foy",
180 + "connector": "logisbourg",
181 + "status": "actif",
182 + "region": "Québec"
183 + },
184 + {
185 + "id": "bribourg",
186 + "name": "Bribourg",
187 + "url": "https://bribourg.com",
188 + "listing_url": "https://bribourg.com/Immeubles.php",
189 + "sectors": "Charlesbourg, Vanier, Beauport",
190 + "connector": "bribourg",
191 + "status": "actif",
192 + "region": "Québec"
193 + },
194 + {
195 + "id": "per",
196 + "name": "Les Immeubles Paul-E. Richard",
197 + "url": "https://immeublesper.com",
198 + "listing_url": "https://immeublesper.com",
199 + "sectors": "Limoilou, Charlesbourg, Beauport",
200 + "connector": "per",
201 + "status": "actif",
202 + "region": "Québec"
203 + },
204 + {
205 + "id": "headway",
206 + "name": "La Corporation Headway",
207 + "url": "https://www.headwayltee.com",
208 + "listing_url": "https://www.headwayltee.com/logements-a-louer/levis",
209 + "sectors": "Vanier, Ste-Foy, Charlesbourg, Lévis",
210 + "connector": "headway",
211 + "status": "actif",
212 + "region": "Québec"
213 + },
214 + {
215 + "id": "contraste",
216 + "name": "Contraste Immobilier",
217 + "url": "https://contrasteimmobilier.ca",
218 + "listing_url": "https://contrasteimmobilier.ca",
219 + "sectors": "Beauport, Limoilou, Ste-Foy, Val-Bélair, Lévis, St-Nicolas, St-Romuald",
220 + "connector": "contraste",
221 + "status": "actif",
222 + "region": "Québec"
223 + },
224 + {
225 + "id": "app_urbains",
226 + "name": "Appartements Urbains",
227 + "url": "https://www.appartementsurbains.ca",
228 + "listing_url": "https://www.appartementsurbains.ca",
229 + "sectors": "Ste-Foy, Montcalm, Limoilou, Loretteville, Lévis",
230 + "connector": "app_urbains",
231 + "status": "actif",
232 + "region": "Québec"
233 + },
234 + {
235 + "id": "picard",
236 + "name": "Picard Immobilier",
237 + "url": "https://picardimmobilier.net",
238 + "listing_url": "https://picardimmobilier.net/logement/arrondissement=charny",
239 + "sectors": "Charny, Les Saules, Ste-Foy, Beauport, Limoilou, Loretteville, Vieux-Québec, Montcalm",
240 + "connector": "picard",
241 + "status": "actif",
242 + "region": "Québec"
243 + },
244 + {
245 + "id": "brochu",
246 + "name": "Groupe Immobilier Brochu",
247 + "url": "https://groupeimmobilierbrochu.com",
248 + "listing_url": "https://groupeimmobilierbrochu.com",
249 + "sectors": "Lévis (St-Romuald, Charny, St-Nicolas), Les Saules",
250 + "connector": "brochu",
251 + "status": "actif",
252 + "region": "Québec"
253 + },
254 + {
255 + "id": "gparadis",
256 + "name": "GParadis",
257 + "url": "https://gparadis.com",
258 + "listing_url": "https://gparadis.com",
259 + "sectors": "Montcalm, St-Sauveur, St-Roch, Limoilou, Vieux-Québec, Ste-Foy, Duberger, Lévis",
260 + "connector": "gparadis",
261 + "status": "actif",
262 + "region": "Québec"
263 + },
264 + {
265 + "id": "lokalia",
266 + "name": "Espaces Lokalia",
267 + "url": "https://www.espaceslokalia.ca",
268 + "listing_url": "https://www.espaceslokalia.ca/immeuble/vivaxces-le-nicolas",
269 + "sectors": "Lévis (Vivaxcès Le Nicolas)",
270 + "connector": "lokalia",
271 + "status": "actif",
272 + "region": "Québec"
273 + },
274 + {
275 + "id": "capreit",
276 + "name": "CAPREIT",
277 + "url": "https://www.capreit.ca",
278 + "listing_url": "https://www.capreit.ca/fr/appartements-a-louer/ville-de-quebec-qc/appartements-le-samuel-holland",
279 + "sectors": "Ville de Québec (Samuel-Holland)",
280 + "connector": "capreit",
281 + "status": "actif",
282 + "region": "Québec"
283 + },
284 + {
285 + "id": "immomarketing",
286 + "name": "IMMOMARKETING (Immoappart)",
287 + "url": "https://immoappart.ca",
288 + "listing_url": "https://immoappart.ca/appartements-a-louer",
289 + "sectors": "Lebourgneuf, Saint-Roch",
290 + "connector": "immomarketing",
291 + "status": "actif",
292 + "region": "Québec"
293 + },
294 + {
295 + "id": "presquile",
296 + "name": "Logis Presqu'Île",
297 + "url": "https://logispresquile.ca",
298 + "listing_url": "https://logispresquile.ca",
299 + "sectors": "Presqu'île / St-Nicolas, près de Ste-Foy",
300 + "connector": null,
301 + "status": "non connectable — aucun prix/disponibilité publié sur le site",
302 + "region": "Québec"
303 + },
304 + {
305 + "id": "brio",
306 + "name": "Les Immeubles Brio",
307 + "url": "https://immeublesbrio.com",
308 + "listing_url": "https://immeublesbrio.com/appartements-a-louer-val-belair",
309 + "sectors": "Val-Bélair, Neufchâtel, Loretteville",
310 + "connector": "brio",
311 + "status": "actif",
312 + "region": "Québec"
313 + },
314 + {
315 + "id": "oklouer",
316 + "name": "OK Louer (location temporaire)",
317 + "url": "https://www.oklouer.com",
318 + "listing_url": "https://www.oklouer.com/logements/limoilou",
319 + "sectors": "Limoilou, Charlesbourg/Beauport, Lac-Beauport",
320 + "connector": "oklouer",
321 + "status": "actif",
322 + "region": "Québec"
323 + },
324 + {
325 + "id": "elements",
326 + "name": "Quartier les Éléments",
327 + "url": "https://www.quartierleselements.com",
328 + "listing_url": "https://www.quartierleselements.com",
329 + "sectors": "Lévis (St-Romuald)",
330 + "connector": "elements",
331 + "status": "actif",
332 + "region": "Québec"
333 + },
334 + {
335 + "id": "huma",
336 + "name": "HUMĀ Condos locatifs",
337 + "url": "https://humalevis.com",
338 + "listing_url": "https://humalevis.com",
339 + "sectors": "Lévis (St-Romuald)",
340 + "connector": "huma",
341 + "status": "actif",
342 + "region": "Québec"
343 + },
344 + {
345 + "id": "leclif",
346 + "name": "Le Clif",
347 + "url": "https://leclif.ca",
348 + "listing_url": "https://leclif.ca",
349 + "sectors": "Charlesbourg",
350 + "connector": "leclif",
351 + "status": "actif",
352 + "region": "Québec"
353 + },
354 + {
355 + "id": "terra",
356 + "name": "Terra Condos locatifs",
357 + "url": "https://www.terracondolocatif.ca",
358 + "listing_url": "https://www.terracondolocatif.ca",
359 + "sectors": "Lévis (Desjardins)",
360 + "connector": "terra",
361 + "status": "actif",
362 + "region": "Québec"
363 + },
364 + {
365 + "id": "rivero",
366 + "name": "Le Rivero",
367 + "url": "https://www.lerivero.ca",
368 + "listing_url": "https://www.lerivero.ca",
369 + "sectors": "Québec (rivière St-Charles)",
370 + "connector": "rivero",
371 + "status": "actif",
372 + "region": "Québec"
373 + },
374 + {
375 + "id": "viridi",
376 + "name": "Le Viridi",
377 + "url": "https://condosleviridi.ca",
378 + "listing_url": "https://condosleviridi.ca",
379 + "sectors": "Québec",
380 + "connector": "viridi",
381 + "status": "actif",
382 + "region": "Québec"
383 + },
384 + {
385 + "id": "lakle",
386 + "name": "La Klé",
387 + "url": "https://lakle.ca",
388 + "listing_url": "https://lakle.ca",
389 + "sectors": "Québec (Cité Verte)",
390 + "connector": "lakle",
391 + "status": "actif",
392 + "region": "Québec"
393 + },
394 + {
395 + "id": "sentinelle",
396 + "name": "La Sentinelle (Groupe Immobilier Brochu)",
397 + "url": "https://lasentinellelevis.com",
398 + "listing_url": "https://lasentinellelevis.com",
399 + "sectors": "Vieux-Lévis",
400 + "connector": "sentinelle",
401 + "status": "actif",
402 + "region": "Québec"
403 + },
404 + {
405 + "id": "legc",
406 + "name": "Le GC (géré par Lafrance & Mathieu)",
407 + "url": "https://condoslegc.ca",
408 + "listing_url": "https://condoslegc.ca",
409 + "sectors": "Lévis (St-Romuald)",
410 + "connector": null,
411 + "status": "couvert via lafrance_mathieu",
412 + "region": "Québec"
413 + },
414 + {
415 + "id": "minto",
416 + "name": "Minto Apartments",
417 + "url": "https://www.mintoapartments.com",
418 + "listing_url": "https://www.mintoapartments.com/montreal/apartment-rentals/projects.html",
419 + "sectors": "Côte-des-Neiges, Westmount, centre-ville",
420 + "connector": "minto",
421 + "status": "actif",
422 + "region": "Montréal"
423 + },
424 + {
425 + "id": "realstar",
426 + "name": "Realstar",
427 + "url": "https://www.realstar.ca",
428 + "listing_url": "https://www.realstar.ca/apartments/qc/montreal/excelsior-apartments",
429 + "sectors": "Côte-Saint-Luc, Brossard",
430 + "connector": "realstar",
431 + "status": "actif",
432 + "region": "Montréal"
433 + },
434 + {
435 + "id": "hazelview",
436 + "name": "Hazelview Properties",
437 + "url": "https://www.hazelviewproperties.com",
438 + "listing_url": "https://www.hazelviewproperties.com/cities/montreal",
439 + "sectors": "Centre-ville, Vieux-Port, Plateau",
440 + "connector": "hazelview",
441 + "status": "actif",
442 + "region": "Montréal"
443 + },
444 + {
445 + "id": "interrent",
446 + "name": "InterRent REIT (CLV Group)",
447 + "url": "https://www.irent.com",
448 + "listing_url": "https://www.irent.com/communities/city/montreal",
449 + "sectors": "Centre-ville, CDN, Côte Saint-Luc",
450 + "connector": "interrent",
451 + "status": "actif",
452 + "region": "Montréal"
453 + },
454 + {
455 + "id": "metcap",
456 + "name": "MetCap Living",
457 + "url": "https://www.metcap.com",
458 + "listing_url": "https://www.metcap.com/province-search-results?lang=en&province=115&city=Montréal",
459 + "sectors": "Montréal, Saint-Laurent, Pointe-Claire",
460 + "connector": "metcap",
461 + "status": "actif",
462 + "region": "Montréal"
463 + },
464 + {
465 + "id": "akelius",
466 + "name": "Akelius Montréal",
467 + "url": "https://rent.akelius.com",
468 + "listing_url": "https://rent.akelius.com/en/search/canada/apartment/montreal",
469 + "sectors": "Plateau, CDN, NDG, Villeray",
470 + "connector": "akelius",
471 + "status": "actif",
472 + "region": "Montréal"
473 + },
474 + {
475 + "id": "boardwalk",
476 + "name": "Boardwalk REIT",
477 + "url": "https://www.bwalk.com",
478 + "listing_url": "https://www.bwalk.com/fr-ca/appartements-a-louer-a-montreal-longueuil/longueuil",
479 + "sectors": "Longueuil",
480 + "connector": "boardwalk",
481 + "status": "actif",
482 + "region": "Montréal"
483 + },
484 + {
485 + "id": "copley",
486 + "name": "Groupe Copley",
487 + "url": "https://www.groupecopley.com",
488 + "listing_url": "https://www.groupecopley.com/",
489 + "sectors": "Westmount, Mont-Royal, Saint-Laurent",
490 + "connector": "copley",
491 + "status": "actif",
492 + "region": "Montréal"
493 + },
494 + {
495 + "id": "cromwell",
496 + "name": "Cromwell Management",
497 + "url": "https://cromwellmgt.ca",
498 + "listing_url": "https://cromwellmgt.ca/en/apartments-for-rent-montreal",
499 + "sectors": "Centre-ville, Outremont, CSL, NDG",
500 + "connector": "cromwell",
501 + "status": "actif",
502 + "region": "Montréal"
503 + },
504 + {
505 + "id": "lynk_olymbec",
506 + "name": "Lynk (Olymbec)",
507 + "url": "https://lynk.ca",
508 + "listing_url": "https://lynk.ca/",
509 + "sectors": "De la Savane / Côte-des-Neiges",
510 + "connector": "lynk_olymbec",
511 + "status": "actif",
512 + "region": "Montréal"
513 + },
514 + {
515 + "id": "trylon",
516 + "name": "Trylon Apartments",
517 + "url": "https://trylonmontreal.com",
518 + "listing_url": "https://trylonmontreal.com/fr",
519 + "sectors": "Centre-ville",
520 + "connector": "trylon",
521 + "status": "actif",
522 + "region": "Montréal"
523 + },
524 + {
525 + "id": "plan_a",
526 + "name": "Plan A Immobilier",
527 + "url": "https://plan-a.ca",
528 + "listing_url": "https://plan-a.ca/plan-a-appartements-et-condos-a-louer/",
529 + "sectors": "Pierrefonds, Laval, Vaudreuil",
530 + "connector": "plan_a",
531 + "status": "actif",
532 + "region": "Montréal"
533 + },
534 + {
535 + "id": "loftsmtl",
536 + "name": "Lofts MTL",
537 + "url": "https://www.loftsmtl.com",
538 + "listing_url": "https://www.loftsmtl.com/",
539 + "sectors": "Vieux-Montréal, Mile-End, NDG, VMR",
540 + "connector": "loftsmtl",
541 + "status": "actif",
542 + "region": "Montréal"
543 + },
544 + {
545 + "id": "axia",
546 + "name": "Axia Appartements",
547 + "url": "https://www.axiaappartements.com",
548 + "listing_url": "https://www.axiaappartements.com/",
549 + "sectors": "Lachine",
550 + "connector": "axia",
551 + "status": "actif",
552 + "region": "Montréal"
553 + },
554 + {
555 + "id": "firma",
556 + "name": "Groupe Firma",
557 + "url": "https://groupefirma.ca",
558 + "listing_url": "https://groupefirma.ca/",
559 + "sectors": "Montérégie, banlieue sud-ouest",
560 + "connector": "firma",
561 + "status": "actif",
562 + "region": "Montréal"
563 + },
564 + {
565 + "id": "ledomaine",
566 + "name": "Les Habitations Le Domaine",
567 + "url": "https://www.ledomaine.ca",
568 + "listing_url": "https://www.ledomaine.ca/",
569 + "sectors": "Mercier/Hochelaga",
570 + "connector": "ledomaine",
571 + "status": "actif",
572 + "region": "Montréal"
573 + },
574 + {
575 + "id": "beaudoin",
576 + "name": "Société Beaudoin Immobilier",
577 + "url": "https://www.beaudoinimmobilier.ca",
578 + "listing_url": "https://www.beaudoinimmobilier.ca/index.php/decouvrez-nos-immeubles",
579 + "sectors": "Longueuil, Boucherville, Lachine",
580 + "connector": "beaudoin",
581 + "status": "actif",
582 + "region": "Montréal"
583 + },
584 + {
585 + "id": "mondev",
586 + "name": "Mondev",
587 + "url": "https://www.mondev.ca",
588 + "listing_url": "https://mondev.ca/apartments-and-condos-for-rent/",
589 + "sectors": "Ville-Marie, Sud-Ouest, Plateau",
590 + "connector": "mondev",
591 + "status": "actif",
592 + "region": "Montréal"
593 + },
594 + {
595 + "id": "rester",
596 + "name": "Rester Management",
597 + "url": "https://rester.ca",
598 + "listing_url": "https://rester.ca/rester-ca-properties/rent-apartment/",
599 + "sectors": "Centre-ville, CDN",
600 + "connector": null,
601 + "status": "non connectable — aucune unité disponible affichée",
602 + "region": "Montréal"
603 + },
604 + {
605 + "id": "hillpark",
606 + "name": "Hillpark Capital",
607 + "url": "https://www.hillpark.ca",
608 + "listing_url": "https://www.hillpark.ca/",
609 + "sectors": "Centre-ville, Plateau, Mile-End",
610 + "connector": null,
611 + "status": "non connectable — hillpark.ca est un site placeholder vide",
612 + "region": "Montréal"
613 + },
614 + {
615 + "id": "denux",
616 + "name": "Groupe Denux",
617 + "url": "https://www.groupedenux.com",
618 + "listing_url": "https://www.groupedenux.com/",
619 + "sectors": "Montréal, Saint-Lambert, Mascouche",
620 + "connector": "denux",
621 + "status": "actif",
622 + "region": "Montréal"
623 + },
624 + {
625 + "id": "utile",
626 + "name": "UTILE (logement étudiant)",
627 + "url": "https://www.utile.org",
628 + "listing_url": "https://www.utile.org/en",
629 + "sectors": "Griffintown, Angus, Milton-Parc",
630 + "connector": "utile",
631 + "status": "actif",
632 + "region": "Montréal"
633 + },
634 + {
635 + "id": "werkliv",
636 + "name": "Werkliv",
637 + "url": "https://werkliv.com",
638 + "listing_url": "https://werkliv.com/en",
639 + "sectors": "Centre-ville, Milton-Parc (étudiant)",
640 + "connector": "werkliv",
641 + "status": "actif",
642 + "region": "Montréal"
643 + },
644 + {
645 + "id": "brivia_1sp",
646 + "name": "Groupe Brivia (1 Square Phillips)",
647 + "url": "https://briviagroup.ca",
648 + "listing_url": "https://www.1squarephillips.ca/locatif",
649 + "sectors": "Centre-ville",
650 + "connector": "brivia_1sp",
651 + "status": "actif",
652 + "region": "Montréal"
653 + },
654 + {
655 + "id": "equinoxe_batimo",
656 + "name": "Batimo/EMD — Collection Équinoxe",
657 + "url": "https://collectionequinoxe.com",
658 + "listing_url": "https://collectionequinoxe.com/",
659 + "sectors": "Laval, Bois-Franc (Saint-Laurent)",
660 + "connector": "equinoxe_batimo",
661 + "status": "actif",
662 + "region": "Montréal"
663 + },
664 + {
665 + "id": "devimco",
666 + "name": "Devimco Appartements",
667 + "url": "https://devimco.com",
668 + "listing_url": "https://devimco.com/appartements",
669 + "sectors": "Griffintown, Brossard (Solar Uniquartier)",
670 + "connector": "devimco",
671 + "status": "actif",
672 + "region": "Montréal"
673 + },
674 + {
675 + "id": "progim",
676 + "name": "Progim",
677 + "url": "https://progim.com",
678 + "listing_url": "http://progimannonces.bstk.io/Listing/Listings",
679 + "sectors": "Grand Montréal (base Brossard)",
680 + "connector": "progim",
681 + "status": "actif",
682 + "region": "Montréal"
683 + },
684 + {
685 + "id": "rentalys",
686 + "name": "Rentalys",
687 + "url": "https://www.rentalys.ca",
688 + "listing_url": "https://location.rentalys.ca/",
689 + "sectors": "Grande région de Montréal",
690 + "connector": "rentalys",
691 + "status": "actif",
692 + "region": "Montréal"
693 + },
694 + {
695 + "id": "gestion_montreal",
696 + "name": "Gestion Montréal",
697 + "url": "https://gestion-montreal.com",
698 + "listing_url": "https://gestion-montreal.com/fr/inscriptions",
699 + "sectors": "Montréal, Repentigny, La Prairie",
700 + "connector": "gestion_montreal",
701 + "status": "actif",
702 + "region": "Montréal"
703 + },
704 + {
705 + "id": "niddamour",
706 + "name": "Nid d'Amour",
707 + "url": "https://niddamour.ca",
708 + "listing_url": "https://niddamour.ca/",
709 + "sectors": "Plateau, Verdun, Outremont, Rosemont",
710 + "connector": "niddamour",
711 + "status": "actif",
712 + "region": "Montréal"
713 + },
714 + {
715 + "id": "shdm",
716 + "name": "SHDM",
717 + "url": "https://www.shdm.org",
718 + "listing_url": "https://www.shdm.org/fr/logements-abordables/louer-un-appartement",
719 + "sectors": "Île de Montréal (abordable)",
720 + "connector": "shdm",
721 + "status": "actif",
722 + "region": "Montréal"
723 + },
724 + {
725 + "id": "quintcap",
726 + "name": "Quintcap",
727 + "url": "https://quintcap.com",
728 + "listing_url": "https://quintcap.com/propriete/square-chateauguay-logements-a-louer/",
729 + "sectors": "La Prairie, Châteauguay",
730 + "connector": null,
731 + "status": "non connectable — unités sans prix (« Contactez-nous »), Belle-Dame 100% loué",
732 + "region": "Montréal"
733 + },
734 + {
735 + "id": "rama",
736 + "name": "Groupe Rama",
737 + "url": "https://grouperama.com",
738 + "listing_url": "https://grouperama.com/mes-proprietes",
739 + "sectors": "Ahuntsic, Rosemont, RDP",
740 + "connector": null,
741 + "status": "non connectable — aucune annonce résidentielle locative actuellement",
742 + "region": "Montréal"
743 + }
744 + ]
745 +}
\ No newline at end of file
added frontend/index.html +27 −0
@@ -0,0 +1,27 @@
1 +<!doctype html>
2 +<!-- ---------------------------------------------------------------------------
3 + Lou-Ka — Agrégateur de logements à louer (province de Québec)
4 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +---------------------------------------------------------------------------- -->
6 +<html lang="fr">
7 + <head>
8 + <meta charset="UTF-8" />
9 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 + <title>Lou-Ka — Logements à louer au Québec</title>
11 + <meta name="description" content="Lou-Ka agrège les appartements à louer affichés par les gestionnaires immobiliers de Québec et Lévis, toujours à jour." />
12 + <meta name="theme-color" content="#f5f3ee" />
13 + <meta name="mobile-web-app-capable" content="yes" />
14 + <meta name="apple-mobile-web-app-capable" content="yes" />
15 + <meta name="apple-mobile-web-app-status-bar-style" content="default" />
16 + <meta name="apple-mobile-web-app-title" content="Lou-Ka" />
17 + <link rel="manifest" href="/manifest.webmanifest" />
18 + <link rel="preconnect" href="https://fonts.googleapis.com" />
19 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
20 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
21 + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23121712'/%3E%3Ctext x='32' y='45' font-family='Arial Black,sans-serif' font-size='32' font-weight='900' fill='%23d9f26b' text-anchor='middle'%3ELK%3C/text%3E%3C/svg%3E" />
22 + </head>
23 + <body>
24 + <div id="root"></div>
25 + <script type="module" src="/src/main.tsx"></script>
26 + </body>
27 +</html>
added frontend/package-lock.json +1834 −0
@@ -0,0 +1,1834 @@
1 +{
2 + "name": "lou-ka-frontend",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "lou-ka-frontend",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "react": "^18.3.1",
12 + "react-dom": "^18.3.1",
13 + "react-router-dom": "^6.26.0"
14 + },
15 + "devDependencies": {
16 + "@types/react": "^18.3.3",
17 + "@types/react-dom": "^18.3.0",
18 + "@vitejs/plugin-react": "^4.3.1",
19 + "typescript": "^5.5.4",
20 + "vite": "^5.4.0"
21 + }
22 + },
23 + "node_modules/@babel/code-frame": {
24 + "version": "7.29.7",
25 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
26 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
27 + "dev": true,
28 + "license": "MIT",
29 + "dependencies": {
30 + "@babel/helper-validator-identifier": "^7.29.7",
31 + "js-tokens": "^4.0.0",
32 + "picocolors": "^1.1.1"
33 + },
34 + "engines": {
35 + "node": ">=6.9.0"
36 + }
37 + },
38 + "node_modules/@babel/compat-data": {
39 + "version": "7.29.7",
40 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
41 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
42 + "dev": true,
43 + "license": "MIT",
44 + "engines": {
45 + "node": ">=6.9.0"
46 + }
47 + },
48 + "node_modules/@babel/core": {
49 + "version": "7.29.7",
50 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
51 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
52 + "dev": true,
53 + "license": "MIT",
54 + "dependencies": {
55 + "@babel/code-frame": "^7.29.7",
56 + "@babel/generator": "^7.29.7",
57 + "@babel/helper-compilation-targets": "^7.29.7",
58 + "@babel/helper-module-transforms": "^7.29.7",
59 + "@babel/helpers": "^7.29.7",
60 + "@babel/parser": "^7.29.7",
61 + "@babel/template": "^7.29.7",
62 + "@babel/traverse": "^7.29.7",
63 + "@babel/types": "^7.29.7",
64 + "@jridgewell/remapping": "^2.3.5",
65 + "convert-source-map": "^2.0.0",
66 + "debug": "^4.1.0",
67 + "gensync": "^1.0.0-beta.2",
68 + "json5": "^2.2.3",
69 + "semver": "^6.3.1"
70 + },
71 + "engines": {
72 + "node": ">=6.9.0"
73 + },
74 + "funding": {
75 + "type": "opencollective",
76 + "url": "https://opencollective.com/babel"
77 + }
78 + },
79 + "node_modules/@babel/generator": {
80 + "version": "7.29.8",
81 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
82 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
83 + "dev": true,
84 + "license": "MIT",
85 + "dependencies": {
86 + "@babel/parser": "^7.29.8",
87 + "@babel/types": "^7.29.8",
88 + "@jridgewell/gen-mapping": "^0.3.12",
89 + "@jridgewell/trace-mapping": "^0.3.28",
90 + "jsesc": "^3.0.2"
91 + },
92 + "engines": {
93 + "node": ">=6.9.0"
94 + }
95 + },
96 + "node_modules/@babel/helper-compilation-targets": {
97 + "version": "7.29.7",
98 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
99 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
100 + "dev": true,
101 + "license": "MIT",
102 + "dependencies": {
103 + "@babel/compat-data": "^7.29.7",
104 + "@babel/helper-validator-option": "^7.29.7",
105 + "browserslist": "^4.24.0",
106 + "lru-cache": "^5.1.1",
107 + "semver": "^6.3.1"
108 + },
109 + "engines": {
110 + "node": ">=6.9.0"
111 + }
112 + },
113 + "node_modules/@babel/helper-globals": {
114 + "version": "7.29.7",
115 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
116 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
117 + "dev": true,
118 + "license": "MIT",
119 + "engines": {
120 + "node": ">=6.9.0"
121 + }
122 + },
123 + "node_modules/@babel/helper-module-imports": {
124 + "version": "7.29.7",
125 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
126 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
127 + "dev": true,
128 + "license": "MIT",
129 + "dependencies": {
130 + "@babel/traverse": "^7.29.7",
131 + "@babel/types": "^7.29.7"
132 + },
133 + "engines": {
134 + "node": ">=6.9.0"
135 + }
136 + },
137 + "node_modules/@babel/helper-module-transforms": {
138 + "version": "7.29.7",
139 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
140 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
141 + "dev": true,
142 + "license": "MIT",
143 + "dependencies": {
144 + "@babel/helper-module-imports": "^7.29.7",
145 + "@babel/helper-validator-identifier": "^7.29.7",
146 + "@babel/traverse": "^7.29.7"
147 + },
148 + "engines": {
149 + "node": ">=6.9.0"
150 + },
151 + "peerDependencies": {
152 + "@babel/core": "^7.0.0"
153 + }
154 + },
155 + "node_modules/@babel/helper-plugin-utils": {
156 + "version": "7.29.7",
157 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
158 + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
159 + "dev": true,
160 + "license": "MIT",
161 + "engines": {
162 + "node": ">=6.9.0"
163 + }
164 + },
165 + "node_modules/@babel/helper-string-parser": {
166 + "version": "7.29.7",
167 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
168 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
169 + "dev": true,
170 + "license": "MIT",
171 + "engines": {
172 + "node": ">=6.9.0"
173 + }
174 + },
175 + "node_modules/@babel/helper-validator-identifier": {
176 + "version": "7.29.7",
177 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
178 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
179 + "dev": true,
180 + "license": "MIT",
181 + "engines": {
182 + "node": ">=6.9.0"
183 + }
184 + },
185 + "node_modules/@babel/helper-validator-option": {
186 + "version": "7.29.7",
187 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
188 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
189 + "dev": true,
190 + "license": "MIT",
191 + "engines": {
192 + "node": ">=6.9.0"
193 + }
194 + },
195 + "node_modules/@babel/helpers": {
196 + "version": "7.29.7",
197 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
198 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
199 + "dev": true,
200 + "license": "MIT",
201 + "dependencies": {
202 + "@babel/template": "^7.29.7",
203 + "@babel/types": "^7.29.7"
204 + },
205 + "engines": {
206 + "node": ">=6.9.0"
207 + }
208 + },
209 + "node_modules/@babel/parser": {
210 + "version": "7.29.8",
211 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
212 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
213 + "dev": true,
214 + "license": "MIT",
215 + "dependencies": {
216 + "@babel/types": "^7.29.8"
217 + },
218 + "bin": {
219 + "parser": "bin/babel-parser.js"
220 + },
221 + "engines": {
222 + "node": ">=6.0.0"
223 + }
224 + },
225 + "node_modules/@babel/plugin-transform-react-jsx-self": {
226 + "version": "7.29.7",
227 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
228 + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
229 + "dev": true,
230 + "license": "MIT",
231 + "dependencies": {
232 + "@babel/helper-plugin-utils": "^7.29.7"
233 + },
234 + "engines": {
235 + "node": ">=6.9.0"
236 + },
237 + "peerDependencies": {
238 + "@babel/core": "^7.0.0-0"
239 + }
240 + },
241 + "node_modules/@babel/plugin-transform-react-jsx-source": {
242 + "version": "7.29.7",
243 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
244 + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
245 + "dev": true,
246 + "license": "MIT",
247 + "dependencies": {
248 + "@babel/helper-plugin-utils": "^7.29.7"
249 + },
250 + "engines": {
251 + "node": ">=6.9.0"
252 + },
253 + "peerDependencies": {
254 + "@babel/core": "^7.0.0-0"
255 + }
256 + },
257 + "node_modules/@babel/template": {
258 + "version": "7.29.7",
259 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
260 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
261 + "dev": true,
262 + "license": "MIT",
263 + "dependencies": {
264 + "@babel/code-frame": "^7.29.7",
265 + "@babel/parser": "^7.29.7",
266 + "@babel/types": "^7.29.7"
267 + },
268 + "engines": {
269 + "node": ">=6.9.0"
270 + }
271 + },
272 + "node_modules/@babel/traverse": {
273 + "version": "7.29.8",
274 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
275 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
276 + "dev": true,
277 + "license": "MIT",
278 + "dependencies": {
279 + "@babel/code-frame": "^7.29.7",
280 + "@babel/generator": "^7.29.8",
281 + "@babel/helper-globals": "^7.29.7",
282 + "@babel/parser": "^7.29.8",
283 + "@babel/template": "^7.29.7",
284 + "@babel/types": "^7.29.8",
285 + "debug": "^4.3.1"
286 + },
287 + "engines": {
288 + "node": ">=6.9.0"
289 + }
290 + },
291 + "node_modules/@babel/types": {
292 + "version": "7.29.8",
293 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
294 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
295 + "dev": true,
296 + "license": "MIT",
297 + "dependencies": {
298 + "@babel/helper-string-parser": "^7.29.7",
299 + "@babel/helper-validator-identifier": "^7.29.7"
300 + },
301 + "engines": {
302 + "node": ">=6.9.0"
303 + }
304 + },
305 + "node_modules/@esbuild/aix-ppc64": {
306 + "version": "0.21.5",
307 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
308 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
309 + "cpu": [
310 + "ppc64"
311 + ],
312 + "dev": true,
313 + "license": "MIT",
314 + "optional": true,
315 + "os": [
316 + "aix"
317 + ],
318 + "engines": {
319 + "node": ">=12"
320 + }
321 + },
322 + "node_modules/@esbuild/android-arm": {
323 + "version": "0.21.5",
324 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
325 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
326 + "cpu": [
327 + "arm"
328 + ],
329 + "dev": true,
330 + "license": "MIT",
331 + "optional": true,
332 + "os": [
333 + "android"
334 + ],
335 + "engines": {
336 + "node": ">=12"
337 + }
338 + },
339 + "node_modules/@esbuild/android-arm64": {
340 + "version": "0.21.5",
341 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
342 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
343 + "cpu": [
344 + "arm64"
345 + ],
346 + "dev": true,
347 + "license": "MIT",
348 + "optional": true,
349 + "os": [
350 + "android"
351 + ],
352 + "engines": {
353 + "node": ">=12"
354 + }
355 + },
356 + "node_modules/@esbuild/android-x64": {
357 + "version": "0.21.5",
358 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
359 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
360 + "cpu": [
361 + "x64"
362 + ],
363 + "dev": true,
364 + "license": "MIT",
365 + "optional": true,
366 + "os": [
367 + "android"
368 + ],
369 + "engines": {
370 + "node": ">=12"
371 + }
372 + },
373 + "node_modules/@esbuild/darwin-arm64": {
374 + "version": "0.21.5",
375 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
376 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
377 + "cpu": [
378 + "arm64"
379 + ],
380 + "dev": true,
381 + "license": "MIT",
382 + "optional": true,
383 + "os": [
384 + "darwin"
385 + ],
386 + "engines": {
387 + "node": ">=12"
388 + }
389 + },
390 + "node_modules/@esbuild/darwin-x64": {
391 + "version": "0.21.5",
392 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
393 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
394 + "cpu": [
395 + "x64"
396 + ],
397 + "dev": true,
398 + "license": "MIT",
399 + "optional": true,
400 + "os": [
401 + "darwin"
402 + ],
403 + "engines": {
404 + "node": ">=12"
405 + }
406 + },
407 + "node_modules/@esbuild/freebsd-arm64": {
408 + "version": "0.21.5",
409 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
410 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
411 + "cpu": [
412 + "arm64"
413 + ],
414 + "dev": true,
415 + "license": "MIT",
416 + "optional": true,
417 + "os": [
418 + "freebsd"
419 + ],
420 + "engines": {
421 + "node": ">=12"
422 + }
423 + },
424 + "node_modules/@esbuild/freebsd-x64": {
425 + "version": "0.21.5",
426 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
427 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
428 + "cpu": [
429 + "x64"
430 + ],
431 + "dev": true,
432 + "license": "MIT",
433 + "optional": true,
434 + "os": [
435 + "freebsd"
436 + ],
437 + "engines": {
438 + "node": ">=12"
439 + }
440 + },
441 + "node_modules/@esbuild/linux-arm": {
442 + "version": "0.21.5",
443 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
444 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
445 + "cpu": [
446 + "arm"
447 + ],
448 + "dev": true,
449 + "license": "MIT",
450 + "optional": true,
451 + "os": [
452 + "linux"
453 + ],
454 + "engines": {
455 + "node": ">=12"
456 + }
457 + },
458 + "node_modules/@esbuild/linux-arm64": {
459 + "version": "0.21.5",
460 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
461 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
462 + "cpu": [
463 + "arm64"
464 + ],
465 + "dev": true,
466 + "license": "MIT",
467 + "optional": true,
468 + "os": [
469 + "linux"
470 + ],
471 + "engines": {
472 + "node": ">=12"
473 + }
474 + },
475 + "node_modules/@esbuild/linux-ia32": {
476 + "version": "0.21.5",
477 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
478 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
479 + "cpu": [
480 + "ia32"
481 + ],
482 + "dev": true,
483 + "license": "MIT",
484 + "optional": true,
485 + "os": [
486 + "linux"
487 + ],
488 + "engines": {
489 + "node": ">=12"
490 + }
491 + },
492 + "node_modules/@esbuild/linux-loong64": {
493 + "version": "0.21.5",
494 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
495 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
496 + "cpu": [
497 + "loong64"
498 + ],
499 + "dev": true,
500 + "license": "MIT",
501 + "optional": true,
502 + "os": [
503 + "linux"
504 + ],
505 + "engines": {
506 + "node": ">=12"
507 + }
508 + },
509 + "node_modules/@esbuild/linux-mips64el": {
510 + "version": "0.21.5",
511 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
512 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
513 + "cpu": [
514 + "mips64el"
515 + ],
516 + "dev": true,
517 + "license": "MIT",
518 + "optional": true,
519 + "os": [
520 + "linux"
521 + ],
522 + "engines": {
523 + "node": ">=12"
524 + }
525 + },
526 + "node_modules/@esbuild/linux-ppc64": {
527 + "version": "0.21.5",
528 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
529 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
530 + "cpu": [
531 + "ppc64"
532 + ],
533 + "dev": true,
534 + "license": "MIT",
535 + "optional": true,
536 + "os": [
537 + "linux"
538 + ],
539 + "engines": {
540 + "node": ">=12"
541 + }
542 + },
543 + "node_modules/@esbuild/linux-riscv64": {
544 + "version": "0.21.5",
545 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
546 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
547 + "cpu": [
548 + "riscv64"
549 + ],
550 + "dev": true,
551 + "license": "MIT",
552 + "optional": true,
553 + "os": [
554 + "linux"
555 + ],
556 + "engines": {
557 + "node": ">=12"
558 + }
559 + },
560 + "node_modules/@esbuild/linux-s390x": {
561 + "version": "0.21.5",
562 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
563 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
564 + "cpu": [
565 + "s390x"
566 + ],
567 + "dev": true,
568 + "license": "MIT",
569 + "optional": true,
570 + "os": [
571 + "linux"
572 + ],
573 + "engines": {
574 + "node": ">=12"
575 + }
576 + },
577 + "node_modules/@esbuild/linux-x64": {
578 + "version": "0.21.5",
579 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
580 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
581 + "cpu": [
582 + "x64"
583 + ],
584 + "dev": true,
585 + "license": "MIT",
586 + "optional": true,
587 + "os": [
588 + "linux"
589 + ],
590 + "engines": {
591 + "node": ">=12"
592 + }
593 + },
594 + "node_modules/@esbuild/netbsd-x64": {
595 + "version": "0.21.5",
596 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
597 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
598 + "cpu": [
599 + "x64"
600 + ],
601 + "dev": true,
602 + "license": "MIT",
603 + "optional": true,
604 + "os": [
605 + "netbsd"
606 + ],
607 + "engines": {
608 + "node": ">=12"
609 + }
610 + },
611 + "node_modules/@esbuild/openbsd-x64": {
612 + "version": "0.21.5",
613 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
614 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
615 + "cpu": [
616 + "x64"
617 + ],
618 + "dev": true,
619 + "license": "MIT",
620 + "optional": true,
621 + "os": [
622 + "openbsd"
623 + ],
624 + "engines": {
625 + "node": ">=12"
626 + }
627 + },
628 + "node_modules/@esbuild/sunos-x64": {
629 + "version": "0.21.5",
630 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
631 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
632 + "cpu": [
633 + "x64"
634 + ],
635 + "dev": true,
636 + "license": "MIT",
637 + "optional": true,
638 + "os": [
639 + "sunos"
640 + ],
641 + "engines": {
642 + "node": ">=12"
643 + }
644 + },
645 + "node_modules/@esbuild/win32-arm64": {
646 + "version": "0.21.5",
647 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
648 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
649 + "cpu": [
650 + "arm64"
651 + ],
652 + "dev": true,
653 + "license": "MIT",
654 + "optional": true,
655 + "os": [
656 + "win32"
657 + ],
658 + "engines": {
659 + "node": ">=12"
660 + }
661 + },
662 + "node_modules/@esbuild/win32-ia32": {
663 + "version": "0.21.5",
664 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
665 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
666 + "cpu": [
667 + "ia32"
668 + ],
669 + "dev": true,
670 + "license": "MIT",
671 + "optional": true,
672 + "os": [
673 + "win32"
674 + ],
675 + "engines": {
676 + "node": ">=12"
677 + }
678 + },
679 + "node_modules/@esbuild/win32-x64": {
680 + "version": "0.21.5",
681 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
682 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
683 + "cpu": [
684 + "x64"
685 + ],
686 + "dev": true,
687 + "license": "MIT",
688 + "optional": true,
689 + "os": [
690 + "win32"
691 + ],
692 + "engines": {
693 + "node": ">=12"
694 + }
695 + },
696 + "node_modules/@jridgewell/gen-mapping": {
697 + "version": "0.3.13",
698 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
699 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
700 + "dev": true,
701 + "license": "MIT",
702 + "dependencies": {
703 + "@jridgewell/sourcemap-codec": "^1.5.0",
704 + "@jridgewell/trace-mapping": "^0.3.24"
705 + }
706 + },
707 + "node_modules/@jridgewell/remapping": {
708 + "version": "2.3.5",
709 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
710 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
711 + "dev": true,
712 + "license": "MIT",
713 + "dependencies": {
714 + "@jridgewell/gen-mapping": "^0.3.5",
715 + "@jridgewell/trace-mapping": "^0.3.24"
716 + }
717 + },
718 + "node_modules/@jridgewell/resolve-uri": {
719 + "version": "3.1.2",
720 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
721 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
722 + "dev": true,
723 + "license": "MIT",
724 + "engines": {
725 + "node": ">=6.0.0"
726 + }
727 + },
728 + "node_modules/@jridgewell/sourcemap-codec": {
729 + "version": "1.5.5",
730 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
731 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
732 + "dev": true,
733 + "license": "MIT"
734 + },
735 + "node_modules/@jridgewell/trace-mapping": {
736 + "version": "0.3.31",
737 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
738 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
739 + "dev": true,
740 + "license": "MIT",
741 + "dependencies": {
742 + "@jridgewell/resolve-uri": "^3.1.0",
743 + "@jridgewell/sourcemap-codec": "^1.4.14"
744 + }
745 + },
746 + "node_modules/@napi-rs/lzma-linux-x64-gnu": {
747 + "version": "1.5.1",
748 + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
749 + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
750 + "cpu": [
751 + "x64"
752 + ],
753 + "dev": true,
754 + "libc": [
755 + "glibc"
756 + ],
757 + "license": "MIT",
758 + "optional": true,
759 + "os": [
760 + "linux"
761 + ],
762 + "engines": {
763 + "node": "^22.20 || ^24.12 || >=25"
764 + }
765 + },
766 + "node_modules/@remix-run/router": {
767 + "version": "1.23.3",
768 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
769 + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
770 + "license": "MIT",
771 + "engines": {
772 + "node": ">=14.0.0"
773 + }
774 + },
775 + "node_modules/@rolldown/pluginutils": {
776 + "version": "1.0.0-beta.27",
777 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
778 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
779 + "dev": true,
780 + "license": "MIT"
781 + },
782 + "node_modules/@rollup/rollup-android-arm-eabi": {
783 + "version": "4.62.4",
784 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
785 + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
786 + "cpu": [
787 + "arm"
788 + ],
789 + "dev": true,
790 + "license": "MIT",
791 + "optional": true,
792 + "os": [
793 + "android"
794 + ]
795 + },
796 + "node_modules/@rollup/rollup-android-arm64": {
797 + "version": "4.62.4",
798 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
799 + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
800 + "cpu": [
801 + "arm64"
802 + ],
803 + "dev": true,
804 + "license": "MIT",
805 + "optional": true,
806 + "os": [
807 + "android"
808 + ]
809 + },
810 + "node_modules/@rollup/rollup-darwin-arm64": {
811 + "version": "4.62.4",
812 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
813 + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
814 + "cpu": [
815 + "arm64"
816 + ],
817 + "dev": true,
818 + "license": "MIT",
819 + "optional": true,
820 + "os": [
821 + "darwin"
822 + ]
823 + },
824 + "node_modules/@rollup/rollup-darwin-x64": {
825 + "version": "4.62.4",
826 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
827 + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
828 + "cpu": [
829 + "x64"
830 + ],
831 + "dev": true,
832 + "license": "MIT",
833 + "optional": true,
834 + "os": [
835 + "darwin"
836 + ]
837 + },
838 + "node_modules/@rollup/rollup-freebsd-arm64": {
839 + "version": "4.62.4",
840 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
841 + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
842 + "cpu": [
843 + "arm64"
844 + ],
845 + "dev": true,
846 + "license": "MIT",
847 + "optional": true,
848 + "os": [
849 + "freebsd"
850 + ]
851 + },
852 + "node_modules/@rollup/rollup-freebsd-x64": {
853 + "version": "4.62.4",
854 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
855 + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
856 + "cpu": [
857 + "x64"
858 + ],
859 + "dev": true,
860 + "license": "MIT",
861 + "optional": true,
862 + "os": [
863 + "freebsd"
864 + ]
865 + },
866 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
867 + "version": "4.62.4",
868 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
869 + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
870 + "cpu": [
871 + "arm"
872 + ],
873 + "dev": true,
874 + "libc": [
875 + "glibc"
876 + ],
877 + "license": "MIT",
878 + "optional": true,
879 + "os": [
880 + "linux"
881 + ]
882 + },
883 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
884 + "version": "4.62.4",
885 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
886 + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
887 + "cpu": [
888 + "arm"
889 + ],
890 + "dev": true,
891 + "libc": [
892 + "musl"
893 + ],
894 + "license": "MIT",
895 + "optional": true,
896 + "os": [
897 + "linux"
898 + ]
899 + },
900 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
901 + "version": "4.62.4",
902 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
903 + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
904 + "cpu": [
905 + "arm64"
906 + ],
907 + "dev": true,
908 + "libc": [
909 + "glibc"
910 + ],
911 + "license": "MIT",
912 + "optional": true,
913 + "os": [
914 + "linux"
915 + ]
916 + },
917 + "node_modules/@rollup/rollup-linux-arm64-musl": {
918 + "version": "4.62.4",
919 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
920 + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
921 + "cpu": [
922 + "arm64"
923 + ],
924 + "dev": true,
925 + "libc": [
926 + "musl"
927 + ],
928 + "license": "MIT",
929 + "optional": true,
930 + "os": [
931 + "linux"
932 + ]
933 + },
934 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
935 + "version": "4.62.4",
936 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
937 + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
938 + "cpu": [
939 + "loong64"
940 + ],
941 + "dev": true,
942 + "libc": [
943 + "glibc"
944 + ],
945 + "license": "MIT",
946 + "optional": true,
947 + "os": [
948 + "linux"
949 + ]
950 + },
951 + "node_modules/@rollup/rollup-linux-loong64-musl": {
952 + "version": "4.62.4",
953 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
954 + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
955 + "cpu": [
956 + "loong64"
957 + ],
958 + "dev": true,
959 + "libc": [
960 + "musl"
961 + ],
962 + "license": "MIT",
963 + "optional": true,
964 + "os": [
965 + "linux"
966 + ]
967 + },
968 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
969 + "version": "4.62.4",
970 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
971 + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
972 + "cpu": [
973 + "ppc64"
974 + ],
975 + "dev": true,
976 + "libc": [
977 + "glibc"
978 + ],
979 + "license": "MIT",
980 + "optional": true,
981 + "os": [
982 + "linux"
983 + ]
984 + },
985 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
986 + "version": "4.62.4",
987 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
988 + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
989 + "cpu": [
990 + "ppc64"
991 + ],
992 + "dev": true,
993 + "libc": [
994 + "musl"
995 + ],
996 + "license": "MIT",
997 + "optional": true,
998 + "os": [
999 + "linux"
1000 + ]
1001 + },
1002 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1003 + "version": "4.62.4",
1004 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
1005 + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
1006 + "cpu": [
1007 + "riscv64"
1008 + ],
1009 + "dev": true,
1010 + "libc": [
1011 + "glibc"
1012 + ],
1013 + "license": "MIT",
1014 + "optional": true,
1015 + "os": [
1016 + "linux"
1017 + ]
1018 + },
1019 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
1020 + "version": "4.62.4",
1021 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
1022 + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
1023 + "cpu": [
1024 + "riscv64"
1025 + ],
1026 + "dev": true,
1027 + "libc": [
1028 + "musl"
1029 + ],
1030 + "license": "MIT",
1031 + "optional": true,
1032 + "os": [
1033 + "linux"
1034 + ]
1035 + },
1036 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
1037 + "version": "4.62.4",
1038 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1039 + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1040 + "cpu": [
1041 + "s390x"
1042 + ],
1043 + "dev": true,
1044 + "libc": [
1045 + "glibc"
1046 + ],
1047 + "license": "MIT",
1048 + "optional": true,
1049 + "os": [
1050 + "linux"
1051 + ]
1052 + },
1053 + "node_modules/@rollup/rollup-linux-x64-gnu": {
1054 + "version": "4.62.4",
1055 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1056 + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1057 + "cpu": [
1058 + "x64"
1059 + ],
1060 + "dev": true,
1061 + "libc": [
1062 + "glibc"
1063 + ],
1064 + "license": "MIT",
1065 + "optional": true,
1066 + "os": [
1067 + "linux"
1068 + ]
1069 + },
1070 + "node_modules/@rollup/rollup-linux-x64-musl": {
1071 + "version": "4.62.4",
1072 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1073 + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1074 + "cpu": [
1075 + "x64"
1076 + ],
1077 + "dev": true,
1078 + "libc": [
1079 + "musl"
1080 + ],
1081 + "license": "MIT",
1082 + "optional": true,
1083 + "os": [
1084 + "linux"
1085 + ]
1086 + },
1087 + "node_modules/@rollup/rollup-openbsd-x64": {
1088 + "version": "4.62.4",
1089 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1090 + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1091 + "cpu": [
1092 + "x64"
1093 + ],
1094 + "dev": true,
1095 + "license": "MIT",
1096 + "optional": true,
1097 + "os": [
1098 + "openbsd"
1099 + ]
1100 + },
1101 + "node_modules/@rollup/rollup-openharmony-arm64": {
1102 + "version": "4.62.4",
1103 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1104 + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1105 + "cpu": [
1106 + "arm64"
1107 + ],
1108 + "dev": true,
1109 + "license": "MIT",
1110 + "optional": true,
1111 + "os": [
1112 + "openharmony"
1113 + ]
1114 + },
1115 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
1116 + "version": "4.62.4",
1117 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1118 + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1119 + "cpu": [
1120 + "arm64"
1121 + ],
1122 + "dev": true,
1123 + "license": "MIT",
1124 + "optional": true,
1125 + "os": [
1126 + "win32"
1127 + ]
1128 + },
1129 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
1130 + "version": "4.62.4",
1131 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1132 + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1133 + "cpu": [
1134 + "ia32"
1135 + ],
1136 + "dev": true,
1137 + "license": "MIT",
1138 + "optional": true,
1139 + "os": [
1140 + "win32"
1141 + ]
1142 + },
1143 + "node_modules/@rollup/rollup-win32-x64-gnu": {
1144 + "version": "4.62.4",
1145 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1146 + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1147 + "cpu": [
1148 + "x64"
1149 + ],
1150 + "dev": true,
1151 + "license": "MIT",
1152 + "optional": true,
1153 + "os": [
1154 + "win32"
1155 + ]
1156 + },
1157 + "node_modules/@rollup/rollup-win32-x64-msvc": {
1158 + "version": "4.62.4",
1159 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1160 + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1161 + "cpu": [
1162 + "x64"
1163 + ],
1164 + "dev": true,
1165 + "license": "MIT",
1166 + "optional": true,
1167 + "os": [
1168 + "win32"
1169 + ]
1170 + },
1171 + "node_modules/@types/babel__core": {
1172 + "version": "7.20.5",
1173 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1174 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1175 + "dev": true,
1176 + "license": "MIT",
1177 + "dependencies": {
1178 + "@babel/parser": "^7.20.7",
1179 + "@babel/types": "^7.20.7",
1180 + "@types/babel__generator": "*",
1181 + "@types/babel__template": "*",
1182 + "@types/babel__traverse": "*"
1183 + }
1184 + },
1185 + "node_modules/@types/babel__generator": {
1186 + "version": "7.27.0",
1187 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1188 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1189 + "dev": true,
1190 + "license": "MIT",
1191 + "dependencies": {
1192 + "@babel/types": "^7.0.0"
1193 + }
1194 + },
1195 + "node_modules/@types/babel__template": {
1196 + "version": "7.4.4",
1197 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1198 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1199 + "dev": true,
1200 + "license": "MIT",
1201 + "dependencies": {
1202 + "@babel/parser": "^7.1.0",
1203 + "@babel/types": "^7.0.0"
1204 + }
1205 + },
1206 + "node_modules/@types/babel__traverse": {
1207 + "version": "7.28.0",
1208 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1209 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1210 + "dev": true,
1211 + "license": "MIT",
1212 + "dependencies": {
1213 + "@babel/types": "^7.28.2"
1214 + }
1215 + },
1216 + "node_modules/@types/estree": {
1217 + "version": "1.0.9",
1218 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1219 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1220 + "dev": true,
1221 + "license": "MIT"
1222 + },
1223 + "node_modules/@types/prop-types": {
1224 + "version": "15.7.15",
1225 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1226 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1227 + "dev": true,
1228 + "license": "MIT"
1229 + },
1230 + "node_modules/@types/react": {
1231 + "version": "18.3.31",
1232 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1233 + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1234 + "dev": true,
1235 + "license": "MIT",
1236 + "dependencies": {
1237 + "@types/prop-types": "*",
1238 + "csstype": "^3.2.2"
1239 + }
1240 + },
1241 + "node_modules/@types/react-dom": {
1242 + "version": "18.3.7",
1243 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1244 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1245 + "dev": true,
1246 + "license": "MIT",
1247 + "peerDependencies": {
1248 + "@types/react": "^18.0.0"
1249 + }
1250 + },
1251 + "node_modules/@vitejs/plugin-react": {
1252 + "version": "4.7.0",
1253 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1254 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1255 + "dev": true,
1256 + "license": "MIT",
1257 + "dependencies": {
1258 + "@babel/core": "^7.28.0",
1259 + "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1260 + "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1261 + "@rolldown/pluginutils": "1.0.0-beta.27",
1262 + "@types/babel__core": "^7.20.5",
1263 + "react-refresh": "^0.17.0"
1264 + },
1265 + "engines": {
1266 + "node": "^14.18.0 || >=16.0.0"
1267 + },
1268 + "peerDependencies": {
1269 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1270 + }
1271 + },
1272 + "node_modules/baseline-browser-mapping": {
1273 + "version": "2.11.12",
1274 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
1275 + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==",
1276 + "dev": true,
1277 + "license": "Apache-2.0",
1278 + "bin": {
1279 + "baseline-browser-mapping": "dist/cli.cjs"
1280 + },
1281 + "engines": {
1282 + "node": ">=6.0.0"
1283 + }
1284 + },
1285 + "node_modules/browserslist": {
1286 + "version": "4.28.7",
1287 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
1288 + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
1289 + "dev": true,
1290 + "funding": [
1291 + {
1292 + "type": "opencollective",
1293 + "url": "https://opencollective.com/browserslist"
1294 + },
1295 + {
1296 + "type": "tidelift",
1297 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1298 + },
1299 + {
1300 + "type": "github",
1301 + "url": "https://github.com/sponsors/ai"
1302 + }
1303 + ],
1304 + "license": "MIT",
1305 + "dependencies": {
1306 + "baseline-browser-mapping": "^2.10.44",
1307 + "caniuse-lite": "^1.0.30001806",
1308 + "electron-to-chromium": "^1.5.393",
1309 + "node-releases": "^2.0.51",
1310 + "update-browserslist-db": "^1.2.3"
1311 + },
1312 + "bin": {
1313 + "browserslist": "cli.js"
1314 + },
1315 + "engines": {
1316 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1317 + }
1318 + },
1319 + "node_modules/caniuse-lite": {
1320 + "version": "1.0.30001807",
1321 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz",
1322 + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==",
1323 + "dev": true,
1324 + "funding": [
1325 + {
1326 + "type": "opencollective",
1327 + "url": "https://opencollective.com/browserslist"
1328 + },
1329 + {
1330 + "type": "tidelift",
1331 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1332 + },
1333 + {
1334 + "type": "github",
1335 + "url": "https://github.com/sponsors/ai"
1336 + }
1337 + ],
1338 + "license": "CC-BY-4.0"
1339 + },
1340 + "node_modules/convert-source-map": {
1341 + "version": "2.0.0",
1342 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1343 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1344 + "dev": true,
1345 + "license": "MIT"
1346 + },
1347 + "node_modules/csstype": {
1348 + "version": "3.2.3",
1349 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1350 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1351 + "dev": true,
1352 + "license": "MIT"
1353 + },
1354 + "node_modules/debug": {
1355 + "version": "4.4.3",
1356 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1357 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1358 + "dev": true,
1359 + "license": "MIT",
1360 + "dependencies": {
1361 + "ms": "^2.1.3"
1362 + },
1363 + "engines": {
1364 + "node": ">=6.0"
1365 + },
1366 + "peerDependenciesMeta": {
1367 + "supports-color": {
1368 + "optional": true
1369 + }
1370 + }
1371 + },
1372 + "node_modules/electron-to-chromium": {
1373 + "version": "1.5.402",
1374 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz",
1375 + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==",
1376 + "dev": true,
1377 + "license": "ISC"
1378 + },
1379 + "node_modules/esbuild": {
1380 + "version": "0.21.5",
1381 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1382 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1383 + "dev": true,
1384 + "hasInstallScript": true,
1385 + "license": "MIT",
1386 + "bin": {
1387 + "esbuild": "bin/esbuild"
1388 + },
1389 + "engines": {
1390 + "node": ">=12"
1391 + },
1392 + "optionalDependencies": {
1393 + "@esbuild/aix-ppc64": "0.21.5",
1394 + "@esbuild/android-arm": "0.21.5",
1395 + "@esbuild/android-arm64": "0.21.5",
1396 + "@esbuild/android-x64": "0.21.5",
1397 + "@esbuild/darwin-arm64": "0.21.5",
1398 + "@esbuild/darwin-x64": "0.21.5",
1399 + "@esbuild/freebsd-arm64": "0.21.5",
1400 + "@esbuild/freebsd-x64": "0.21.5",
1401 + "@esbuild/linux-arm": "0.21.5",
1402 + "@esbuild/linux-arm64": "0.21.5",
1403 + "@esbuild/linux-ia32": "0.21.5",
1404 + "@esbuild/linux-loong64": "0.21.5",
1405 + "@esbuild/linux-mips64el": "0.21.5",
1406 + "@esbuild/linux-ppc64": "0.21.5",
1407 + "@esbuild/linux-riscv64": "0.21.5",
1408 + "@esbuild/linux-s390x": "0.21.5",
1409 + "@esbuild/linux-x64": "0.21.5",
1410 + "@esbuild/netbsd-x64": "0.21.5",
1411 + "@esbuild/openbsd-x64": "0.21.5",
1412 + "@esbuild/sunos-x64": "0.21.5",
1413 + "@esbuild/win32-arm64": "0.21.5",
1414 + "@esbuild/win32-ia32": "0.21.5",
1415 + "@esbuild/win32-x64": "0.21.5"
1416 + }
1417 + },
1418 + "node_modules/escalade": {
1419 + "version": "3.2.0",
1420 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1421 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1422 + "dev": true,
1423 + "license": "MIT",
1424 + "engines": {
1425 + "node": ">=6"
1426 + }
1427 + },
1428 + "node_modules/fsevents": {
1429 + "version": "2.3.3",
1430 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1431 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1432 + "dev": true,
1433 + "hasInstallScript": true,
1434 + "license": "MIT",
1435 + "optional": true,
1436 + "os": [
1437 + "darwin"
1438 + ],
1439 + "engines": {
1440 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1441 + }
1442 + },
1443 + "node_modules/gensync": {
1444 + "version": "1.0.0-beta.2",
1445 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1446 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1447 + "dev": true,
1448 + "license": "MIT",
1449 + "engines": {
1450 + "node": ">=6.9.0"
1451 + }
1452 + },
1453 + "node_modules/js-tokens": {
1454 + "version": "4.0.0",
1455 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1456 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1457 + "license": "MIT"
1458 + },
1459 + "node_modules/jsesc": {
1460 + "version": "3.1.0",
1461 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1462 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1463 + "dev": true,
1464 + "license": "MIT",
1465 + "bin": {
1466 + "jsesc": "bin/jsesc"
1467 + },
1468 + "engines": {
1469 + "node": ">=6"
1470 + }
1471 + },
1472 + "node_modules/json5": {
1473 + "version": "2.2.3",
1474 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1475 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1476 + "dev": true,
1477 + "license": "MIT",
1478 + "bin": {
1479 + "json5": "lib/cli.js"
1480 + },
1481 + "engines": {
1482 + "node": ">=6"
1483 + }
1484 + },
1485 + "node_modules/loose-envify": {
1486 + "version": "1.4.0",
1487 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1488 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1489 + "license": "MIT",
1490 + "dependencies": {
1491 + "js-tokens": "^3.0.0 || ^4.0.0"
1492 + },
1493 + "bin": {
1494 + "loose-envify": "cli.js"
1495 + }
1496 + },
1497 + "node_modules/lru-cache": {
1498 + "version": "5.1.1",
1499 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1500 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1501 + "dev": true,
1502 + "license": "ISC",
1503 + "dependencies": {
1504 + "yallist": "^3.0.2"
1505 + }
1506 + },
1507 + "node_modules/ms": {
1508 + "version": "2.1.3",
1509 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1510 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1511 + "dev": true,
1512 + "license": "MIT"
1513 + },
1514 + "node_modules/nanoid": {
1515 + "version": "3.3.17",
1516 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
1517 + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
1518 + "dev": true,
1519 + "funding": [
1520 + {
1521 + "type": "github",
1522 + "url": "https://github.com/sponsors/ai"
1523 + }
1524 + ],
1525 + "license": "MIT",
1526 + "bin": {
1527 + "nanoid": "bin/nanoid.cjs"
1528 + },
1529 + "engines": {
1530 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1531 + }
1532 + },
1533 + "node_modules/node-releases": {
1534 + "version": "2.0.53",
1535 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1536 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1537 + "dev": true,
1538 + "license": "MIT",
1539 + "engines": {
1540 + "node": ">=18"
1541 + }
1542 + },
1543 + "node_modules/picocolors": {
1544 + "version": "1.1.1",
1545 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1546 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1547 + "dev": true,
1548 + "license": "ISC"
1549 + },
1550 + "node_modules/postcss": {
1551 + "version": "8.5.26",
1552 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1553 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1554 + "dev": true,
1555 + "funding": [
1556 + {
1557 + "type": "opencollective",
1558 + "url": "https://opencollective.com/postcss/"
1559 + },
1560 + {
1561 + "type": "tidelift",
1562 + "url": "https://tidelift.com/funding/github/npm/postcss"
1563 + },
1564 + {
1565 + "type": "github",
1566 + "url": "https://github.com/sponsors/ai"
1567 + }
1568 + ],
1569 + "license": "MIT",
1570 + "dependencies": {
1571 + "nanoid": "^3.3.17",
1572 + "picocolors": "^1.1.1",
1573 + "source-map-js": "^1.2.1"
1574 + },
1575 + "engines": {
1576 + "node": "^10 || ^12 || >=14"
1577 + }
1578 + },
1579 + "node_modules/react": {
1580 + "version": "18.3.1",
1581 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1582 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1583 + "license": "MIT",
1584 + "dependencies": {
1585 + "loose-envify": "^1.1.0"
1586 + },
1587 + "engines": {
1588 + "node": ">=0.10.0"
1589 + }
1590 + },
1591 + "node_modules/react-dom": {
1592 + "version": "18.3.1",
1593 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1594 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1595 + "license": "MIT",
1596 + "dependencies": {
1597 + "loose-envify": "^1.1.0",
1598 + "scheduler": "^0.23.2"
1599 + },
1600 + "peerDependencies": {
1601 + "react": "^18.3.1"
1602 + }
1603 + },
1604 + "node_modules/react-refresh": {
1605 + "version": "0.17.0",
1606 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1607 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1608 + "dev": true,
1609 + "license": "MIT",
1610 + "engines": {
1611 + "node": ">=0.10.0"
1612 + }
1613 + },
1614 + "node_modules/react-router": {
1615 + "version": "6.30.4",
1616 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1617 + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1618 + "license": "MIT",
1619 + "dependencies": {
1620 + "@remix-run/router": "1.23.3"
1621 + },
1622 + "engines": {
1623 + "node": ">=14.0.0"
1624 + },
1625 + "peerDependencies": {
1626 + "react": ">=16.8"
1627 + }
1628 + },
1629 + "node_modules/react-router-dom": {
1630 + "version": "6.30.4",
1631 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1632 + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1633 + "license": "MIT",
1634 + "dependencies": {
1635 + "@remix-run/router": "1.23.3",
1636 + "react-router": "6.30.4"
1637 + },
1638 + "engines": {
1639 + "node": ">=14.0.0"
1640 + },
1641 + "peerDependencies": {
1642 + "react": ">=16.8",
1643 + "react-dom": ">=16.8"
1644 + }
1645 + },
1646 + "node_modules/rollup": {
1647 + "version": "4.62.4",
1648 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1649 + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1650 + "dev": true,
1651 + "license": "MIT",
1652 + "dependencies": {
1653 + "@types/estree": "1.0.9"
1654 + },
1655 + "bin": {
1656 + "rollup": "dist/bin/rollup"
1657 + },
1658 + "engines": {
1659 + "node": ">=18.0.0",
1660 + "npm": ">=8.0.0"
1661 + },
1662 + "optionalDependencies": {
1663 + "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1664 + "@rollup/rollup-android-arm-eabi": "4.62.4",
1665 + "@rollup/rollup-android-arm64": "4.62.4",
1666 + "@rollup/rollup-darwin-arm64": "4.62.4",
1667 + "@rollup/rollup-darwin-x64": "4.62.4",
1668 + "@rollup/rollup-freebsd-arm64": "4.62.4",
1669 + "@rollup/rollup-freebsd-x64": "4.62.4",
1670 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1671 + "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1672 + "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1673 + "@rollup/rollup-linux-arm64-musl": "4.62.4",
1674 + "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1675 + "@rollup/rollup-linux-loong64-musl": "4.62.4",
1676 + "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1677 + "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1678 + "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1679 + "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1680 + "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1681 + "@rollup/rollup-linux-x64-gnu": "4.62.4",
1682 + "@rollup/rollup-linux-x64-musl": "4.62.4",
1683 + "@rollup/rollup-openbsd-x64": "4.62.4",
1684 + "@rollup/rollup-openharmony-arm64": "4.62.4",
1685 + "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1686 + "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1687 + "@rollup/rollup-win32-x64-gnu": "4.62.4",
1688 + "@rollup/rollup-win32-x64-msvc": "4.62.4",
1689 + "fsevents": "~2.3.2"
1690 + }
1691 + },
1692 + "node_modules/scheduler": {
1693 + "version": "0.23.2",
1694 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1695 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1696 + "license": "MIT",
1697 + "dependencies": {
1698 + "loose-envify": "^1.1.0"
1699 + }
1700 + },
1701 + "node_modules/semver": {
1702 + "version": "6.3.1",
1703 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1704 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1705 + "dev": true,
1706 + "license": "ISC",
1707 + "bin": {
1708 + "semver": "bin/semver.js"
1709 + }
1710 + },
1711 + "node_modules/source-map-js": {
1712 + "version": "1.2.1",
1713 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1714 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1715 + "dev": true,
1716 + "license": "BSD-3-Clause",
1717 + "engines": {
1718 + "node": ">=0.10.0"
1719 + }
1720 + },
1721 + "node_modules/typescript": {
1722 + "version": "5.9.3",
1723 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1724 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1725 + "dev": true,
1726 + "license": "Apache-2.0",
1727 + "bin": {
1728 + "tsc": "bin/tsc",
1729 + "tsserver": "bin/tsserver"
1730 + },
1731 + "engines": {
1732 + "node": ">=14.17"
1733 + }
1734 + },
1735 + "node_modules/update-browserslist-db": {
1736 + "version": "1.2.3",
1737 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
1738 + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
1739 + "dev": true,
1740 + "funding": [
1741 + {
1742 + "type": "opencollective",
1743 + "url": "https://opencollective.com/browserslist"
1744 + },
1745 + {
1746 + "type": "tidelift",
1747 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1748 + },
1749 + {
1750 + "type": "github",
1751 + "url": "https://github.com/sponsors/ai"
1752 + }
1753 + ],
1754 + "license": "MIT",
1755 + "dependencies": {
1756 + "escalade": "^3.2.0",
1757 + "picocolors": "^1.1.1"
1758 + },
1759 + "bin": {
1760 + "update-browserslist-db": "cli.js"
1761 + },
1762 + "peerDependencies": {
1763 + "browserslist": ">= 4.21.0"
1764 + }
1765 + },
1766 + "node_modules/vite": {
1767 + "version": "5.4.21",
1768 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1769 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1770 + "dev": true,
1771 + "license": "MIT",
1772 + "dependencies": {
1773 + "esbuild": "^0.21.3",
1774 + "postcss": "^8.4.43",
1775 + "rollup": "^4.20.0"
1776 + },
1777 + "bin": {
1778 + "vite": "bin/vite.js"
1779 + },
1780 + "engines": {
1781 + "node": "^18.0.0 || >=20.0.0"
1782 + },
1783 + "funding": {
1784 + "url": "https://github.com/vitejs/vite?sponsor=1"
1785 + },
1786 + "optionalDependencies": {
1787 + "fsevents": "~2.3.3"
1788 + },
1789 + "peerDependencies": {
1790 + "@types/node": "^18.0.0 || >=20.0.0",
1791 + "less": "*",
1792 + "lightningcss": "^1.21.0",
1793 + "sass": "*",
1794 + "sass-embedded": "*",
1795 + "stylus": "*",
1796 + "sugarss": "*",
1797 + "terser": "^5.4.0"
1798 + },
1799 + "peerDependenciesMeta": {
1800 + "@types/node": {
1801 + "optional": true
1802 + },
1803 + "less": {
1804 + "optional": true
1805 + },
1806 + "lightningcss": {
1807 + "optional": true
1808 + },
1809 + "sass": {
1810 + "optional": true
1811 + },
1812 + "sass-embedded": {
1813 + "optional": true
1814 + },
1815 + "stylus": {
1816 + "optional": true
1817 + },
1818 + "sugarss": {
1819 + "optional": true
1820 + },
1821 + "terser": {
1822 + "optional": true
1823 + }
1824 + }
1825 + },
1826 + "node_modules/yallist": {
1827 + "version": "3.1.1",
1828 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1829 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1830 + "dev": true,
1831 + "license": "ISC"
1832 + }
1833 + }
1834 +}
added frontend/package.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "name": "lou-ka-frontend",
3 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
4 + "private": true,
5 + "version": "1.0.0",
6 + "type": "module",
7 + "scripts": {
8 + "dev": "vite",
9 + "build": "tsc -b && vite build",
10 + "preview": "vite preview"
11 + },
12 + "dependencies": {
13 + "react": "^18.3.1",
14 + "react-dom": "^18.3.1",
15 + "react-router-dom": "^6.26.0"
16 + },
17 + "devDependencies": {
18 + "@types/react": "^18.3.3",
19 + "@types/react-dom": "^18.3.0",
20 + "@vitejs/plugin-react": "^4.3.1",
21 + "typescript": "^5.5.4",
22 + "vite": "^5.4.0"
23 + }
24 +}
added frontend/public/manifest.webmanifest +18 −0
@@ -0,0 +1,18 @@
1 +{
2 + "name": "Lou-Ka — Logements à louer au Québec",
3 + "short_name": "Lou-Ka",
4 + "description": "Tous les logements à louer de Québec et Lévis, un seul endroit.",
5 + "start_url": "/",
6 + "display": "standalone",
7 + "background_color": "#f5f3ee",
8 + "theme_color": "#f5f3ee",
9 + "lang": "fr",
10 + "icons": [
11 + {
12 + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Crect width='512' height='512' rx='96' fill='%23121712'/%3E%3Ctext x='256' y='356' font-family='Arial Black,sans-serif' font-size='256' font-weight='900' fill='%23d9f26b' text-anchor='middle'%3ELK%3C/text%3E%3C/svg%3E",
13 + "sizes": "512x512",
14 + "type": "image/svg+xml",
15 + "purpose": "any"
16 + }
17 + ]
18 +}
added frontend/src/App.tsx +119 −0
@@ -0,0 +1,119 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// App.tsx : layout global (header + ticker en direct + footer encre) et routage
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { NavLink, Route, Routes } from "react-router-dom";
8 +import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
9 +import Home from "./pages/Home";
10 +import ListingPage from "./pages/Listing";
11 +import SourcesPage from "./pages/Sources";
12 +
13 +function Ticker() {
14 + const [items, setItems] = useState<string[]>([]);
15 +
16 + useEffect(() => {
17 + Promise.all([fetchStats(), fetchFacets(), fetchSources()])
18 + .then(([stats, facets, src]) => {
19 + registerSourceNames(src.sources);
20 + const parts: string[] = [
21 + `${stats.total} logements actifs`,
22 + `Québec ${stats.quebec ?? 0}`,
23 + `Lévis ${stats.levis ?? 0}`,
24 + ];
25 + if ((stats.montreal ?? 0) > 0) parts.push(`Grand Montréal ${stats.montreal}`);
26 + if (stats.avg_price != null)
27 + parts.push(`Loyer moyen ${Math.round(stats.avg_price)} $`);
28 + for (const s of facets.sources.slice(0, 10))
29 + parts.push(`${sourceName(s.source)} · ${s.n}`);
30 + parts.push("Mise à jour automatique");
31 + setItems(parts);
32 + })
33 + .catch(() => setItems(["Lou-Ka — logements à louer au Québec"]));
34 + }, []);
35 +
36 + if (items.length === 0) return null;
37 + // contenu doublé pour une boucle de défilement continue
38 + return (
39 + <div className="ticker" aria-hidden="true">
40 + <div className="ticker-track">
41 + {[...items, ...items].map((t, i) => (
42 + <span key={i}>{t}</span>
43 + ))}
44 + </div>
45 + </div>
46 + );
47 +}
48 +
49 +function Header() {
50 + return (
51 + <>
52 + <header className="header">
53 + <div className="container header-inner">
54 + <NavLink to="/" className="brand" aria-label="Lou-Ka — accueil">
55 + Lou<span className="ka">Ka</span>
56 + <span className="brand-tag">Québec · Lévis · Montréal — toujours à jour</span>
57 + </NavLink>
58 + <nav className="nav">
59 + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
60 + Logements
61 + </NavLink>
62 + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>
63 + Sources
64 + </NavLink>
65 + </nav>
66 + </div>
67 + </header>
68 + <Ticker />
69 + </>
70 + );
71 +}
72 +
73 +function Footer() {
74 + return (
75 + <footer className="footer">
76 + <div className="container">
77 + <div className="fbrand">
78 + Lou<span className="ka">Ka</span>
79 + </div>
80 + <div className="frow">
81 + <div>
82 + <b>Agrégateur indépendant</b> de logements à louer dans la province de Québec.
83 + Les annonces proviennent des sites publics des gestionnaires immobiliers et sont
84 + rafraîchies automatiquement — chaque fiche renvoie vers l'annonce originale.
85 + </div>
86 + </div>
87 + <div className="fmono">
88 + © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai
89 + </div>
90 + </div>
91 + </footer>
92 + );
93 +}
94 +
95 +export default function App() {
96 + return (
97 + <>
98 + <Header />
99 + <main>
100 + <Routes>
101 + <Route path="/" element={<Home />} />
102 + <Route path="/logement/:uid" element={<ListingPage />} />
103 + <Route path="/sources" element={<SourcesPage />} />
104 + <Route
105 + path="*"
106 + element={
107 + <div className="notice container">
108 + <div className="big">🧭</div>
109 + <h2>Page introuvable</h2>
110 + <p>Le lien demandé n'existe pas.</p>
111 + </div>
112 + }
113 + />
114 + </Routes>
115 + </main>
116 + <Footer />
117 + </>
118 + );
119 +}
added frontend/src/api.ts +101 −0
@@ -0,0 +1,101 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// api.ts : types + client API robuste (timeout, erreurs typées)
5 +// -----------------------------------------------------------------------------
6 +
7 +export interface Listing {
8 + uid: string;
9 + source: string;
10 + external_id: string;
11 + url: string;
12 + title: string;
13 + address: string;
14 + sector: string;
15 + city: string;
16 + unit_type: string;
17 + price: number | null;
18 + price_label: string;
19 + availability: string;
20 + description: string;
21 + amenities: string[];
22 + images: string[];
23 + last_seen: number;
24 + updated_at: number;
25 + active: number;
26 +}
27 +
28 +export interface Facets {
29 + cities: string[];
30 + sectors: string[];
31 + unit_types: string[];
32 + sources: { source: string; n: number }[];
33 +}
34 +
35 +export interface Source {
36 + id: string;
37 + name: string;
38 + url: string;
39 + listing_url: string;
40 + sectors: string;
41 + connector: string | null;
42 + status: string;
43 + active_listings: number;
44 + last_sync: number | null;
45 +}
46 +
47 +export interface Stats {
48 + total: number;
49 + quebec: number;
50 + levis: number;
51 + montreal: number;
52 + sources: number;
53 + avg_price: number | null;
54 +}
55 +
56 +const SOURCE_NAMES: Record<string, string> = {};
57 +
58 +export function registerSourceNames(sources: Source[]) {
59 + for (const s of sources) SOURCE_NAMES[s.id] = s.name;
60 +}
61 +export function sourceName(id: string): string {
62 + return SOURCE_NAMES[id] ?? id;
63 +}
64 +
65 +async function get<T>(path: string): Promise<T> {
66 + const ctrl = new AbortController();
67 + const timer = setTimeout(() => ctrl.abort(), 20000);
68 + try {
69 + const res = await fetch(path, { signal: ctrl.signal });
70 + if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
71 + return (await res.json()) as T;
72 + } finally {
73 + clearTimeout(timer);
74 + }
75 +}
76 +
77 +export interface ListingFilters {
78 + city?: string;
79 + sector?: string;
80 + unit_type?: string;
81 + source?: string;
82 + price_max?: string;
83 + q?: string;
84 +}
85 +
86 +export function fetchListings(f: ListingFilters) {
87 + const params = new URLSearchParams();
88 + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
89 + return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`);
90 +}
91 +
92 +export const fetchListing = (uid: string) =>
93 + get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);
94 +export const fetchFacets = () => get<Facets>("/api/facets");
95 +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
96 +export const fetchStats = () => get<Stats>("/api/stats");
97 +
98 +export const fmtPrice = (p: number | null, label?: string) =>
99 + p != null
100 + ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"
101 + : label || "Prix sur demande";
added frontend/src/components/ListingCard.tsx +41 −0
@@ -0,0 +1,41 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/ListingCard.tsx : carte d'annonce (grille de résultats)
5 +// -----------------------------------------------------------------------------
6 +import { Link } from "react-router-dom";
7 +import { Listing, fmtPrice, sourceName } from "../api";
8 +
9 +export default function ListingCard({ l }: { l: Listing }) {
10 + const img = l.images && l.images.length > 0 ? l.images[0] : null;
11 + return (
12 + <Link to={`/logement/${encodeURIComponent(l.uid)}`} className="card">
13 + <div className="card-img">
14 + {img ? (
15 + <img src={img} alt={l.title} loading="lazy" />
16 + ) : (
17 + <div className="noimg">🏠</div>
18 + )}
19 + {l.unit_type && <span className="badge type">{l.unit_type}</span>}
20 + {l.images.length > 1 && (
21 + <span className="badge right">📷 {l.images.length}</span>
22 + )}
23 + </div>
24 + <div className="card-body">
25 + <div className="card-price">
26 + {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/ mois</small>}
27 + </div>
28 + <div className="card-title">{l.title || l.address}</div>
29 + <div className="card-meta">
30 + {l.sector && <span>{l.sector}</span>}
31 + {l.sector && l.city && <span className="sep" />}
32 + {l.city && <span>{l.city}</span>}
33 + </div>
34 + <div className="card-foot">
35 + <span className="source-tag">{sourceName(l.source)}</span>
36 + {l.availability && <span className="avail">{l.availability}</span>}
37 + </div>
38 + </div>
39 + </Link>
40 + );
41 +}
added frontend/src/main.tsx +18 −0
@@ -0,0 +1,18 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// main.tsx : point d'entrée React
5 +// -----------------------------------------------------------------------------
6 +import React from "react";
7 +import ReactDOM from "react-dom/client";
8 +import { BrowserRouter } from "react-router-dom";
9 +import App from "./App";
10 +import "./styles.css";
11 +
12 +ReactDOM.createRoot(document.getElementById("root")!).render(
13 + <React.StrictMode>
14 + <BrowserRouter>
15 + <App />
16 + </BrowserRouter>
17 + </React.StrictMode>
18 +);
added frontend/src/pages/Home.tsx +226 −0
@@ -0,0 +1,226 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Home.tsx : accueil — héro, statistiques, filtres, grille d'annonces
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useMemo, useState } from "react";
7 +import {
8 + Facets, Listing, Stats,
9 + fetchFacets, fetchListings, fetchSources, fetchStats,
10 + registerSourceNames, sourceName,
11 +} from "../api";
12 +import ListingCard from "../components/ListingCard";
13 +
14 +const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];
15 +
16 +export default function Home() {
17 + const [listings, setListings] = useState<Listing[] | null>(null);
18 + const [total, setTotal] = useState(0);
19 + const [facets, setFacets] = useState<Facets | null>(null);
20 + const [stats, setStats] = useState<Stats | null>(null);
21 + const [error, setError] = useState<string | null>(null);
22 +
23 + // filtres
24 + const [q, setQ] = useState("");
25 + const [city, setCity] = useState("");
26 + const [source, setSource] = useState("");
27 + const [priceMax, setPriceMax] = useState("");
28 + const [unitType, setUnitType] = useState("");
29 + // feuille de filtres mobile (bottom sheet)
30 + const [sheetOpen, setSheetOpen] = useState(false);
31 + const activeFilters = [q, city, source, priceMax, unitType].filter(Boolean).length;
32 +
33 + useEffect(() => {
34 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
35 + fetchFacets().then(setFacets).catch(() => {});
36 + fetchStats().then(setStats).catch(() => {});
37 + }, []);
38 +
39 + useEffect(() => {
40 + let cancelled = false;
41 + setListings(null);
42 + setError(null);
43 + fetchListings({ q, city, source, price_max: priceMax, unit_type: unitType })
44 + .then((r) => {
45 + if (!cancelled) {
46 + setListings(r.listings);
47 + setTotal(r.total);
48 + }
49 + })
50 + .catch((e) => !cancelled && setError(String(e)));
51 + return () => {
52 + cancelled = true;
53 + };
54 + }, [q, city, source, priceMax, unitType]);
55 +
56 + const availableTypes = useMemo(() => {
57 + const set = new Set(facets?.unit_types ?? []);
58 + return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t));
59 + }, [facets]);
60 +
61 + return (
62 + <div className="container">
63 + <section className="hero">
64 + <span className="kicker">Agrégateur — Québec · Lévis · Montréal</span>
65 + <h1>
66 + Tous les logements <span className="outline">à louer</span>,<br />
67 + <span className="hl">un seul</span> endroit.
68 + </h1>
69 + <p className="lede">
70 + Lou-Ka rassemble les appartements affichés par les gestionnaires immobiliers du
71 + Québec — Québec, Lévis et le Grand Montréal — mis à jour automatiquement, avec
72 + toutes les photos et un lien direct vers l'annonce originale.
73 + </p>
74 + <div className="stat-row">
75 + <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>
76 + {stats && (
77 + <>
78 + <span className="stat-chip"><b>{stats.total}</b> logements actifs</span>
79 + <span className="stat-chip"><b>{stats.quebec ?? 0}</b> à Québec</span>
80 + <span className="stat-chip"><b>{stats.levis ?? 0}</b> à Lévis</span>
81 + {(stats.montreal ?? 0) > 0 && (
82 + <span className="stat-chip"><b>{stats.montreal}</b> Grand Montréal</span>
83 + )}
84 + {stats.avg_price != null && (
85 + <span className="stat-chip">
86 + loyer moyen <b>{Math.round(stats.avg_price)} $</b>
87 + </span>
88 + )}
89 + </>
90 + )}
91 + </div>
92 + </section>
93 +
94 + {sheetOpen && (
95 + <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />
96 + )}
97 + <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">
98 + <div className="sheet-head">
99 + <span>Filtres</span>
100 + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">
101 +
102 + </button>
103 + </div>
104 + <div className="field">
105 + <label htmlFor="f-q">Recherche</label>
106 + <input
107 + id="f-q" placeholder="Adresse, quartier, rue…" value={q}
108 + onChange={(e) => setQ(e.target.value)}
109 + />
110 + </div>
111 + <div className="field">
112 + <label htmlFor="f-city">Ville</label>
113 + <select id="f-city" value={city} onChange={(e) => setCity(e.target.value)}>
114 + <option value="">Toutes</option>
115 + {(facets?.cities ?? ["Québec", "Lévis"]).map((c) => (
116 + <option key={c} value={c}>{c}</option>
117 + ))}
118 + </select>
119 + </div>
120 + <div className="field">
121 + <label htmlFor="f-price">Loyer max</label>
122 + <select id="f-price" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>
123 + <option value="">Aucun</option>
124 + {[800, 1000, 1200, 1400, 1600, 1800, 2000, 2500].map((p) => (
125 + <option key={p} value={p}>{p} $</option>
126 + ))}
127 + </select>
128 + </div>
129 + <div className="field">
130 + <label htmlFor="f-source">Gestionnaire</label>
131 + <select id="f-source" value={source} onChange={(e) => setSource(e.target.value)}>
132 + <option value="">Tous</option>
133 + {(facets?.sources ?? []).map((s) => (
134 + <option key={s.source} value={s.source}>
135 + {sourceName(s.source)} ({s.n})
136 + </option>
137 + ))}
138 + </select>
139 + </div>
140 + <div className="field">
141 + <label htmlFor="f-type">Taille</label>
142 + <select id="f-type" value={unitType} onChange={(e) => setUnitType(e.target.value)}>
143 + <option value="">Toutes</option>
144 + {availableTypes.map((t) => (
145 + <option key={t} value={t}>{t}</option>
146 + ))}
147 + </select>
148 + </div>
149 + <button
150 + className="btn btn-ghost" style={{ alignSelf: "end" }}
151 + onClick={() => { setQ(""); setCity(""); setSource(""); setPriceMax(""); setUnitType(""); }}
152 + >
153 + Réinitialiser
154 + </button>
155 + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
156 + Voir les résultats {listings ? `(${total})` : ""}
157 + </button>
158 + </section>
159 +
160 + <div className="chips" role="group" aria-label="Filtrer par taille">
161 + {availableTypes.map((t) => (
162 + <button
163 + key={t}
164 + className={`chip ${unitType === t ? "on" : ""}`}
165 + onClick={() => setUnitType(unitType === t ? "" : t)}
166 + >
167 + {t}
168 + </button>
169 + ))}
170 + </div>
171 +
172 + <div className="results-head">
173 + <h2>Logements disponibles</h2>
174 + {listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
175 + </div>
176 +
177 + {error && (
178 + <div className="notice">
179 + <div className="big">⚠️</div>
180 + <h2>Impossible de charger les annonces</h2>
181 + <p>{error}</p>
182 + <button className="btn btn-primary" onClick={() => window.location.reload()}>
183 + Réessayer
184 + </button>
185 + </div>
186 + )}
187 +
188 + {!error && listings === null && (
189 + <div className="grid" aria-busy="true">
190 + {Array.from({ length: 8 }).map((_, i) => (
191 + <div className="skel" key={i}>
192 + <div className="sk-img" />
193 + <div className="sk-line" />
194 + <div className="sk-line short" />
195 + </div>
196 + ))}
197 + </div>
198 + )}
199 +
200 + {!error && listings !== null && listings.length === 0 && (
201 + <div className="notice">
202 + <div className="big">🔍</div>
203 + <h2>Aucun logement ne correspond</h2>
204 + <p>Essayez d'élargir vos critères, ou lancez une synchronisation (<code>python run.py sync</code>).</p>
205 + </div>
206 + )}
207 +
208 + {!error && listings !== null && listings.length > 0 && (
209 + <div className="grid">
210 + {listings.map((l) => (
211 + <ListingCard key={l.uid} l={l} />
212 + ))}
213 + </div>
214 + )}
215 +
216 + {/* Bouton flottant mobile — ouvre la feuille de filtres */}
217 + <button
218 + className="fab"
219 + onClick={() => setSheetOpen(true)}
220 + aria-label="Ouvrir les filtres"
221 + >
222 + ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
223 + </button>
224 + </div>
225 + );
226 +}
added frontend/src/pages/Listing.tsx +146 −0
@@ -0,0 +1,146 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Listing.tsx : fiche d'un logement — galerie complète + détails
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Link, useParams } from "react-router-dom";
8 +import { Listing, fetchListing, fetchSources, fmtPrice, registerSourceNames, sourceName } from "../api";
9 +
10 +export default function ListingPage() {
11 + const { uid } = useParams<{ uid: string }>();
12 + const [l, setL] = useState<Listing | null>(null);
13 + const [error, setError] = useState<string | null>(null);
14 + const [imgIdx, setImgIdx] = useState(0);
15 + const [zoom, setZoom] = useState(false);
16 +
17 + useEffect(() => {
18 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
19 + if (!uid) return;
20 + fetchListing(uid)
21 + .then((d) => { setL(d); setImgIdx(0); })
22 + .catch((e) => setError(String(e)));
23 + window.scrollTo(0, 0);
24 + }, [uid]);
25 +
26 + if (error)
27 + return (
28 + <div className="notice container">
29 + <div className="big">⚠️</div>
30 + <h2>Annonce introuvable</h2>
31 + <p>{error}</p>
32 + <Link className="btn btn-primary" to="/">Retour aux logements</Link>
33 + </div>
34 + );
35 +
36 + if (!l)
37 + return (
38 + <div className="container detail">
39 + <div className="detail-grid">
40 + <div className="skel"><div className="sk-img" /></div>
41 + <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
42 + </div>
43 + </div>
44 + );
45 +
46 + const imgs = l.images ?? [];
47 + const main = imgs[imgIdx];
48 + const updated = l.updated_at
49 + ? new Date(l.updated_at * 1000).toLocaleDateString("fr-CA", {
50 + day: "numeric", month: "long", year: "numeric",
51 + })
52 + : null;
53 +
54 + return (
55 + <div className="container detail">
56 + <nav className="crumbs" aria-label="Fil d'Ariane">
57 + <Link to="/">Logements</Link> ›
58 + {l.city && <span>{l.city}</span>} ›
59 + <span>{l.title || l.address}</span>
60 + </nav>
61 +
62 + <div className="detail-grid">
63 + <div className="gallery">
64 + <div className="gallery-main" onClick={() => main && setZoom(true)}>
65 + {main ? (
66 + <img src={main} alt={`${l.title} — photo ${imgIdx + 1}`} />
67 + ) : (
68 + <div className="noimg" style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontSize: 48 }}>
69 + 🏠
70 + </div>
71 + )}
72 + </div>
73 + {imgs.length > 1 && (
74 + <div className="thumbs">
75 + {imgs.map((u, i) => (
76 + <button
77 + key={u}
78 + className={i === imgIdx ? "on" : ""}
79 + onClick={() => setImgIdx(i)}
80 + aria-label={`Photo ${i + 1}`}
81 + >
82 + <img src={u} alt="" loading="lazy" />
83 + </button>
84 + ))}
85 + </div>
86 + )}
87 + {l.description && (
88 + <p style={{ color: "var(--ink-2)", marginTop: 18 }}>{l.description}</p>
89 + )}
90 + </div>
91 +
92 + <aside className="panel">
93 + <div className="price">
94 + {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/ mois</small>}
95 + </div>
96 + <h1>{l.title || l.address}</h1>
97 + <div className="loc">
98 + {[l.address !== l.title ? l.address : "", l.sector, l.city]
99 + .filter(Boolean)
100 + .join(" · ")}
101 + </div>
102 +
103 + <div className="kv">
104 + {l.unit_type && (
105 + <div className="cell"><div className="k">Taille</div><div className="v">{l.unit_type}</div></div>
106 + )}
107 + {l.availability && (
108 + <div className="cell"><div className="k">Disponibilité</div><div className="v">{l.availability}</div></div>
109 + )}
110 + <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div>
111 + {l.price_label && (
112 + <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div>
113 + )}
114 + </div>
115 +
116 + {l.amenities.length > 0 && (
117 + <>
118 + <div className="k" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.07em", color: "var(--ink-3)", fontWeight: 700, marginBottom: 8 }}>
119 + Inclusions et commodités
120 + </div>
121 + <div className="amenity-row">
122 + {l.amenities.map((a) => (
123 + <span className="amenity" key={a}>{a}</span>
124 + ))}
125 + </div>
126 + </>
127 + )}
128 +
129 + <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
130 + Voir l'annonce chez {sourceName(l.source)} ↗
131 + </a>
132 + <div className="fine">
133 + {updated && <>Dernière synchronisation : {updated}. </>}
134 + Les prix et disponibilités sont ceux affichés par la source.
135 + </div>
136 + </aside>
137 + </div>
138 +
139 + {zoom && main && (
140 + <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">
141 + <img src={main} alt="" />
142 + </div>
143 + )}
144 + </div>
145 + );
146 +}
added frontend/src/pages/Sources.tsx +74 −0
@@ -0,0 +1,74 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Sources.tsx : registre des gestionnaires immobiliers agrégés
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Source, fetchSources } from "../api";
8 +
9 +export default function SourcesPage() {
10 + const [sources, setSources] = useState<Source[] | null>(null);
11 + const [error, setError] = useState<string | null>(null);
12 +
13 + useEffect(() => {
14 + fetchSources()
15 + .then((r) => setSources(r.sources))
16 + .catch((e) => setError(String(e)));
17 + }, []);
18 +
19 + return (
20 + <div className="container sources">
21 + <span className="kicker">Registre — gestionnaires immobiliers</span>
22 + <h1>Sources agrégées</h1>
23 + <p className="sub">
24 + Gestionnaires immobiliers de Québec, Lévis et du Grand Montréal recensés par Lou-Ka. Chaque source «
25 + active » est synchronisée périodiquement par un connecteur dédié; les autres sont en
26 + attente de connecteur.
27 + </p>
28 +
29 + {error && <div className="notice">⚠️ {error}</div>}
30 + {!sources && !error && <div className="notice">Chargement…</div>}
31 +
32 + {sources && (
33 + <div className="src-wrap">
34 + <table className="src-table">
35 + <thead>
36 + <tr>
37 + <th>Gestionnaire</th>
38 + <th>Secteurs</th>
39 + <th>Statut</th>
40 + <th style={{ textAlign: "right" }}>Annonces actives</th>
41 + <th>Dernière synchro</th>
42 + </tr>
43 + </thead>
44 + <tbody>
45 + {sources.map((s) => (
46 + <tr key={s.id}>
47 + <td>
48 + <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a>
49 + </td>
50 + <td style={{ color: "var(--ink-2)", maxWidth: 380 }}>{s.sectors}</td>
51 + <td>
52 + {s.connector ? (
53 + <span className="pill ok">connecté</span>
54 + ) : (
55 + <span className="pill todo">{s.status}</span>
56 + )}
57 + </td>
58 + <td style={{ textAlign: "right" }}>
59 + <span className="count-pill">{s.active_listings || "—"}</span>
60 + </td>
61 + <td style={{ color: "var(--ink-3)" }}>
62 + {s.last_sync
63 + ? new Date(s.last_sync * 1000).toLocaleString("fr-CA")
64 + : "—"}
65 + </td>
66 + </tr>
67 + ))}
68 + </tbody>
69 + </table>
70 + </div>
71 + )}
72 + </div>
73 + );
74 +}
added frontend/src/styles.css +425 −0
@@ -0,0 +1,425 @@
1 +/* -----------------------------------------------------------------------------
2 + Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + styles.css : système de design « éditorial sharp » — thème clair
5 + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
6 + · Signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
7 + · Accent électrique lime #d9f26b sur encre verte profonde
8 + · 100 % adaptatif mobile (PWA installable, safe-areas iOS)
9 +----------------------------------------------------------------------------- */
10 +:root {
11 + --paper: #f5f3ee;
12 + --surface: #ffffff;
13 + --surface-2: #faf9f5;
14 + --ink: #141814;
15 + --ink-2: #4d5551;
16 + --ink-3: #8b928c;
17 + --line: rgba(20, 24, 20, 0.14);
18 + --line-strong: rgba(20, 24, 20, 0.85);
19 + --green: #1c5c41;
20 + --green-deep: #123f2e;
21 + --lime: #d9f26b;
22 + --lime-soft: #f0f9d2;
23 + --amber: #e8a33d;
24 + --amber-soft: #fdf3e2;
25 + --danger: #b3423a;
26 + --r-card: 10px;
27 + --r-ctl: 6px;
28 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
29 + --shadow-off: 6px 6px 0 var(--ink);
30 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
31 + --font-display: "Space Grotesk", system-ui, sans-serif;
32 + --font-body: "Inter", system-ui, sans-serif;
33 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
34 +}
35 +
36 +* { box-sizing: border-box; }
37 +html { scroll-behavior: smooth; }
38 +body {
39 + margin: 0;
40 + background: var(--paper);
41 + color: var(--ink);
42 + font-family: var(--font-body);
43 + font-size: 15px;
44 + line-height: 1.55;
45 + -webkit-font-smoothing: antialiased;
46 + padding-bottom: env(safe-area-inset-bottom);
47 +}
48 +/* grain subtil — texture signature */
49 +body::before {
50 + content: "";
51 + position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35;
52 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
53 +}
54 +#root { position: relative; z-index: 1; }
55 +
56 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0; }
57 +a { color: inherit; text-decoration: none; }
58 +button { font-family: inherit; }
59 +img { display: block; }
60 +::selection { background: var(--lime); color: var(--ink); }
61 +
62 +.mono { font-family: var(--font-mono); }
63 +.kicker {
64 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
65 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--green);
66 + display: inline-flex; align-items: center; gap: 8px;
67 +}
68 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
69 +
70 +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
71 +@media (max-width: 640px) { .container { padding: 0 16px; } }
72 +
73 +/* ================= Header ================= */
74 +.header {
75 + position: sticky; top: 0; z-index: 50;
76 + background: rgba(245, 243, 238, 0.88);
77 + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
78 + border-bottom: 2px solid var(--ink);
79 +}
80 +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
81 +.brand {
82 + font-family: var(--font-display); font-weight: 700; font-size: 26px;
83 + letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1;
84 +}
85 +.brand .ka {
86 + background: var(--ink); color: var(--lime); padding: 2px 7px 4px;
87 + border-radius: 6px; margin-left: 3px; transform: rotate(-2deg);
88 + transition: transform 0.2s ease;
89 +}
90 +.brand:hover .ka { transform: rotate(0deg); }
91 +.brand-tag {
92 + font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
93 + letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px;
94 +}
95 +@media (max-width: 760px) { .brand-tag { display: none; } }
96 +.nav { margin-left: auto; display: flex; gap: 4px; }
97 +.nav a {
98 + padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px;
99 + color: var(--ink-2); border: 1.5px solid transparent;
100 + transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center;
101 +}
102 +.nav a:hover { border-color: var(--ink); color: var(--ink); }
103 +.nav a.active { background: var(--ink); color: var(--lime); }
104 +
105 +/* ================= Ticker ================= */
106 +.ticker {
107 + background: var(--ink); color: var(--lime); overflow: hidden;
108 + font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em;
109 + text-transform: uppercase; padding: 7px 0; white-space: nowrap;
110 + border-bottom: 1px solid rgba(217, 242, 107, 0.25);
111 +}
112 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; }
113 +.ticker span { padding: 0 26px; position: relative; }
114 +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
115 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
116 +@media (prefers-reduced-motion: reduce) {
117 + .ticker-track { animation: none; }
118 + * { transition-duration: 0.01ms !important; }
119 +}
120 +
121 +/* ================= Hero ================= */
122 +.hero { padding: 58px 0 22px; }
123 +@media (max-width: 640px) { .hero { padding: 34px 0 14px; } }
124 +.hero h1 {
125 + font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98;
126 + text-transform: uppercase; letter-spacing: -0.035em; max-width: 900px; margin-top: 14px;
127 +}
128 +.hero h1 .outline {
129 + color: transparent; -webkit-text-stroke: 2px var(--ink);
130 +}
131 +.hero h1 .hl {
132 + background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block;
133 + transform: rotate(-1deg);
134 +}
135 +.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 640px; margin: 20px 0 0; }
136 +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
137 +.stat-chip {
138 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px;
139 + padding: 8px 16px; font-family: var(--font-mono); font-size: 12px;
140 + color: var(--ink-2); display: flex; gap: 8px; align-items: center;
141 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);
142 +}
143 +.stat-chip b { color: var(--ink); font-weight: 700; }
144 +.stat-chip .pulse {
145 + width: 8px; height: 8px; border-radius: 50%; background: var(--green);
146 + box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite;
147 +}
148 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(217, 242, 107, 0.4); } }
149 +
150 +/* ================= Filter bar ================= */
151 +.filterbar {
152 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
153 + box-shadow: var(--shadow-off-soft); padding: 16px; margin: 30px 0 6px;
154 + display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr 1fr auto; gap: 12px;
155 +}
156 +@media (max-width: 980px) { .filterbar { grid-template-columns: 1fr 1fr 1fr; } }
157 +@media (max-width: 640px) { .filterbar { grid-template-columns: 1fr 1fr; padding: 14px; } }
158 +.field { display: flex; flex-direction: column; gap: 5px; }
159 +.field label {
160 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
161 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); padding-left: 2px;
162 +}
163 +.field input, .field select {
164 + border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl);
165 + padding: 11px 12px; font-size: 15px; color: var(--ink); outline: none; font-family: inherit;
166 + transition: border-color 0.15s ease, box-shadow 0.15s ease; min-height: 44px;
167 + appearance: none; -webkit-appearance: none;
168 +}
169 +.field select {
170 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
171 + background-repeat: no-repeat; background-position: right 12px center; padding-right: 30px;
172 +}
173 +.field input:focus, .field select:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
174 +.btn {
175 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px;
176 + font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px;
177 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease;
178 + font-family: var(--font-display); letter-spacing: 0.01em;
179 +}
180 +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
181 +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); }
182 +.btn-primary:hover { background: var(--green-deep); }
183 +.btn-ghost { background: transparent; color: var(--ink); }
184 +.btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.2); }
185 +
186 +/* ================= Chips ================= */
187 +.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; }
188 +.chips::-webkit-scrollbar { display: none; }
189 +.chip {
190 + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink);
191 + border-radius: 999px; padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer;
192 + font-family: var(--font-display); white-space: nowrap; min-height: 40px;
193 + transition: all 0.13s ease;
194 +}
195 +.chip:hover { background: var(--lime-soft); transform: translateY(-1px); }
196 +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.2); }
197 +
198 +/* ================= Results ================= */
199 +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }
200 +.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; }
201 +.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; }
202 +.grid {
203 + display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
204 + gap: 22px; padding-bottom: 70px;
205 +}
206 +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 48px; } }
207 +
208 +.card {
209 + background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-card);
210 + overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat);
211 + transition: transform 0.16s ease, box-shadow 0.16s ease;
212 +}
213 +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
214 +.card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
215 +.card-img { position: relative; aspect-ratio: 16/10.5; background: repeating-linear-gradient(45deg, #eceae3 0 12px, #f3f1ea 12px 24px); overflow: hidden; }
216 +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
217 +.card:hover .card-img img { transform: scale(1.05); }
218 +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
219 +.badge {
220 + position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px;
221 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em;
222 + background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink);
223 +}
224 +.badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); }
225 +.badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; }
226 +.card-body { padding: 16px 18px 16px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
227 +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
228 +.card-price small { font-family: var(--font-mono); font-weight: 500; color: var(--ink-3); font-size: 11px; letter-spacing: 0.05em; }
229 +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); }
230 +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; font-family: var(--font-mono); }
231 +.card-meta .sep { width: 4px; height: 4px; background: var(--lime); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); }
232 +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1.5px dashed var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
233 +.source-tag {
234 + font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase;
235 + letter-spacing: 0.08em; color: var(--green-deep); background: var(--lime-soft);
236 + border: 1px solid var(--green); border-radius: 4px; padding: 3px 8px;
237 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;
238 +}
239 +.avail { font-size: 11.5px; color: var(--ink-2); font-weight: 500; text-align: right; }
240 +
241 +/* ================= Skeletons ================= */
242 +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
243 +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); }
244 +.skel .sk-img, .skel .sk-line {
245 + background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%);
246 + background-size: 800px 100%; animation: shimmer 1.4s infinite linear;
247 +}
248 +.skel .sk-img { aspect-ratio: 16/10.5; }
249 +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
250 +.skel .sk-line.short { width: 45%; }
251 +
252 +/* ================= Empty / error ================= */
253 +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
254 +.notice .big { font-size: 44px; margin-bottom: 10px; }
255 +.notice h2 { text-transform: uppercase; }
256 +
257 +/* ================= Detail page ================= */
258 +.detail { padding: 30px 0 90px; }
259 +.crumbs {
260 + font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase;
261 + color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
262 +}
263 +.crumbs a { border-bottom: 1.5px solid transparent; }
264 +.crumbs a:hover { color: var(--green); border-color: var(--green); }
265 +.detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
266 +@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } }
267 +
268 +.gallery { display: flex; flex-direction: column; gap: 10px; }
269 +.gallery-main {
270 + border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--ink);
271 + aspect-ratio: 16/10; background: #eceae3; cursor: zoom-in; box-shadow: var(--shadow-off-soft);
272 +}
273 +.gallery-main img { width: 100%; height: 100%; object-fit: cover; }
274 +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; }
275 +.thumbs button {
276 + border: 2px solid var(--line); border-radius: 8px; overflow: hidden; padding: 0; cursor: pointer;
277 + aspect-ratio: 4/3; background: #eceae3; transition: border-color 0.12s ease, transform 0.12s ease;
278 +}
279 +.thumbs button:hover { transform: translateY(-2px); }
280 +.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
281 +.thumbs img { width: 100%; height: 100%; object-fit: cover; }
282 +
283 +.panel {
284 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
285 + box-shadow: var(--shadow-off-soft); padding: 26px; position: sticky; top: 120px;
286 +}
287 +@media (max-width: 900px) { .panel { position: static; } }
288 +.panel .price { font-family: var(--font-display); font-size: 34px; font-weight: 700; letter-spacing: -0.03em; }
289 +.panel .price small { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); font-weight: 500; letter-spacing: 0.05em; }
290 +.panel h1 { font-size: 22px; margin: 8px 0 2px; }
291 +.panel .loc { color: var(--ink-2); font-size: 14px; }
292 +.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
293 +@media (max-width: 380px) { .kv { grid-template-columns: 1fr; } }
294 +.kv .cell { background: var(--surface-2); border: 1.5px solid var(--line); border-radius: var(--r-ctl); padding: 10px 13px; }
295 +.kv .cell .k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; }
296 +.kv .cell .v { font-weight: 700; font-size: 14.5px; margin-top: 2px; font-family: var(--font-display); }
297 +.klabel { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; margin-bottom: 8px; }
298 +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 20px; }
299 +.amenity {
300 + background: var(--lime-soft); color: var(--green-deep); font-size: 12px; font-weight: 600;
301 + border: 1px solid var(--green); border-radius: 999px; padding: 5px 12px;
302 +}
303 +.cta {
304 + display: block; text-align: center; background: var(--ink); color: var(--lime);
305 + font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl);
306 + padding: 15px; border: 1.5px solid var(--ink); min-height: 48px;
307 + box-shadow: 4px 4px 0 rgba(20,24,20,0.25); transition: all 0.14s ease;
308 +}
309 +.cta:hover { background: var(--lime); color: var(--ink); }
310 +.cta:active { transform: translate(2px, 2px); box-shadow: none; }
311 +.panel .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; }
312 +
313 +/* ================= Lightbox ================= */
314 +.lightbox {
315 + position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: 100;
316 + display: flex; align-items: center; justify-content: center; cursor: zoom-out;
317 + padding: max(16px, env(safe-area-inset-top)) 16px;
318 +}
319 +.lightbox img { max-width: 94vw; max-height: 90vh; border-radius: 6px; border: 2px solid var(--lime); }
320 +
321 +/* ================= Sources page ================= */
322 +.sources { padding: 44px 0 90px; }
323 +.sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; }
324 +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 700px; }
325 +.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); }
326 +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 720px; }
327 +.src-table th {
328 + text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
329 + letter-spacing: 0.12em; color: var(--ink-3); padding: 14px 18px;
330 + border-bottom: 1.5px solid var(--ink); background: var(--surface-2);
331 +}
332 +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
333 +.src-table tr:last-child td { border-bottom: none; }
334 +.src-table tr:hover td { background: var(--surface-2); }
335 +.src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
336 +.pill { display: inline-block; border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
337 +.pill.ok { background: var(--lime); color: var(--ink); border: 1px solid var(--ink); }
338 +.pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); }
339 +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; }
340 +
341 +/* ================= Mobile : feuille de filtres + FAB ================= */
342 +.sheet-head { display: none; }
343 +.sheet-apply { display: none; }
344 +.sheet-backdrop { display: none; }
345 +.fab { display: none; }
346 +
347 +@media (max-width: 640px) {
348 + /* En-tête compact */
349 + .header-inner { height: 56px; }
350 + .brand { font-size: 21px; }
351 + .nav a { padding: 8px 13px; font-size: 13.5px; }
352 + .ticker { font-size: 10.5px; padding: 6px 0; }
353 +
354 + /* Héro resserré + stats en rangée défilante */
355 + .hero h1 { font-size: clamp(30px, 9.4vw, 44px); }
356 + .hero p.lede { font-size: 15px; }
357 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
358 + .stat-row::-webkit-scrollbar { display: none; }
359 + .stat-chip { flex: 0 0 auto; white-space: nowrap; }
360 +
361 + /* La barre de filtres devient une feuille coulissante (bottom sheet) */
362 + .filterbar { display: none; }
363 + .filterbar.open {
364 + display: grid; grid-template-columns: 1fr; gap: 12px;
365 + position: fixed; left: 0; right: 0; bottom: 0; z-index: 95;
366 + margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0;
367 + max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
368 + padding: 16px 18px calc(18px + env(safe-area-inset-bottom));
369 + box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35);
370 + animation: sheet-up 0.22s ease;
371 + }
372 + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
373 + .filterbar.open .sheet-head {
374 + display: flex; justify-content: space-between; align-items: center;
375 + font-family: var(--font-display); font-weight: 700; font-size: 17px;
376 + text-transform: uppercase; letter-spacing: -0.01em;
377 + position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px;
378 + border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1;
379 + }
380 + .sheet-close {
381 + border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%;
382 + width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1;
383 + }
384 + .filterbar.open .sheet-apply { display: block; width: 100%; }
385 + .sheet-backdrop {
386 + display: block; position: fixed; inset: 0; z-index: 90;
387 + background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px);
388 + }
389 +
390 + /* Bouton flottant */
391 + .fab {
392 + display: flex; align-items: center; gap: 6px;
393 + position: fixed; left: 50%; transform: translateX(-50%);
394 + bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80;
395 + background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink);
396 + border-radius: 999px; padding: 13px 24px; font-family: var(--font-display);
397 + font-weight: 700; font-size: 15px; cursor: pointer;
398 + box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(20, 24, 20, 0.25);
399 + }
400 + .fab:active { transform: translateX(-50%) scale(0.97); }
401 +
402 + /* Fiche logement : vignettes en bande défilante + panneau non collant */
403 + .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; padding-bottom: 4px; }
404 + .thumbs::-webkit-scrollbar { display: none; }
405 + .thumbs button { flex: 0 0 96px; }
406 + .detail { padding-top: 20px; }
407 + .panel { padding: 20px; }
408 + .panel .price { font-size: 28px; }
409 + .results-head { margin-top: 20px; }
410 + .chips { margin-right: -16px; padding-right: 16px; }
411 + .notice { padding: 48px 16px; }
412 +}
413 +
414 +/* Anti-zoom iOS : les champs doivent faire >= 16px */
415 +@media (max-width: 900px) {
416 + .field input, .field select { font-size: 16px; }
417 +}
418 +
419 +/* ================= Footer ================= */
420 +.footer { background: var(--ink); color: rgba(245, 243, 238, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; }
421 +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 34px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 12px; }
422 +.footer .fbrand .ka { color: var(--lime); }
423 +.footer b { color: var(--paper); }
424 +.footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 720px; }
425 +.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(245,243,238,0.45); margin-top: 18px; }
added frontend/tsconfig.json +20 −0
@@ -0,0 +1,20 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2020",
4 + "useDefineForClassFields": true,
5 + "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 + "module": "ESNext",
7 + "skipLibCheck": true,
8 + "moduleResolution": "bundler",
9 + "allowImportingTsExtensions": true,
10 + "resolveJsonModule": true,
11 + "isolatedModules": true,
12 + "noEmit": true,
13 + "jsx": "react-jsx",
14 + "strict": true,
15 + "noUnusedLocals": false,
16 + "noUnusedParameters": false,
17 + "noFallthroughCasesInSwitch": true
18 + },
19 + "include": ["src"]
20 +}
added frontend/tsconfig.tsbuildinfo +1 −0
@@ -0,0 +1 @@
1 +{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/components/listingcard.tsx","./src/pages/home.tsx","./src/pages/listing.tsx","./src/pages/sources.tsx"],"version":"5.9.3"}
\ No newline at end of file
added frontend/vite.config.ts +15 −0
@@ -0,0 +1,15 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// vite.config.ts : configuration Vite (proxy API en dev, build vers dist/)
5 +// -----------------------------------------------------------------------------
6 +import { defineConfig } from "vite";
7 +import react from "@vitejs/plugin-react";
8 +
9 +export default defineConfig({
10 + plugins: [react()],
11 + server: {
12 + proxy: { "/api": "http://localhost:8080" },
13 + },
14 + build: { outDir: "dist" },
15 +});
added gestion-immobiliere-quebec.md +475 −0
@@ -0,0 +1,475 @@
1 +# Gestion immobilière résidentielle au Québec — Liste exhaustive par ville/région
2 +
3 +> **Critère de rétention** : chaque compagnie listée possède un **site web affichant des appartements/logements à louer** (page « à louer », « disponibilités », listings avec prix, portail de location). Les firmes faisant uniquement de la gestion de copropriétés (syndicats), du commercial, ou des services B2B aux propriétaires sans annonces publiques sont exclues (listées en annexe).
4 +>
5 +> **Méthode** : recherches web intensives menées ville par ville (8 volets régionaux en parallèle), ~300 requêtes de recherche au total, vérification individuelle de chaque site (extraction de la page de location). Portails d'annonces tiers (Centris, Realtor.ca, LogisQuébec, Louer.ca, Kangalou, Zumper, etc.) exclus — seuls les gestionnaires/propriétaires-exploitants sont retenus.
6 +>
7 +> **Date de compilation** : 6 août 2026 — **~240 compagnies uniques vérifiées** (255 entrées régionales avant déduplication).
8 +
9 +---
10 +
11 +## Sommaire
12 +
13 +1. [Montréal (île)](#1-montréal-île) — 44 compagnies
14 +2. [Québec + Lévis](#2-québec--lévis) — 39 compagnies
15 +3. [Laval + Rive-Nord](#3-laval--rive-nord-laurentides) — 35 compagnies
16 +4. [Longueuil + Rive-Sud / Montérégie](#4-longueuil--rive-sud--montérégie) — 30 compagnies
17 +5. [Gatineau / Outaouais](#5-gatineau--outaouais) — 29 compagnies
18 +6. [Sherbrooke / Estrie + Granby](#6-sherbrooke--estrie--granby) — 35 compagnies
19 +7. [Trois-Rivières / Mauricie / Centre-du-Québec + Joliette](#7-trois-rivières--mauricie--centre-du-québec--lanaudière) — 33 compagnies
20 +8. [Régions Est & Nord](#8-régions-est--nord) — 30 compagnies
21 +9. [Gestionnaires multi-régions](#9-gestionnaires-multi-régions)
22 +10. [Annexe — Compagnies exclues](#10-annexe--compagnies-exclues-vérifiées)
23 +
24 +---
25 +
26 +## 1. Montréal (île)
27 +
28 +| # | Nom | Site web | Secteurs desservis | Notes |
29 +|---|---|---|---|---|
30 +| 1 | Cogir Immobilier | https://www.cogir.net | Centre-ville, Ville-Marie, Westmount, Rosemont, Saint-Laurent, Griffintown + QC/ON | 23 000+ logements, 175 immeubles; unités, prix et demande en ligne |
31 +| 2 | CAPREIT | https://www.capreit.ca | Montréal, Saint-Laurent, Pointe-Claire, Dorval, Côte-Saint-Luc, West Island | REIT pancanadien; recherche par ville/quartier |
32 +| 3 | Minto Apartments | https://www.mintoapartments.com | Côte-des-Neiges (Rockhill, Le Hill-Park), centre-ville (Haddon Hall) | Suites, prix et disponibilités en ligne |
33 +| 4 | Akelius Residential | https://rent.akelius.com | Plateau-Mont-Royal, Côte-des-Neiges, NDG | ~240 appartements affichés avec loyers |
34 +| 5 | Hazelview Properties | https://www.hazelviewproperties.com/cities/montreal | Centre-ville (près McGill) | 20 000+ unités au Canada |
35 +| 6 | InterRent REIT / CLV Group | https://www.irent.com | Centre-ville, Côte-Saint-Luc, NDG, Verdun | REIT; communautés avec unités et prix |
36 +| 7 | Realstar Management | https://www.realstar.ca | Côte-Saint-Luc/Cavendish (Excelsior) | Gestionnaire national; plans et prix en ligne |
37 +| 8 | MetCap Living | https://www.metcap.com | Plusieurs quartiers de Montréal | 240+ communautés au Canada; studios dès ~1 219 $ |
38 +| 9 | Boardwalk REIT | https://www.bwalk.com | Île-des-Sœurs (Verdun), Ville Saint-Laurent | REIT, 33 000+ unités au Canada |
39 +| 10 | GWL Realty Advisors Residential | https://www.gwlraresidential.com | Centre-ville / Quartier des spectacles (Le Livmore I & II) | Institutionnel (Canada Vie), ~820 unités |
40 +| 11 | Cromwell Management | https://cromwellmgt.ca | Centre-ville, Côte-des-Neiges, Westmount | Propriétaire-gestionnaire montréalais historique |
41 +| 12 | Mondev | https://mondev.ca | Ville-Marie, Griffintown, LaSalle, Outremont, Parc-Ex, Ahuntsic, Plateau, Saint-Laurent | ~30 immeubles locatifs neufs avec bureaux de location |
42 +| 13 | Devimco Appartements | https://devimco.com/appartements | Centre-ville (Maestria, Alexander), Griffintown | Condos locatifs neufs, studio au penthouse |
43 +| 14 | Samcon | https://www.samcon.ca | Villeray, Ahuntsic-Cartierville, Plateau, Ville-Marie | Promoteur montréalais; condos locatifs dès ~1 480 $ |
44 +| 15 | Summit Property Management | https://www.summitmanagement.ca/apartments | Centre-ville et quartiers centraux | 3 000+ appartements (affilié Rimap) |
45 +| 16 | Gestion Lameer | https://www.lameer.ca/properties | Ahuntsic, Lachine, LaSalle, Saint-Laurent, CDN/NDG, Côte-Saint-Luc, Plateau | Gestion pour tiers, 2 300+ unités résidentielles |
46 +| 17 | MSI Gestion Immobilière | https://msimmobiliers.com | Ahuntsic, LaSalle, Lachine, CDN–NDG (+ Québec, Lévis, Laval, Longueuil) | 3 500 logements gérés, 25 ans |
47 +| 18 | Gestion Montréal | https://gestion-montreal.com | Centre-ville, Verdun, Griffintown, LaSalle (+ Longueuil/Repentigny) | Dizaines d'annonces (appartements, studios, condos) |
48 +| 19 | Groupe Denux | https://www.groupedenux.com | Centre-ville de Montréal | Tours résidentielles avec prix |
49 +| 20 | Lofts MTL | https://www.loftsmtl.com | Quartiers centraux | Lofts/appartements rénovés (1 200–2 300 $) |
50 +| 21 | Axia Appartements | https://www.axiaapartments.com | Lachine | Projet locatif neuf, location en ligne |
51 +| 22 | Trylon Apartments | https://trylonmontreal.com | Centre-ville (av. des Pins) | ~150 appartements meublés/semi-meublés |
52 +| 23 | Rester Management | https://www.rester.ca | Centre-ville, Milton-Parc (McGill) | Propriétés avec loyers et disponibilités |
53 +| 24 | The Rental Agents | https://therentalagents.com | Plateau, centre-ville et autres | Courtage locatif; studios à condos de luxe |
54 +| 25 | Rental Montreal (Cheff & Lanctôt) | https://rentalmontreal.com | Plateau, centre-ville, Vieux-Montréal, Outremont, Westmount | Centaines de propriétés meublées/non meublées |
55 +| 26 | Accès International | https://www.accesinternational.com | Centre-ville, Griffintown, Outremont, Vieux-Montréal | Location de luxe, meublé |
56 +| 27 | RAGQ | https://www.ragq.com | Centre-ville, Plateau, Rosemont, Villeray, Hochelaga, Sud-Ouest | Location meublée courte/longue durée + gestion |
57 +| 28 | Gestion iParc | https://iparc.ca | Hochelaga-Maisonneuve, centre-ville, Berri-UQAM | Depuis 2003; ~24 annonces actives |
58 +| 29 | Immomarketing (Immoappart) | https://immoappart.ca | Montréal + Rive-Sud/Rive-Nord + Québec | ~200 logements administrés, depuis 1984 |
59 +| 30 | CAP Gestion / CAP Immobilier | https://capgestion.ca | Montréal-Nord, Pointe-aux-Trembles (+ Laurentides/Lanaudière) | Fiches détaillées d'annonces |
60 +| 31 | Gestion Immobilière M.J | https://gestionmj.com | Ahuntsic-Cartierville / District Chabanel | Studios à 3 chambres avec prix |
61 +| 32 | Gestion Immobilière Cora (Immcora) | https://www.immcora.com | Ahuntsic/Cartierville | Petit propriétaire-gestionnaire |
62 +| 33 | Habitations Le Domaine | https://www.ledomaine.ca | Mercier–Hochelaga-Maisonneuve | OBNL, locatif abordable (2½ à 5½ dès 660 $) |
63 +| 34 | SHDM | https://www.shdm.org | Île de Montréal (plusieurs arrondissements) | Société paramunicipale; logements abordables |
64 +| 35 | Progim | https://progim.com | Grand Montréal (Montréal, Rive-Sud, Laval) | Gestionnaire depuis 1989 (siège Brossard) |
65 +| 36 | Rentalys | https://www.rentalys.ca | Montréal | Gestion + coopératives; portail de location |
66 +| 37 | Devloc | https://www.devloc.ca | Montréal et environs | ~87 logements à louer affichés |
67 +| 38 | Localys | https://www.gestionlocalys.com | Grand Montréal (bureau à Verdun) | 240+ unités sous gestion |
68 +| 39 | University Apartments (Werkliv) | https://universityapartments.ca | Centre-ville (McGill/Concordia) | Logement étudiant meublé |
69 +| 40 | T.R.A.M.S Property Management | https://tramsmgmt.com | Kirkland, Dollard-des-Ormeaux, Anjou | 40+ ans; page de propriétés résidentielles à louer |
70 +| 41 | Sotramont | https://sotramont.com | Pointe-Claire (Liveo), Bois-Franc | Condos locatifs neufs dès 1 970 $ |
71 +| 42 | Nid d'Amour | https://niddamour.ca | Plateau, Verdun/Île-des-Sœurs, Outremont, Rosemont | Meublé/semi-meublé + gestion |
72 +| 43 | Gestion Immobilière Groupe Théorêt | https://locationappartement.ca | Montréal (Pie-IX) + Laval, Terrebonne, Sainte-Thérèse, Charlemagne | Entreprise familiale depuis 1955 |
73 +| 44 | Bons Locataires | https://www.bonslocataires.com | Montréal, Verdun + Laval, Rive-Sud | Agence de location/gestion locative; visites virtuelles |
74 +
75 +---
76 +
77 +## 2. Québec + Lévis
78 +
79 +### Gestionnaires / propriétaires multi-immeubles
80 +
81 +| # | Nom | Site web | Secteurs desservis | Notes |
82 +|---|---|---|---|---|
83 +| 1 | Logisco | https://logisco.com | Ste-Foy–Sillery–Cap-Rouge, Val-Bélair, Loretteville, Vanier, St-Augustin + Lévis (St-Romuald, St-Nicolas) | 6 000+ appartements; disponibilités par projet |
84 +| 2 | Groupe Laberge | https://www.laberge.qc.ca | Ste-Foy, Beauport, Vanier, L'Ancienne-Lorette, Limoilou | 53 complexes locatifs; moteur de recherche d'unités |
85 +| 3 | Cogir Immobilier | https://www.cogir.net | Québec (Le Bacc Ste-Foy, Loretteville, Montserrat…) | 9+ immeubles résidentiels à Québec |
86 +| 4 | Immostar | https://immostar.ca / https://immostaralouer.ca | Ste-Foy/blvd Laurier, Cap-Rouge, Lévis, Wendake | Promoteur-gestionnaire; section « À louer » dédiée |
87 +| 5 | DMA (Douville, Moffet & Associés) / Locago | https://locago.ca | Ste-Foy, Vanier, Lebourgneuf, Charlesbourg, Beauport | Complexes IDOLA, Le WOW, Le Pivot, L'Aristocrate… |
88 +| 6 | Groupe Dallaire | https://groupedallaire.ca (https://faubourgdumoulin.ca) | Québec (Faubourg du Moulin — Alizé I-II) | Premier développeur de la région |
89 +| 7 | Trudel | https://trudel.ca | Vanier/Fleur de Lys, Charlesbourg | Condos locatifs; équipe de location sur place |
90 +| 8 | Immeubles Roussin | https://immeublesroussin.com | Sainte-Foy, Lévis, Beauport | 1 000+ portes (L'Aromate, L'Allié, La Vigie…) |
91 +| 9 | Immeubles Simard | https://immeublessimard.com | Québec (Grande Allée, centre-ville) | Section « À louer / Appartements » |
92 +| 10 | MSI Gestion immobilière | https://www.msimmobiliers.com | Québec (Limoilou, Beauport, Ste-Foy, Vanier) + Lévis | Gros gestionnaire tiers (25 ans) |
93 +| 11 | Gestipro | https://gestipro.info | Ste-Foy, Sillery, Beauport, Limoilou + Lévis | Page « À louer » complète; gère aussi pour tiers |
94 +| 12 | Logisma | https://logisma.ca | Ville de Québec | 740+ logements, studio au 5½ |
95 +| 13 | Lafrance & Mathieu | https://lafrance-mathieu.com | Beauport, Charlesbourg, Limoilou, St-Roch, Lévis, Val-Bélair | ~70 logements affichés par secteur |
96 +| 14 | Société immobilière Bélanger | https://sibelanger.com | Québec (St-Jean-Baptiste, Lebourgneuf) + Lévis | Meublé et non meublé; location 100 % en ligne |
97 +| 15 | SDG Immobilier | https://www.sdgimmobilier.ca | Charlesbourg, Ste-Foy, Limoilou, Charny | 500+ logements, 40+ immeubles |
98 +| 16 | SGIQ | https://gestionimmobilierequebec.com | Limoilou, St-Sauveur, Ste-Foy (Myrand), Sillery | ~150 logements affichés avec prix |
99 +| 17 | GIM Côté | https://gimcote.com | Ste-Foy, Limoilou/Charlesbourg, Lévis | Propriétaire depuis 1985; listings avec prix |
100 +| 18 | Logisbourg | https://www.logisbourg.com | Charlesbourg, Lebourgneuf, Beauport, Cité-Limoilou, Ste-Foy | 1 200+ appartements et condos locatifs |
101 +| 19 | Bribourg | https://bribourg.com | Charlesbourg, Vanier, Beauport | Familiale, 28+ immeubles |
102 +| 20 | Les Immeubles Paul-E. Richard | https://immeublesper.com | Limoilou, Charlesbourg, Beauport | 18 immeubles, 300+ unités |
103 +| 21 | La Corporation Headway | https://www.headwayltee.com | Vanier (Place Prévert), Ste-Foy, Charlesbourg, Lévis | 1 650 appartements dans 8 complexes |
104 +| 22 | Contraste Immobilier | https://contrasteimmobilier.ca | Beauport, Limoilou, Ste-Foy, Val-Bélair, Lévis | Disponibilités par quartier |
105 +| 23 | Appartements Urbains | https://www.appartementsurbains.ca | Ste-Foy, Montcalm, Limoilou, Loretteville, Lévis | Unités affichées par immeuble |
106 +| 24 | Picard Immobilier | https://picardimmobilier.net | Charny, Les Saules, Ste-Foy, Beauport, Limoilou, Vieux-Québec | ~700 appartements, 56 immeubles |
107 +| 25 | Groupe Immobilier Brochu | https://groupeimmobilierbrochu.com | Lévis (St-Romuald, Charny, St-Nicolas), Québec | Projets avec prix + La Sentinelle (144 unités, Vieux-Lévis) |
108 +| 26 | GParadis | https://gparadis.com | Montcalm, St-Sauveur, St-Roch, Limoilou, Vieux-Québec, Ste-Foy + Lévis | Immeubles rénovés; « À louer maintenant » |
109 +| 27 | Espaces Lokalia | https://www.espaceslokalia.ca | Lévis (Vivaxcès Le Nicolas) + 15 villes au Québec | Bras locatif lié à Habitations Trigone |
110 +| 28 | CAPREIT | https://www.capreit.ca | Québec (Le Samuel-Holland) | REIT national |
111 +| 29 | Immomarketing (Immoappart) | https://immoappart.ca | Lebourgneuf, Saint-Roch | Portail maison de ses propres immeubles |
112 +| 30 | Logis Presqu'Île | https://logispresquile.ca | Presqu'île (St-Nicolas/Lévis) | 3½–5½ rénovés |
113 +| 31 | Les Immeubles Brio | https://immeublesbrio.com | Val-Bélair, Neufchâtel, Loretteville | 49 unités, prix affichés |
114 +| 32 | OK Louer | https://www.oklouer.com | Limoilou, Charlesbourg/Beauport, Lac-Beauport | Location temporaire/meublée |
115 +
116 +### Opérateurs mono-projet (sites de location dédiés)
117 +
118 +| # | Nom | Site web | Secteur | Notes |
119 +|---|---|---|---|---|
120 +| 33 | Quartier les Éléments | https://www.quartierleselements.com | Lévis (St-Romuald) | 5 phases; bureau de location sur place |
121 +| 34 | HUMĀ Condos locatifs | https://humalevis.com | Lévis (St-Romuald) | 132+ appartements |
122 +| 35 | Le Clif | https://leclif.ca | Charlesbourg | Plans interactifs, prix |
123 +| 36 | Terra Condos locatifs | https://www.terracondolocatif.ca | Lévis (Desjardins) | Condos locatifs neufs |
124 +| 37 | Le Rivero | https://www.lerivero.ca | Québec (rivière St-Charles) | Plans d'unités |
125 +| 38 | Le Viridi | https://condosleviridi.ca | Québec | Condos locatifs |
126 +| 39 | La Klé | https://lakle.ca | Québec (Cité Verte) | Tour locative écoresponsable |
127 +
128 +*(Le GC à St-Romuald est loué via Lafrance & Mathieu, déjà listé.)*
129 +
130 +---
131 +
132 +## 3. Laval + Rive-Nord (Laurentides)
133 +
134 +| # | Nom | Site web | Villes/secteurs | Notes |
135 +|---|---|---|---|---|
136 +| 1 | Gestion Immobilière Groupe Théorêt | https://locationappartement.ca | Laval, Terrebonne, Sainte-Thérèse, Charlemagne, Montréal | Familiale depuis 1955 |
137 +| 2 | CAP Immobilier / CAP Gestion | https://capgestion.ca | Saint-Jérôme, Deux-Montagnes, Repentigny, Lavaltrie, Lachute, Laval | Bureau à Saint-Jérôme; prix et adresses |
138 +| 3 | Cogir Immobilier | https://www.cogir.net | Laval (Espace Montmorency, Castel, Unilia…), Mascouche, Repentigny, Terrebonne | 8+ immeubles à Laval |
139 +| 4 | Mostra (gestion Cogir) | https://mostracentropolis.ca / https://mostramascouche.ca | Laval (Centropolis, 550 unités), Mascouche | Prix et phases affichés |
140 +| 5 | MSI Gestion Immobilière | https://www.msimmobiliers.com | Laval, Repentigny (+ Montréal, Québec) | 3 500 logements gérés |
141 +| 6 | Plan A Immobilier | https://plan-a.ca | Laval (Soléa Fabreville, NOVA) + Montréal, Vaudreuil, Sherbrooke, Saguenay | Propriétaire-gestionnaire, 3 500 unités |
142 +| 7 | CAPREIT | https://www.capreit.ca | Laval (Domaine Bellerive, Market, Le Topaze…) | REIT pancanadien |
143 +| 8 | Espaces Lokalia | https://www.espaceslokalia.ca | Laval, Mascouche, Saint-Jérôme, Rive-Nord | Vivacité (50+) / Vivaxcès |
144 +| 9 | Groupe Mathieu | https://groupemathieu.com | Blainville, Sainte-Thérèse, Laval-Vimont, Terrebonne | Page « Projets à louer » |
145 +| 10 | Gestion Cinq Étoiles du Québec | https://appartementslocatifs.ca | Blainville (L'Émeraude, Le Cassiopée) | 3½–4½ dès 1 730 $ |
146 +| 11 | Immobilier 3C (Proplex) | https://gestionimmobiliereproplex.com | Saint-Jérôme (Bellefeuille), Blainville (Chambéry) | Pages « logement à louer » par ville |
147 +| 12 | GBD Location | https://www.gbdlocation.com | Deux-Montagnes, Saint-Eustache, Sainte-Marthe-sur-le-Lac, Saint-Joseph-du-Lac | Constructeur-locateur |
148 +| 13 | Déveloplex | https://www.developlex.ca | Deux-Montagnes, Saint-Jérôme, Saint-Eustache, Lachute, Sainte-Julienne | ~15 immeubles |
149 +| 14 | Habitations PHG | https://habitationsphg.com | Deux-Montagnes (OXIA, Carré de la Gare) | Carte des logements disponibles |
150 +| 15 | Vï Condos Locatifs (Danam Lacourse) | https://www.vicondoslocatifs.com | Terrebonne (Urbanova), Mascouche | Tout inclus; unités et prix |
151 +| 16 | Groupe Grilli Samuel / Citéa | https://grillisamuel.com / https://projetcitea.com | Terrebonne (Urbanova) | Inventaire locatif en ligne |
152 +| 17 | Symbio (Développement FTG / Claridge) | https://symbiohabitat.ca | Terrebonne (Lachenaie) | 219 unités, studio à 5½ |
153 +| 18 | Grandeur Natura | https://grandeurnatura.ca | Terrebonne (Urbanova) | 3½ à 5½ avec plans |
154 +| 19 | Cosoltec | https://www.cosoltec.com | Sainte-Thérèse (Evado), Blainville (Le Monroe), Saint-Jérôme (Natür) | Constructeur-gestionnaire |
155 +| 20 | Evado Appartements (Cosoltec) | https://www.evado.ca | Sainte-Thérèse (centre-ville) | ~112 unités |
156 +| 21 | Immomarketing (Immoappart) | https://immoappart.ca | Laval, Rive-Nord | Carte interactive |
157 +| 22 | Gestion Immobilière Summum | https://location.summumpm.com | Laval, Blainville, Terrebonne, Saint-Jérôme, Montréal, Longueuil | Portail d'annonces du gestionnaire |
158 +| 23 | Gestion Immobilière Royal | https://gestionroyal.com | Laval-des-Rapides, Montréal | Annonces 3½–5½ avec prix |
159 +| 24 | Groupe Inspire | https://groupeinspire.ca | Laval (Havre-des-Îles, Chomedey) + Montréal | Unités 3½-5½ dès 1 700 $ |
160 +| 25 | Viventi Laval | https://viventilaval.com | Laval (St-Vincent-de-Paul) | Appartements disponibles affichés |
161 +| 26 | OCartier (TDR Développements) | https://ocartier.ca | Laval (métro Cartier) | Studios à 3 ch. avec prix |
162 +| 27 | Marquise 8 | https://marquise8.com | Laval (centre-ville, Chomedey) | Condos locatifs tout inclus |
163 +| 28 | Collection Équinoxe | https://collectionequinoxe.com | Laval (Lévesque, Daniel-Johnson/Centropolis) | « Voir les condos à louer » |
164 +| 29 | Logements 50 ans et plus | https://logements50ans.ca | Blainville + Sainte-Adèle | Créneau 50+ (non RPA), dès 895 $ |
165 +| 30 | Groupe Rive-Nord / LBM Gestion Locative | https://grouperivenord.ca | Laval et Rive-Nord | Page « Condos à louer » |
166 +| 31 | Gestion Pro-Urbain | https://gestionpro-urbain.com | Charlemagne, Lanaudière, Grand Montréal | Unités, prix et disponibilités |
167 +| 32 | Entreprises Duquette Immobilier | https://www.duquette.ca | Saint-Eustache; base à Laval | Construction + gestion; listings par ville |
168 +| 33 | Vision Immo | https://visionimmo.com | Terrebonne (Urbanova) + Montréal/Lachine | Page « Logements » |
169 +| 34 | LeLogeur | https://lelogeur.com | Laval + Grand Montréal | Gestionnaire + moteur de recherche |
170 +| 35 | Bons Locataires | https://www.bonslocataires.com | Laval (Chomedey…), Grand Montréal | Agence de location/gestion locative |
171 +
172 +---
173 +
174 +## 4. Longueuil + Rive-Sud / Montérégie
175 +
176 +| # | Nom | Site web | Villes/secteurs | Notes |
177 +|---|---|---|---|---|
178 +| 1 | Devimco Immobilier (Appartements) | https://devimco.com/appartements | Longueuil (Ostral), Brossard (Éolia, Nobel, Lumeo) | Promoteur-exploitant; prix par projet |
179 +| 2 | Cogir Immobilier | https://www.cogir.net | Brossard (siège), Longueuil, Saint-Hubert, Boucherville, Sainte-Julie, Varennes, Beloeil, Saint-Lambert | Plus gros gestionnaire de la région |
180 +| 3 | Espaces Lokalia | https://www.espaceslokalia.ca | Brossard, Longueuil, Saint-Hubert, Chambly, Châteauguay, Sainte-Julie, Valleyfield, Beauharnois… | Bannières Vivacité (50+) et Vivaxcès |
181 +| 4 | CAPREIT | https://www.capreit.ca | Brossard (Vue du Fleuve), Longueuil (Le 2250, Jardins Longueuil) | FPI pancanadienne |
182 +| 5 | Boardwalk Communities | https://www.bwalk.com | Longueuil | Appartements et maisons en rangée |
183 +| 6 | Groupe Deschênes Pépin | https://groupedeschenespepin.com | Longueuil, Sainte-Julie, Brossard (Le Blüm) | Promoteur-gestionnaire |
184 +| 7 | MSI Gestion immobilière | https://msimmobiliers.com | Longueuil (+ Québec) | Moteur de recherche de logements |
185 +| 8 | Groupe Robin | https://grouperobin.com/appartements/ | Saint-Hyacinthe (Havre des Dominicains…) | Section « Appartements à louer » avec filtres |
186 +| 9 | Groupe Fluet | https://www.groupefluet.com | Saint-Hyacinthe (Manoir des Cascades…) | Logements préretraités/retraités autonomes |
187 +| 10 | Société Immobilière GBS (Domaine les Arpents Verts) | https://domainelesarpentsverts.com | Saint-Hyacinthe | ~199 portes |
188 +| 11 | Unéo Gestion Immobilière | https://uneo.ca | Saint-Jean-sur-Richelieu, Saint-Hyacinthe | Acquisition-construction-gestion |
189 +| 12 | EVOL Saint-Jean | https://evolstjean.com | Saint-Jean-sur-Richelieu | Complexe locatif neuf |
190 +| 13 | Les Constructions Saro | https://constructionsaro.com | Saint-Hubert, Longueuil, Rive-Sud | 3½ à 7½ haut de gamme, 50+ |
191 +| 14 | Logiluxx (Investissements Cleary) | https://logiluxx.com | Longueuil/Saint-Hubert, Boucherville | Locatif tout inclus LEED |
192 +| 15 | Citiluxx (Groupe Cleary) | https://citiluxx.com | Longueuil (Faubourg Cousineau) | Condos locatifs, prix affichés |
193 +| 16 | Port de Mer | https://portdemer.ca | Longueuil (Place Charles-Le Moyne, métro) | 389 unités |
194 +| 17 | Groupe Sovima (AlterEGO) | https://www.groupesovima.com/alterego | Brossard | Condos à louer sur le fleuve |
195 +| 18 | LSR GesDev | https://www.lsrgesdev.com | Candiac (Novia de la gare), Longueuil; siège Saint-Lambert | Développeur-gestionnaire |
196 +| 19 | EMD-Batimo / Lib | https://lelib.ca | Vaudreuil-Dorion (187 unités), Mont-Saint-Hilaire (132 unités) | Complexes locatifs 55+ |
197 +| 20 | Le Groupe Maurice | https://www.legroupemaurice.com | Brossard, Boucherville, Saint-Hyacinthe | RPA — appartements locatifs pour aînés |
198 +| 21 | Immomarketing (Immoappart) | https://immoappart.ca | Saint-Lambert + Rive-Sud | ~200 logements avec carte |
199 +| 22 | Groupe Lacombe (Le Sofia) | https://sofiavarennes.com | Varennes | 48 unités face au fleuve |
200 +| 23 | Terrasse Cent4 | https://terrassecent4.com | La Prairie | Condos locatifs neufs |
201 +| 24 | Aera Chambly | https://aerachambly.com | Chambly | Complexe locatif neuf |
202 +| 25 | Vallem | https://www.vallem.ca | Beloeil–Mont-Saint-Hilaire (Lumicité) | Condos locatifs |
203 +| 26 | Primo Immobilier | https://primoimmobilier.ca | Saint-Mathieu-de-Beloeil (Lumicité) + autres | Condos locatifs de luxe dès ~2 075 $ |
204 +| 27 | Groupe Lobato | https://groupelobato.com | Mont-Saint-Hilaire | Condos 4½–5½ près de la gare |
205 +| 28 | BonsLocataires.com | https://www.bonslocataires.com | Longueuil, La Prairie, Chambly, Saint-Jean-sur-Richelieu | Agence de location/gestion locative |
206 +| 29 | Groupe Anna | https://groupeanna.com/logements | Longueuil, Saint-Jean-sur-Richelieu, Mascouche | Meublé / location temporaire |
207 +| 30 | Plan A (VELA) | https://plan-a.ca | Vaudreuil-Dorion (VELA) | Unités 4½ affichées (~1 805 $) |
208 +
209 +---
210 +
211 +## 5. Gatineau / Outaouais
212 +
213 +| # | Nom | Site web | Secteurs | Notes |
214 +|---|---|---|---|---|
215 +| 1 | LR Gestion immobilière | https://lrgestion.com / https://www.lrlocation.ca | Gatineau, Hull, Aylmer, Buckingham, Masson-Angers, Chelsea | Gros portefeuille local; dizaines de logements avec prix |
216 +| 2 | ImmoTop | https://immotop.ca | Gatineau (Templeton, Aylmer) + Ottawa | Projets Oasis et Desrosiers |
217 +| 3 | Gestion Immobilière Metropolis | https://www.gestionmetropolis.ca | Gatineau / Outaouais | Depuis 1999, membre CORPIQ |
218 +| 4 | Elite Immobilier | https://eliteimmobilier.ca | Gatineau, Aylmer | Portail de listings (RentCafe) |
219 +| 5 | Garic | https://garic.ca/a-louer | Gatineau | Studios, 3½, 4½ avec prix |
220 +| 6 | Immeubles Desmarais | https://www.immeublesdesmarais.ca | Gatineau, Hull, Aylmer, Buckingham | Appartements, condos, maisons |
221 +| 7 | Groupe MCS5 | https://groupemcs5.com | Gatineau, Aylmer, Hull | Page de logements à louer |
222 +| 8 | Osgoode Properties | https://www.osgoodeproperties.com | Gatineau (Cité-des-Jeunes, Hull) + Ottawa | Studio à 3 chambres |
223 +| 9 | CLV Group (InterRent) | https://www.clvgroup.com | Aylmer + Ottawa | 1-2 ch. dès ~1 115 $ |
224 +| 10 | Brigil | https://www.brigil.com | Gatineau, Hull, Aylmer, Buckingham | Promoteur-gestionnaire majeur; unités et prix par immeuble |
225 +| 11 | Groupe Heafey | https://lewe2.ca / https://loggiasurleparc.com | Hull centre-ville (We 2/3), Gatineau (Loggia) | Sites locatifs par projet |
226 +| 12 | Groupe Katasa | https://katasa.ca | Gatineau/Hull (Le Chambord, District 50+…) | Développeur-gestionnaire basé à Gatineau |
227 +| 13 | Devcore | https://devcore.ca | Gatineau (Cinq23, Le Central) + Sept-Îles | Page « Rentals » + sites de projets |
228 +| 14 | Cogir Immobilier | https://www.cogir.net | Hull (Le Vibe, Bloome) | 2 immeubles locatifs à Gatineau |
229 +| 15 | Zibi (Dream) / Aalto Suites | https://zibi.ca / https://aaltosuites.ca | Hull (secteur riverain Zibi) | 1-2 ch. dès ~1 795 $ |
230 +| 16 | Realstar Management | https://www.realstar.ca | Hull (Heritage Lofts I & II) | 1-3 ch. affichées |
231 +| 17 | Centurion Property Associates | https://www.cpliving.com | Gatineau | Bachelor à 2 ch. dès ~1 499 $ |
232 +| 18 | Gestion Immobilière de l'Outaouais (GIO) | https://gioinc.ca | Gatineau (Plateau) | Propriétés à louer avec prix |
233 +| 19 | Urban Services | https://urbanservices.ca | Gatineau / Outaouais | Studios à maisons de ville; portail dédié |
234 +| 20 | Halfred | https://www.halfred.ca | Hull, Gatineau (+ Laurentides) | Nombreuses unités avec prix |
235 +| 21 | Gestion Quanta | https://gestionquanta.ca | Gatineau, Hull, Aylmer | Appartements avec prix |
236 +| 22 | Gestion Immobilière Pénates et Lares | https://gestionimmobilierepl.com | Gatineau, Hull, Aylmer, Chelsea, Cantley, Val-des-Monts, Thurso | Couverture la plus large des petites municipalités |
237 +| 23 | Gestion Souleymane | https://gestionsouleymane.com | Gatineau, Hull, Aylmer, Masson, Buckingham | Adresses et prix (1 300–1 950 $) |
238 +| 24 | MK Gestion Prestige | https://mkgprestige.com | Outaouais / Gatineau | Outil « Trouver un logement » (carte) |
239 +| 25 | Les Immeubles Michel Nazair | https://www.immnasr.com | Gatineau (Mont-Fleuri…) | Fiches détaillées |
240 +| 26 | Gestion Immobilière Wandji | https://wandji-immobilier.com | Gatineau, Aylmer, Plateau, Buckingham | Site JS — fiches vérifiées indirectement |
241 +| 27 | LOGIR | https://logir.ca | Gatineau / Outaouais | Logement abordable (998 unités); portail d'inscription — cas limite |
242 +| 28 | La Cité Gatineau (Adam Real Estate) | https://lacitegatineau.com | Gatineau (boul. du Carrefour) | Tour de 200+ condos locatifs |
243 +| 29 | Appartements Six80 | https://appartementsix80.com | Hull (680 St-Joseph) | Outil interactif; studio dès 1 155 $ |
244 +
245 +---
246 +
247 +## 6. Sherbrooke / Estrie + Granby
248 +
249 +| # | Nom | Site web | Villes/secteurs | Notes |
250 +|---|---|---|---|---|
251 +| 1 | Groupe Savouet | https://www.savouet.ca | Sherbrooke (tous secteurs), East Angus | Gestionnaire + propriétaire; listings avec prix |
252 +| 2 | Uptimo Gestion immobilière | https://www.uptimo.ca | Sherbrooke | Unités affichées avec prix |
253 +| 3 | Gestion immobilière Dynamic | https://lesgestionsdynamic.com | Sherbrooke (tous secteurs), Magog, Granby, East Angus | ~50+ logements par secteur |
254 +| 4 | Prestiplex / Agence de location Sherbrooke | https://prestiplex.com / https://agencedelocationsherbrooke.com | Sherbrooke, Magog, East Angus | Même groupe; annonces avec prix |
255 +| 5 | Les Entreprises Lachance | https://lachance.qc.ca | Sherbrooke, Magog, Orford, East Angus, Waterville | Constructeur-gestionnaire; inventaire détaillé |
256 +| 6 | À Louer Sherbrooke | https://www.alouersherbrooke.com | Sherbrooke (quartier Université) | 100+ logements, 5 immeubles |
257 +| 7 | Construction JPG | https://www.constructionjpg.com | Sherbrooke, East Angus | ~11 immeubles locatifs (3½ à 7½) |
258 +| 8 | Innoplex Immobilier | https://innopleximmobilier.com | Sherbrooke, Estrie | 6 immeubles présentés |
259 +| 9 | Les Gestions Bestlife | https://lesgestionsbestlife.com | Sherbrooke (Fleurimont, Rock Forest, Mont-Bellevue) | 500+ logements en gestion |
260 +| 10 | Gestion Matinale | https://gestionmatinale.com | Sherbrooke | ~42 appartements + maisons/chalets |
261 +| 11 | Logements Lauréat Richard | https://laureatrichard.com | Sherbrooke (quartier Nord) | 700+ appartements gérés |
262 +| 12 | Immeubles Alexcellence | https://immeublesalexcellence.com | Sherbrooke (UdeS, Mont-Bellevue) | Ciblé étudiants |
263 +| 13 | Les Immeubles D.B. | https://immeublesdb.com | Sherbrooke (Nord, Est, Centre, Ouest) | 12 immeubles / ~400 appartements |
264 +| 14 | Les Immeubles PM | https://www.lesimmeublespm.com | Sherbrooke | Familiale depuis 2004; prix affichés |
265 +| 15 | Immeubles L.P.L. | https://loyersherbrooke.com | Sherbrooke (6 secteurs) | 3½–5½ et duplex |
266 +| 16 | Gestion Immobilière du Verseau | https://gestionduverseau.com | Sherbrooke (6 secteurs) | Depuis 1989 |
267 +| 17 | Groupe Odyssée | https://www.groupeodyssee.com | Sherbrooke (Le Vivo, Quantum, Novium…) | Promoteur-gestionnaire |
268 +| 18 | Plan A Immobilier | https://plan-a.ca | Sherbrooke (Complexe du Parc) + autres régions | 3 500 unités au QC |
269 +| 19 | Groupe Custeau | https://www.groupecusteau.com / https://espacecentro.com | Sherbrooke (centre-ville, UdeS, Rock Forest) | 1 200+ unités résidentielles |
270 +| 20 | Le Montagnais | https://www.lemontagnais.com | Sherbrooke (6 campus) | Résidences étudiantes, studios à 5½ |
271 +| 21 | Realstar (Les Jardins Hauterive) | https://www.realstar.ca | Sherbrooke (rivière Magog) | Studios à 3 chambres |
272 +| 22 | Constructions Morin | https://constructionsmorin.com | Sherbrooke (St-Élie/Rock Forest) | Tout inclus; inventaire détaillé |
273 +| 23 | Les Immeubles GCI | https://lesimmeublesgci.com | Sherbrooke (Domaine Duvernay) | Condos locatifs de luxe |
274 +| 24 | Les Constructions Tèratèr | https://terater.ca | Windsor/Val-Joli, Sherbrooke, Orford, Cookshire-Eaton | Constructeur-locateur |
275 +| 25 | Gestion Floria | https://gestionfloria.ca | Sherbrooke, Magog (Mont-Orford), Coaticook | Condos neufs haut de gamme |
276 +| 26 | Groupe Immobilier Memphrémagog | https://groupeimmobiliermemphremagog.com | Magog (Côteaux du Marais), Sherbrooke | Locatif de prestige (55+) |
277 +| 27 | Gestion A. Godbout | https://appartementsgranby.com | Granby | 100+ logements |
278 +| 28 | Immeubles Claude Desroches | https://www.immeublesclaudedesroches.com | Granby, Cowansville | Liste avec prix (dès 690 $) |
279 +| 29 | Immeubles Georges Landry | https://immeublesgeorgeslandry.com | Granby | 7 immeubles 50+ |
280 +| 30 | Gestion Marc Breton | https://gestionmarcbreton.com | Granby, Bromont | Condos et maisons haut de gamme |
281 +| 31 | Les Habitations Rivard | https://www.habitationsrivard.com | Granby, Bromont, Shefford, Waterloo, Ange-Gardien | Condos et maisons de ville |
282 +| 32 | Gesteco | https://gesteco.ca | Granby + Montérégie/Estrie | Condos 4½-5½ avec fiches |
283 +| 33 | Espaces Lokalia | https://www.espaceslokalia.ca | Granby (Vivacité) | 50+ tout inclus |
284 +| 34 | Symphonie de Cowansville | https://symphoniedecowansville.ca | Cowansville | Immeuble neuf (2022) |
285 +| 35 | Appartements Oxford | https://appartoxford.com | Lennoxville (près Bishop's) | 2½ à 5½, 760–1 145 $ |
286 +
287 +---
288 +
289 +## 7. Trois-Rivières / Mauricie / Centre-du-Québec + Lanaudière
290 +
291 +### Trois-Rivières / Mauricie
292 +
293 +| # | Nom | Site web | Villes/secteurs | Notes |
294 +|---|---|---|---|---|
295 +| 1 | Gestions Thrace | https://www.gestionsthrace.com | Trois-Rivières, Cap-de-la-Madeleine, Bécancour | +2 500 logements en gestion; depuis 1991 |
296 +| 2 | IMMO 3R | https://immo3r.com | Trois-Rivières, Shawinigan, Bécancour, Nicolet, Sherbrooke | Immeubles neufs; fiches avec prix |
297 +| 3 | Société Nicolyn | https://societenicolyn.com | Trois-Rivières, Gentilly, Victoriaville | Section « Appartements à louer » |
298 +| 4 | Gestion immobilière Nord-Sud | https://www.gestionnordsud.com | Trois-Rivières | Page « Logements à louer » |
299 +| 5 | Hestia Groupe immobilier | https://www.gestionhestia.com | Trois-Rivières | 3½ 1 195 $, 4½ 1 395 $, 5½ 1 700 $ |
300 +| 6 | Gestion C3R | https://www.gestionc3r.com | Trois-Rivières, Shawinigan, Grand-Mère, St-Boniface | +450 appartements |
301 +| 7 | LogisPro Mauricie | https://logispro.ca | Trois-Rivières, Cap-de-la-Madeleine, Shawinigan, Bécancour, Nicolet | 695–1 355 $+; studios à 5½ |
302 +| 8 | Simplex Immobilier | https://simpleximmobilier.com | Trois-Rivières, Shawinigan (+ Montréal, Québec) | +600 logements en gestion |
303 +| 9 | Gestion Valco | https://gestionvalco.ca | Trois-Rivières, Shawinigan, Nicolet, Louiseville | Page « Logements à louer » |
304 +| 10 | Gestion Immobilière Groupe Théorêt | https://locationappartement.ca | Shawinigan (Grand-Mère) + Montréal, Laval | Annonces par adresse |
305 +| 11 | Logement Mauricie | http://logementmauricie.com | Trois-Rivières, Cap-de-la-Madeleine, Shawinigan | Regroupement de propriétaires; +200 logements |
306 +| 12 | Groupe Robin | https://grouperobin.com | Trois-Rivières (District 55) | Bureau de location à TR |
307 +| 13 | Location DI (Groupe Sphère DI) | https://locationdi.com | Trois-Rivières, Bécancour + Lanaudière | +750 logements avec photos 3D |
308 +| 14 | Groupe Immobilier Danelly | https://www.groupeimmobilierdanelly.com | Louiseville | 2 immeubles neufs, dès 880 $ |
309 +| 15 | Lambert Immobilier | https://lambertimmobilier.com | Louiseville, Mauricie | Page « Logements disponibles » |
310 +
311 +### Bécancour / Nicolet
312 +
313 +| # | Nom | Site web | Villes | Notes |
314 +|---|---|---|---|---|
315 +| 16 | Gestion May Bourg | https://maybourg.com | Bécancour | 4½ 1 350–1 525 $ |
316 +| 17 | Groupe Fournelle | https://www.groupefournelle.com | Bécancour (Ste-Angèle) | 4½ et 5½, condos et maisons de ville |
317 +| 18 | Gestions des immeubles Rou-Bec | https://rou-bec.com | Nicolet, Shawinigan | Fiches de logements |
318 +| 19 | Habitations Jutras | https://jutras.com | Nicolet, Drummondville, Sherbrooke | Constructeur-gestionnaire |
319 +
320 +### Drummondville
321 +
322 +| # | Nom | Site web | Villes | Notes |
323 +|---|---|---|---|---|
324 +| 20 | Gestion 1139 | https://gestion1139.com | Drummondville et environs | Portail avec filtres (2½ à 6½) |
325 +| 21 | Gestion TB | https://gestiontb.ca / https://appartementstb.ca | Drummondville | Prix par typologie |
326 +| 22 | Immogex | https://immogex.com | Drummondville (centre-ville) | +30 ans; section « À louer » |
327 +| 23 | Gestion Le Grand | https://gestionlegrand.ca | Drummondville | Location d'appartements et maisons |
328 +| 24 | Gestion ISR | https://gestion-isr.com | Drummondville, Centre-du-Québec | Portail de location dynamique |
329 +
330 +### Victoriaville / Bois-Francs
331 +
332 +| # | Nom | Site web | Villes | Notes |
333 +|---|---|---|---|---|
334 +| 25 | Gestion Immo-Logis | https://www.gestionimmologis.com | Victoriaville, Princeville, Plessisville | Logements par immeuble |
335 +| 26 | Cité Immobilier | https://citeimmobilier.com | Victoriaville | 224 logements + Le Quartz |
336 +| 27 | Groupe Jacques | https://www.groupejacques.com | Victoriaville | Appartements, condos, résidences aînés |
337 +
338 +### Joliette / Lanaudière
339 +
340 +| # | Nom | Site web | Villes | Notes |
341 +|---|---|---|---|---|
342 +| 28 | CAP Gestion (CAP Immobilier) | https://capgestion.ca | Joliette, Louiseville, Lanaudière, Laurentides | Pages « Logements à louer » par ville |
343 +| 29 | Gestion immobilière Forsa | https://www.gestionforsa.com | Joliette, St-Charles-Borromée, St-Gabriel | 2½ à 5½ avec prix |
344 +| 30 | Info-Logement | https://www.info-logement.com | Joliette, St-Charles-Borromée | Annonces complètes |
345 +| 31 | Accès Logis GB | https://acceslogisgb.com | Joliette, Crabtree, St-Ambroise-de-Kildare, Shawinigan | 3½–5½ (1 150–1 500 $) |
346 +| 32 | Les Habitations SF | https://www.leshabitationssf.com | St-Charles-Borromée/Joliette (+ Piedmont) | 1 250–1 800 $ |
347 +| 33 | Groupe Evoludev | https://location.groupeevoludev.com | Joliette, St-Charles-Borromée | Portail par projet (Le Saint-Charles) |
348 +
349 +---
350 +
351 +## 8. Régions Est & Nord
352 +
353 +### Saguenay–Lac-Saint-Jean
354 +
355 +| # | Nom | Site web | Villes | Notes |
356 +|---|---|---|---|---|
357 +| 1 | Gestion Immobilière Courtemanche | https://gestioncourtemanche.ca | Chicoutimi, Chicoutimi-Nord | Plusieurs centaines de logements; 30+ ans |
358 +| 2 | Gestion Immobilière 7/7 | https://www.gestion77.com | Chicoutimi | Meublé/équipé tout inclus |
359 +| 3 | Location Saguenay | https://locationsaguenay.com | Chicoutimi, Jonquière | 3 à 6 chambres + colocation (UQAC/Cégep) |
360 +| 4 | Gestion Immobilière MKAR | https://gestionimmobilieremkar.ca | Jonquière, Chicoutimi | 2½–4½, lofts, colocation |
361 +| 5 | Gestion Immeubles GT | https://www.gestionimmeublesgt.ca | Alma | 3½ à 5½ dans 4 secteurs |
362 +| 6 | Gestion Immobilière Maltais | https://www.gestionimmobilieremaltais.ca | Alma, Métabetchouan, Saint-Bruno | ~150 appartements; familiale 40 ans |
363 +| 7 | Les Appartements Novelo | https://appartementsnovelo.com | Saguenay (Jonquière/Arvida) | 2½ à 5½ rénovés |
364 +| 8 | Plan A | https://plan-a.ca | Jonquière (District Jonquière) + autres régions | 3 500 unités au QC |
365 +
366 +### Bas-Saint-Laurent / Gaspésie
367 +
368 +| # | Nom | Site web | Villes | Notes |
369 +|---|---|---|---|---|
370 +| 9 | Appartements Rimouski (Immeubles DTM) | https://www.appartementsrimouski.com | Rimouski, Le Bic | Fiches d'unités avec prix |
371 +| 10 | Immobilia Rimouski | https://immobiliarimouski.com | Rimouski (centre-ville) | Ex-Immeubles MT; app mobile locataires |
372 +| 11 | LogisPro Rimouski | https://logisprorimouski.com | Rimouski | 110 logements (2½–5½) |
373 +| 12 | Les Immeubles Bois | https://immeublesbois.ca | Rimouski | 320+ logements, 26 immeubles |
374 +| 13 | Beaulieu Groupe Immobilier | https://beaulieugroupeimmobilier.com | Rimouski | 24 logements neufs en location |
375 +| 14 | Beaulieu Gestion Immobilière | https://www.beaulieugestion.ca | Rivière-du-Loup, Saint-Antonin, Cacouna | 3½ à 5½ + maisons |
376 +| 15 | Groupe Medway | https://condosmedway.ca | Rivière-du-Loup + autres villes | Condos locatifs neufs |
377 +| 16 | STB Immobilier | https://stbimmobilier.com | Matane | 4 immeubles (4½ à 6½), dès 1 335 $ |
378 +| 17 | Habitat Honguedo | https://habitat-honguedo.com | Gaspé (centre-ville) | 3½ à 5½ (650–1 000 $) |
379 +
380 +### Côte-Nord
381 +
382 +| # | Nom | Site web | Villes | Notes |
383 +|---|---|---|---|---|
384 +| 18 | Multi-Logis | https://multi-logis.com | Sept-Îles, Port-Cartier (+ Lévis) | 500+ logements; moteur de recherche |
385 +| 19 | Devcore | https://devcore.ca | Sept-Îles (centre-ville) | 555 portes rénovées; siège à Gatineau |
386 +| 20 | Les Habitations Jeanne | https://www.leshabitationsjeanne.com | Baie-Comeau et environs | 200+ appartements |
387 +| 21 | Gestion Laprise | https://gestionlaprise.com | Baie-Comeau, Haute-Côte-Nord | 70 appartements (31 jours et +) |
388 +
389 +### Abitibi-Témiscamingue
390 +
391 +| # | Nom | Site web | Villes | Notes |
392 +|---|---|---|---|---|
393 +| 22 | Gestion Habitation | https://gestionhabitation.ca | Val-d'Or, Amos, Malartic | Logements par ville |
394 +| 23 | Société immobilière Tri-Logis | https://tri-logis.ca | Rouyn-Noranda | 600+ logements dont 75+ meublés |
395 +| 24 | Les Immeubles DCL | https://www.lesimmeublesdcl.com | Rouyn-Noranda | Page « Logements à louer » + abordable |
396 +| 25 | GIA-T (Gestion Immobilière Abitibi-Témiscamingue) | https://gia-t.com | Rouyn-Noranda | 2½ à 5½ avec prix; gère aussi pour tiers |
397 +
398 +### Chaudière-Appalaches (hors Lévis)
399 +
400 +| # | Nom | Site web | Villes | Notes |
401 +|---|---|---|---|---|
402 +| 26 | Atlas société immobilière | http://www.atlasimmo.ca | Saint-Georges (Beauce) | Pages « à louer » par immeuble |
403 +| 27 | Groupe Immobilier Fidélité | https://www.immofidelite.com | Thetford Mines | Moteur de recherche d'unités |
404 +| 28 | Le Quartier Plus | https://www.lequartierplus.com | Thetford Mines | Complexe 55+ (3 phases), dès 1 195 $ |
405 +| 29 | Place Florimay | https://placeflorimay.ca | Montmagny | 170 appartements neufs, 3½ dès 1 150 $ |
406 +| 30 | Les Logements Côte du Sud | https://www.logementscotesud.com | Montmagny (+ La Pocatière) | OBNL, 152 logements |
407 +
408 +---
409 +
410 +## 9. Gestionnaires multi-régions
411 +
412 +Ces compagnies apparaissent dans plusieurs sections ci-dessus — elles couvrent plusieurs régions du Québec :
413 +
414 +| Nom | Site web | Régions couvertes |
415 +|---|---|---|
416 +| **Cogir Immobilier** | https://www.cogir.net | Montréal, Québec, Laval, Rive-Nord, Rive-Sud, Gatineau (23 000+ logements) |
417 +| **CAPREIT** | https://www.capreit.ca | Montréal, Québec, Laval, Rive-Sud |
418 +| **Espaces Lokalia** | https://www.espaceslokalia.ca | Rive-Sud, Laval/Rive-Nord, Lévis, Granby, Valleyfield (~15 villes) |
419 +| **MSI Gestion Immobilière** | https://www.msimmobiliers.com | Montréal, Québec, Lévis, Laval, Repentigny, Longueuil (3 500 logements) |
420 +| **Plan A Immobilier** | https://plan-a.ca | Montréal, Laval, Vaudreuil, Sherbrooke, Saguenay (3 500 unités) |
421 +| **Immomarketing (Immoappart)** | https://immoappart.ca | Montréal, Rive-Sud, Rive-Nord, Québec (~200 logements) |
422 +| **CAP Gestion / CAP Immobilier** | https://capgestion.ca | Montréal, Laurentides, Lanaudière (Joliette, Saint-Jérôme, Repentigny…) |
423 +| **Gestion Immobilière Groupe Théorêt** | https://locationappartement.ca | Montréal, Laval, Terrebonne, Sainte-Thérèse, Shawinigan |
424 +| **Realstar Management** | https://www.realstar.ca | Montréal, Gatineau (Hull), Sherbrooke |
425 +| **Devimco Appartements** | https://devimco.com/appartements | Montréal, Brossard, Longueuil |
426 +| **Devcore** | https://devcore.ca | Gatineau, Sept-Îles |
427 +| **Groupe Robin** | https://grouperobin.com | Saint-Hyacinthe, Trois-Rivières |
428 +| **Boardwalk REIT** | https://www.bwalk.com | Montréal, Longueuil |
429 +| **Bons Locataires** | https://www.bonslocataires.com | Montréal, Laval, Rive-Sud |
430 +| **Gestion Immobilière Summum** | https://location.summumpm.com | Laval, Rive-Nord, Montréal, Longueuil |
431 +| **Habitations Jutras** | https://jutras.com | Nicolet, Drummondville, Sherbrooke |
432 +| **IMMO 3R** | https://immo3r.com | Mauricie, Centre-du-Québec, Sherbrooke |
433 +| **Multi-Logis** | https://multi-logis.com | Sept-Îles, Port-Cartier, Lévis |
434 +
435 +---
436 +
437 +## 10. Annexe — Compagnies exclues (vérifiées)
438 +
439 +Compagnies examinées mais **non retenues** parce que leur site n'affiche pas de logements à louer (gestion de copropriétés, commercial, services B2B, ou présence Facebook seulement) :
440 +
441 +### Gestion de copropriétés / commercial / B2B uniquement
442 +- **Cominar** (cominar.com) — FPI bureaux/commerces/industriel
443 +- **Groupe MACH** (groupemach.com) — commercial/bureaux
444 +- **Shiller Lavy** (shillerlavy.com) — vitrine commerciale
445 +- **Ipso Facto** (ipsofactoimmobilier.com) — firme d'investissement
446 +- **Kevlar** (groupekevlar.com) — promoteur; location via sites tiers
447 +- **Groupe Beaudoin** (groupebeaudoin.com) — mécanique du bâtiment/construction
448 +- **Lecsor** (gestionnaireimmobilier.ca) — gestion de copropriétés
449 +- **Park Laign**, **Solution Condo**, **Deluxco**, **Gestion Immoplex**, **Gestion immobilière Provision** — syndicats de copropriété
450 +- **Marsik Property Management**, **Humanova**, **Revimmo**, **LocHabitat**, **AGIMMO**, **Bénoline**, **Le Groupe Poirier**, **Gestion Lameer (volet Rive-Nord)** — services aux propriétaires sans annonces
451 +- **Multivesco** (multivesco.com) — commercial seulement
452 +- **Immex** (immex.ca) — locaux commerciaux seulement
453 +- **ALCO Gestion Immobilière** (alco-immobilier.com) — annonces sur Facebook seulement
454 +- **Gestion Immobilière Vailla** (gestionvailla.com) — annonces sur Facebook seulement
455 +- **Gestion Morin** (gestionmorin.com) — page « À louer » renvoie vers Facebook
456 +- **Gestion Fauvel** — terrains et commercial
457 +- **Groupe Logiloge** — GRT logement social, pas d'annonces
458 +- **Groupe Patrimoine** (groupepatrimoine.ca) — RPA sans locatif standard
459 +
460 +### Hors Québec (côté provincial) sur leur site
461 +- **District Realty**, **Paramount Properties**, **Homestead Land Holdings** — Ottawa/Ontario seulement
462 +- **Osgoode Properties** — retenu pour Gatineau seulement (rien à Montréal)
463 +- **Hazelview Properties** — retenu pour Montréal seulement (rien à Gatineau/Laval)
464 +
465 +### Pas de site web (Facebook / Pages Jaunes seulement)
466 +- Trans-Action Investissement (Abitibi), Immoclé (Thetford), Appartements Kimaji (Sept-Îles), Investivaco (Saguenay), Immeubles Belvédère (Chicoutimi), Gestion Immobilière Dubé et KRTB (Rivière-du-Loup), Les Habitations DB (Baie-Comeau), Gestion Immo HLR (Shawinigan), Gestion Capital RDR (Trois-Rivières), Gestion Logis-Vic (Victoriaville), Gestibec (Bécancour), Turcotte Location (Magog), Gestion immobilière SRS (Lévis), Immobilier Hébert & Fils (Saint-Hyacinthe), Gestion Immobilière Clé (Saint-Jean), Multimo (Longueuil), Rando Gestion / Marchand Immobilia / Tour Laval / Groupe Calex (Laval), Groupe Immobilier Van Horne (Montréal), Gestion Immobilière Ampleman (Québec)
467 +
468 +### Cas limites (mentionnés mais non comptés)
469 +- **Marcel Pinard Gestion Immobilière** (marcelpinardgestionimmobiliere.com) — Trois-Rivières/Sherbrooke/Granby; page « Locations disponibles » quasi vide (surtout services B2B)
470 +- **Agrasoy Realty** (agrasoyrealty.com) — page d'annonces en erreur 404 lors de la vérification
471 +- **Hillpark Capital** (hillpark.ca) — annonces uniquement sur portails tiers
472 +
473 +---
474 +
475 +*Document généré le 6 août 2026 par recherche web systématique (8 volets régionaux, vérification site par site). Les disponibilités et prix cités sont ceux observés au moment de la vérification et changent constamment — vérifier sur le site de chaque compagnie.*
added louka/__init__.py +6 −0
@@ -0,0 +1,6 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# -----------------------------------------------------------------------------
5 +"""Paquet louka : schéma standard, base de données, connecteurs, API."""
6 +__version__ = "1.0.0"
added louka/connectors/__init__.py +30 −0
@@ -0,0 +1,30 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/__init__.py : registre AUTO-DÉCOUVRANT des connecteurs
5 +# Tout module de ce paquet contenant une sous-classe de BaseConnector avec un
6 +# source_id non vide est enregistré automatiquement — aucun fichier partagé
7 +# à modifier pour ajouter un connecteur.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import importlib
12 +import pkgutil
13 +import sys
14 +
15 +from .base import BaseConnector
16 +
17 +CONNECTORS: dict[str, type[BaseConnector]] = {}
18 +
19 +for _mod in pkgutil.iter_modules(__path__):
20 + if _mod.name in ("base", "__init__"):
21 + continue
22 + try:
23 + module = importlib.import_module(f"{__name__}.{_mod.name}")
24 + except Exception as exc: # un connecteur cassé ne bloque pas les autres
25 + print(f"[lou-ka] connecteur '{_mod.name}' ignoré : {exc}", file=sys.stderr)
26 + continue
27 + for obj in vars(module).values():
28 + if (isinstance(obj, type) and issubclass(obj, BaseConnector)
29 + and obj is not BaseConnector and getattr(obj, "source_id", "")):
30 + CONNECTORS[obj.source_id] = obj
added louka/connectors/akelius.py +144 −0
@@ -0,0 +1,144 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/akelius.py : connecteur Akelius Residential (rent.akelius.com)
5 +# Page de recherche Canada rendue côté serveur (Angular SSR) : l'état
6 +# TransferState (<script id="akeliusWebsite-state">) contient le JSON complet
7 +# des unités canadiennes (adresse, loyer, pi², chambres, photos). On filtre
8 +# sur le Grand Montréal (Montréal, Westmount, Mont-Royal, Saint-Lambert,
9 +# Greenfield Park) — Toronto/Ottawa/Gatineau exclus.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import json
14 +import re
15 +
16 +from ..schema import Listing, strip_accents
17 +from .base import BaseConnector
18 +
19 +BASE = "https://rent.akelius.com"
20 +SEARCH_URL = f"{BASE}/en/search/canada/apartment/montreal"
21 +STATE_RE = re.compile(
22 + r'<script id="akeliusWebsite-state" type="application/json">(.*?)</script>',
23 + re.S)
24 +
25 +# Villes admissibles (Grand Montréal) -> (ville normalisée, secteur imposé)
26 +_GM_CITIES = {
27 + "montreal": ("Montréal", None), # secteur = borough du flux
28 + "westmount": ("Westmount", "Westmount"),
29 + "mont-royal": ("Mont-Royal", "Mont-Royal"),
30 + "saint-lambert": ("Saint-Lambert", "Saint-Lambert"),
31 + "greenfield park": ("Longueuil", "Greenfield Park"),
32 +}
33 +
34 +_BED_TYPES = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}
35 +
36 +
37 +class AkeliusConnector(BaseConnector):
38 + source_id = "akelius"
39 + request_delay = 0.6
40 + max_units = 400 # garde-fou
41 +
42 + def fetch(self) -> list[Listing]:
43 + html = self.get(SEARCH_URL).text
44 + m = STATE_RE.search(html)
45 + if not m:
46 + return []
47 + # TransferState Angular : les guillemets sont encodés « &q; »
48 + state = json.loads(m.group(1).replace("&q;", '"'))
49 +
50 + # La clé du cache API est un hash variable : on repère la liste d'unités
51 + units: list[dict] = []
52 + for val in state.values():
53 + body = val.get("b") if isinstance(val, dict) else None
54 + if (isinstance(body, list) and body
55 + and isinstance(body[0], dict) and "keyfacts" in body[0]):
56 + units = body
57 + break
58 +
59 + listings: list[Listing] = []
60 + for u in units[: self.max_units]:
61 + try:
62 + lst = self._unit_listing(u)
63 + if lst:
64 + listings.append(lst)
65 + except Exception:
66 + continue
67 + return listings
68 +
69 + def _unit_listing(self, u: dict) -> Listing | None:
70 + addr = u.get("address") or {}
71 + kf = u.get("keyfacts") or {}
72 + if (addr.get("province") or "").upper() != "QC":
73 + return None
74 + city_key = strip_accents((addr.get("city") or "").strip().lower())
75 + if city_key not in _GM_CITIES:
76 + return None
77 + city, forced_sector = _GM_CITIES[city_key]
78 + sector = forced_sector or (addr.get("borough") or "").strip()
79 +
80 + uid = str(u.get("id") or "").strip()
81 + if not uid:
82 + return None
83 + street = (addr.get("streetName") or "").strip()
84 + postal = (addr.get("postalCode") or "").strip()
85 +
86 + beds = kf.get("number-of-bedrooms")
87 + unit_type = _BED_TYPES.get(beds, "") if isinstance(beds, int) else ""
88 + apt_type = (kf.get("apartment-type") or "").strip()
89 + if not unit_type and apt_type == "loft":
90 + unit_type = "Loft"
91 +
92 + rent = kf.get("total-rent")
93 + price = float(rent) if isinstance(rent, (int, float)) and rent else None
94 +
95 + if kf.get("is-available-from-now-on"):
96 + availability = "Libre maintenant"
97 + else:
98 + availability = (kf.get("available-from-date") or "")[:10]
99 + if availability:
100 + availability = f"Disponible le {availability}"
101 +
102 + size = kf.get("unit-size")
103 + baths = kf.get("number-of-bathrooms")
104 + floor = kf.get("floor")
105 + desc_bits = []
106 + if apt_type:
107 + desc_bits.append(f"Type : {apt_type}")
108 + if size:
109 + desc_bits.append(f"{size} pi²")
110 + if baths:
111 + desc_bits.append(f"{baths} salle(s) de bain")
112 + if floor is not None:
113 + desc_bits.append(f"étage {floor}")
114 + if kf.get("free-rent"):
115 + desc_bits.append(f"promotion : {kf['free-rent']}")
116 +
117 + amenities = []
118 + if size:
119 + amenities.append(f"{size} pi²")
120 + if baths:
121 + amenities.append(f"{baths} sdb")
122 +
123 + images = [i for i in (u.get("imageUrls") or [])
124 + if isinstance(i, str) and i.startswith("http")][:30]
125 +
126 + title = f"{street} — unité {uid.split('-')[-1]}" if street else uid
127 + return Listing(
128 + source=self.source_id,
129 + external_id=uid,
130 + url=f"{BASE}/en/search/canada/detail/{uid}",
131 + title=title,
132 + address=", ".join(x for x in [street, city, postal] if x),
133 + sector=sector,
134 + city=city,
135 + unit_type=unit_type,
136 + price=price,
137 + price_label=f"{int(rent)} $/mois" if price else "",
138 + availability=availability,
139 + description=" — ".join(desc_bits)[:600],
140 + amenities=amenities,
141 + images=images,
142 + lat=addr.get("latitude"),
143 + lng=addr.get("longitude"),
144 + )
added louka/connectors/app_urbains.py +157 −0
@@ -0,0 +1,157 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/app_urbains.py : connecteur Appartements Urbains
5 +# (appartementsurbains.ca — Ste-Foy, Montcalm, Limoilou, Loretteville, Lévis)
6 +# Pages projets (Oxygen/WP) : tableau d'unités disponibles avec liens
7 +# « /unites/?_unite=NNN ». Une annonce par unité disponible.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://www.appartementsurbains.ca"
19 +LIST_URL = f"{BASE}/projets-residentiels/"
20 +
21 +KNOWN_SECTORS = ("Montcalm", "Limoilou", "Lévis", "Ste-Foy", "Loretteville",
22 + "Sainte-Foy", "Saint-Roch", "Lebourgneuf")
23 +
24 +ADDR_RE = re.compile(
25 + r"\d{1,5}[^,<>|]{2,60},\s*(?:Lévis|Québec)\s*,?\s*(?:QC|Qu[ée]bec)?"
26 + r"[^.<>|]{0,15}[A-Z]\d[A-Z]\s?\d[A-Z]\d"
27 +)
28 +IMG_RE = re.compile(r'https?://[^"\s\\)]+/wp-content/uploads/[^"\s\\)]+'
29 + r"\.(?:jpe?g|png|webp)", re.I)
30 +BAD_IMG_RE = re.compile(r"favicon|logo|icon|cropped-|social|plugins|hqdefault|"
31 + r"pdf|texture", re.I)
32 +TYPE_RE = re.compile(r"\d\s*½(?:\s*\+\s*\w+)?|\d\s*1/2|Studio|Loft", re.I)
33 +
34 +
35 +class AppartementsUrbainsConnector(BaseConnector):
36 + source_id = "app_urbains"
37 + request_delay = 0.6
38 + max_projects = 20 # garde-fou de crawl
39 +
40 + def _discover_projects(self) -> dict[str, dict]:
41 + """Menu de navigation : « Nom du projet » + « Quartier »."""
42 + projects: dict[str, dict] = {}
43 + html = self.get(LIST_URL).text
44 + soup = BeautifulSoup(html, "html.parser")
45 + for a in soup.select(f'a[href^="{BASE}/"], a[href^="/"]'):
46 + href = a.get("href") or ""
47 + if href.startswith("/"):
48 + href = BASE + href
49 + href = href.split("?")[0].rstrip("/")
50 + slug = href.replace(BASE, "").strip("/")
51 + # les pages projets sont à la racine (un seul segment, pas de section connue)
52 + if (not slug or "/" in slug or slug in
53 + ("projets-residentiels", "nouveaux-projets", "quartiers",
54 + "a-propos", "actualites", "carrieres", "nous-joindre",
55 + "html-sitemap", "projets-commerciaux")):
56 + continue
57 + text = re.sub(r"\s+", " ", a.get_text(" ", strip=True))
58 + sector = ""
59 + for s in KNOWN_SECTORS:
60 + if re.search(rf"{re.escape(s)}\s*$", text):
61 + sector = s
62 + break
63 + if not sector: # lien sans étiquette de quartier -> ignorer
64 + continue
65 + name = text[: -len(sector)].strip()
66 + if slug not in projects and name:
67 + projects[slug] = {"name": name, "sector": sector}
68 + return projects
69 +
70 + def fetch(self) -> list[Listing]:
71 + listings: list[Listing] = []
72 + try:
73 + projects = self._discover_projects()
74 + except Exception:
75 + return listings
76 +
77 + for i, (slug, meta) in enumerate(projects.items()):
78 + if i >= self.max_projects:
79 + break
80 + url = f"{BASE}/{slug}/"
81 + try:
82 + html = self.get(url).text
83 + except Exception:
84 + continue
85 + soup = BeautifulSoup(html, "html.parser")
86 + text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
87 +
88 + m = ADDR_RE.search(text)
89 + address = m.group(0).strip() if m else ""
90 + sector = meta["sector"]
91 + city = infer_city(sector)
92 + if "Lévis" in address:
93 + city = "Lévis"
94 +
95 + # Images du projet (souvent servies depuis le domaine du projet)
96 + images = [u for u in dict.fromkeys(
97 + IMG_RE.findall(html) +
98 + re.findall(r'data-lazy-src="([^"]+\.(?:jpe?g|png|webp))"', html, re.I))
99 + if not BAD_IMG_RE.search(u)]
100 + images = [u if u.startswith("http") else BASE + u for u in images][:25]
101 +
102 + og = soup.find("meta", attrs={"property": "og:description"})
103 + desc = og["content"].strip()[:600] if og and og.get("content") else ""
104 +
105 + # Commodités (blocs « avantages »)
106 + amenities = list(dict.fromkeys(
107 + s.get_text(strip=True)
108 + for s in soup.select("div.ct-div-block.c-center span.ct-span")
109 + if s.get_text(strip=True)))[:15]
110 +
111 + # Tableau des unités disponibles
112 + seen: set[str] = set()
113 + for a in soup.select('a[href*="_unite="]'):
114 + try:
115 + num = a.get_text(strip=True)
116 + if not num or num in seen:
117 + continue
118 + seen.add(num)
119 + row = a
120 + row_text = ""
121 + for _ in range(4):
122 + row = row.parent
123 + if row is None:
124 + break
125 + cls = " ".join(row.get("class") or [])
126 + if "c-full-width" in cls and "flex-row" in cls:
127 + row_text = re.sub(r"\s+", " ",
128 + row.get_text(" ", strip=True))
129 + break
130 + tm = TYPE_RE.search(row_text)
131 + unit_type = tm.group(0) if tm else ""
132 + pm = re.search(r"Prix\s*([\d\s]+\$)", row_text)
133 + price_label = pm.group(1).strip() if pm else ""
134 + am = re.search(
135 + r"Disponibilité\s+(.*?)(?:\s+Superficie|\s+Prix|$)",
136 + row_text)
137 + availability = am.group(1).strip() if am else ""
138 + listings.append(Listing(
139 + source=self.source_id,
140 + external_id=f"{slug}-{num}",
141 + url=url,
142 + title=f"{meta['name']} — unité {num}",
143 + address=address,
144 + sector=sector,
145 + city=city,
146 + unit_type=normalize_unit_type(unit_type),
147 + price=parse_price(price_label),
148 + price_label=price_label,
149 + availability=availability,
150 + description=desc,
151 + amenities=amenities,
152 + images=images,
153 + ))
154 + except Exception:
155 + continue
156 +
157 + return listings
added louka/connectors/axia.py +118 −0
@@ -0,0 +1,118 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/axia.py : connecteur AXIA Appartements (axiaappartements.com)
5 +# Complexe locatif neuf à Lachine (Montréal), géré par Pur Immobilia.
6 +# Site vitrine WordPress/WPBakery une page : deux configurations
7 +# (2 chambres 4½ et 3 chambres 5½) avec superficie et prix
8 +# « à partir de » — 1 annonce par configuration.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Listing, parse_price
17 +from .base import BaseConnector
18 +
19 +BASE = "https://www.axiaappartements.com"
20 +
21 +ADDRESS = "200, boul. Saint-Joseph, Lachine (Québec) H8S 2L3"
22 +
23 +IMG_RE = re.compile(
24 + r"https://www\.axiaappartements\.com/wp-content/uploads/"
25 + r"[^\"\s\\]+?\.(?:jpg|jpeg|webp)", re.I)
26 +
27 +
28 +class AxiaConnector(BaseConnector):
29 + source_id = "axia"
30 + request_delay = 0.6
31 +
32 + def fetch(self) -> list[Listing]:
33 + listings: list[Listing] = []
34 + try:
35 + html = self.get(BASE + "/").text
36 + except Exception:
37 + return listings
38 + soup = BeautifulSoup(html, "html.parser")
39 +
40 + # Photos du complexe (icônes/logos exclus)
41 + images = [u for u in dict.fromkeys(IMG_RE.findall(html))
42 + if not re.search(r"icone|logo|favicon|fleche|-\d+x\d+\.",
43 + u, re.I)][:25]
44 +
45 + # Description (meta) + commodités (libellés d'icônes)
46 + desc = ""
47 + og = soup.find("meta", attrs={"property": "og:description"}) or \
48 + soup.find("meta", attrs={"name": "description"})
49 + if og and og.get("content"):
50 + desc = og["content"].strip()[:600]
51 + amenities = []
52 + for el in soup.select(".icon-label"):
53 + txt = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()
54 + if txt and txt not in amenities:
55 + amenities.append(txt)
56 +
57 + # Promotion / disponibilité
58 + availability = "Disponible"
59 + m = re.search(r"(\d\s*MOIS OFFERTS[^<*]{0,80})", html, re.I)
60 + if m:
61 + availability = "Disponible — promotion " + \
62 + re.sub(r"\s+", " ", m.group(1)).strip()
63 +
64 + # Cartes de prix : « 2 chambres (4 ½) », superficie, « À partir de … $ »
65 + for card in soup.select(".price-card"):
66 + try:
67 + lst = self._parse_card(card, images, desc, amenities,
68 + availability)
69 + except Exception:
70 + continue
71 + if lst:
72 + listings.append(lst)
73 + return listings
74 +
75 + def _parse_card(self, card, images, desc, amenities,
76 + availability) -> Listing | None:
77 + head = card.get_text(" ", strip=True) # « 2 chambres (4 ½) »
78 + m = re.search(r"(\d)\s*chambres?", head, re.I)
79 + mtype = re.search(r"\(\s*(\d)\s*(?:½|1/2)\s*\)", head)
80 + if not (m or mtype):
81 + return None
82 + if mtype:
83 + unit_type = f"{mtype.group(1)}½"
84 + else:
85 + n = int(m.group(1))
86 + unit_type = {0: "Studio", 1: "3½", 2: "4½", 3: "5½"}.get(
87 + n, f"{n} chambres")
88 +
89 + # superficie et prix dans les blocs suivants du même conteneur
90 + container = card.parent
91 + sqft = price_label = ""
92 + if container:
93 + sq = container.select_one(".superficie")
94 + if sq:
95 + sqft = sq.get_text(" ", strip=True)
96 + mt = container.select_one(".montant")
97 + if mt:
98 + price_label = mt.get_text(" ", strip=True)
99 +
100 + price = parse_price(price_label)
101 + n_ch = m.group(1) if m else {"4½": "2", "5½": "3"}.get(unit_type, "")
102 +
103 + return Listing(
104 + source=self.source_id,
105 + external_id=f"axia-lachine-{unit_type.replace('½', '.5')}",
106 + url=BASE + "/#appartements",
107 + title=f"AXIA Appartements — {n_ch} chambres ({unit_type})",
108 + address=ADDRESS,
109 + sector="Lachine",
110 + city="Montréal",
111 + unit_type=unit_type,
112 + price=price,
113 + price_label=re.sub(r"\s+", " ", price_label).strip(),
114 + availability=availability,
115 + description=(f"{sqft}. {desc}" if sqft else desc)[:600],
116 + amenities=amenities[:15],
117 + images=images,
118 + )
added louka/connectors/base.py +72 −0
@@ -0,0 +1,72 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/base.py : classe de base des connecteurs + backends de fetch
5 +# (requests direct, ou Firecrawl pour les sites JavaScript)
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import os
10 +import time
11 +
12 +import requests
13 +
14 +from ..schema import Listing
15 +
16 +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
17 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
18 + "LouKaBot/1.0 (+contact@spboucher.ai)")
19 +
20 +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
21 +
22 +
23 +class BaseConnector:
24 + """Un connecteur = un adaptateur propre à un site d'agence.
25 +
26 + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne
27 + la liste complète des annonces actuellement affichées sur le site.
28 + Le pipeline (ingest.py) s'occupe du diff avec la base de données.
29 + """
30 +
31 + source_id: str = ""
32 + request_delay: float = 0.6 # politesse entre requêtes
33 + timeout: int = 30
34 +
35 + def __init__(self) -> None:
36 + self.session = requests.Session()
37 + self.session.headers["User-Agent"] = USER_AGENT
38 + self._last_request = 0.0
39 +
40 + # -- backends -------------------------------------------------------------
41 + def get(self, url: str, **kw) -> requests.Response:
42 + """GET direct avec throttling poli."""
43 + wait = self.request_delay - (time.time() - self._last_request)
44 + if wait > 0:
45 + time.sleep(wait)
46 + resp = self.session.get(url, timeout=self.timeout, **kw)
47 + self._last_request = time.time()
48 + resp.raise_for_status()
49 + return resp
50 +
51 + def get_rendered(self, url: str) -> str:
52 + """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.
53 +
54 + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).
55 + À utiliser pour les sites SPA (Logisco, Locago, etc.).
56 + """
57 + key = os.environ.get("FIRECRAWL_API_KEY")
58 + if not key:
59 + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
60 + resp = requests.post(
61 + FIRECRAWL_API,
62 + json={"url": url, "formats": ["html"]},
63 + headers={"Authorization": f"Bearer {key}"},
64 + timeout=90,
65 + )
66 + resp.raise_for_status()
67 + data = resp.json()
68 + return (data.get("data") or {}).get("html", "")
69 +
70 + # -- contrat --------------------------------------------------------------
71 + def fetch(self) -> list[Listing]:
72 + raise NotImplementedError
added louka/connectors/beaudoin.py +190 −0
@@ -0,0 +1,190 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/beaudoin.py : connecteur Société Beaudoin immobilier
5 +# (beaudoinimmobilier.ca — Longueuil, Boucherville, Montréal :
6 +# Anjou, Lachine, Saint-Léonard). Thème WordPress Houzez : les logements
7 +# sont des posts « property » exposés par l'API REST
8 +# (/wp-json/wp/v2/properties) avec prix, adresse géocodée, lat/lng,
9 +# taxonomies (type d'unité, ville, disponibilité, commodités) et
10 +# galerie d'images (IDs de médias résolus via /wp-json/wp/v2/media).
11 +# Le site couvre d'autres régions : seul le Grand Montréal est conservé.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import html as htmllib
16 +import re
17 +
18 +from ..schema import Listing, normalize_unit_type, strip_accents
19 +from .base import BaseConnector
20 +
21 +BASE = "https://www.beaudoinimmobilier.ca"
22 +API = f"{BASE}/index.php/wp-json/wp/v2"
23 +
24 +# Villes admissibles (Grand Montréal) — comparées sans accents, en minuscules.
25 +_ALLOWED_CITIES = (
26 + "montreal", "longueuil", "boucherville", "brossard", "saint-lambert",
27 + "laval", "lachine", "anjou", "saint-leonard", "saint-hubert",
28 +)
29 +
30 +
31 +def _clean(txt: str) -> str:
32 + txt = re.sub(r"<[^>]+>", " ", txt or "")
33 + return re.sub(r"\s+", " ", htmllib.unescape(txt)).strip()
34 +
35 +
36 +class BeaudoinConnector(BaseConnector):
37 + source_id = "beaudoin"
38 + request_delay = 0.5
39 + max_pages = 5 # garde-fou (5 x 100 propriétés)
40 + max_media_ids = 30 # images max par annonce
41 +
42 + # -- helpers REST ----------------------------------------------------------
43 + def _terms(self, taxonomy: str) -> dict[int, str]:
44 + try:
45 + data = self.get(f"{API}/{taxonomy}?per_page=100").json()
46 + return {t["id"]: htmllib.unescape(t["name"]) for t in data}
47 + except Exception:
48 + return {}
49 +
50 + def _media_urls(self, ids: list[str]) -> list[str]:
51 + ids = [i for i in ids if str(i).isdigit()][: self.max_media_ids]
52 + if not ids:
53 + return []
54 + try:
55 + url = (f"{API}/media?include={','.join(map(str, ids))}"
56 + f"&per_page=100&_fields=id,source_url")
57 + data = self.get(url).json()
58 + by_id = {str(m["id"]): m.get("source_url", "") for m in data}
59 + return [by_id[str(i)] for i in ids if by_id.get(str(i))]
60 + except Exception:
61 + return []
62 +
63 + @staticmethod
64 + def _split_city(name: str) -> tuple[str, str]:
65 + """'Montréal Anjou' -> ('Montréal', 'Anjou') ; 'Longueuil' -> (ville, '')."""
66 + name = name.strip()
67 + if strip_accents(name.lower()).startswith("montreal"):
68 + sector = name[len("Montréal"):].strip(" -–")
69 + return "Montréal", sector
70 + return name, ""
71 +
72 + @staticmethod
73 + def _city_allowed(name: str) -> bool:
74 + key = strip_accents(name.lower()).replace(" ", "-")
75 + return any(tok in key for tok in _ALLOWED_CITIES)
76 +
77 + # -- fetch ------------------------------------------------------------------
78 + def fetch(self) -> list[Listing]:
79 + types = self._terms("property_type")
80 + statuses = self._terms("property_status")
81 + cities = self._terms("property_city")
82 + features = self._terms("property_feature")
83 +
84 + props: list[dict] = []
85 + for page in range(1, self.max_pages + 1):
86 + try:
87 + batch = self.get(
88 + f"{API}/properties?per_page=100&page={page}").json()
89 + except Exception:
90 + break
91 + if not isinstance(batch, list) or not batch:
92 + break
93 + props.extend(batch)
94 + if len(batch) < 100:
95 + break
96 +
97 + listings: list[Listing] = []
98 + for p in props:
99 + try:
100 + lst = self._parse_property(p, types, statuses, cities, features)
101 + except Exception:
102 + continue
103 + if lst:
104 + listings.append(lst)
105 + return listings
106 +
107 + def _parse_property(self, p: dict, types: dict, statuses: dict,
108 + cities: dict, features: dict) -> Listing | None:
109 + meta = p.get("property_meta") or {}
110 +
111 + def m1(key: str) -> str:
112 + v = meta.get(key) or []
113 + return str(v[0]).strip() if v and v[0] is not None else ""
114 +
115 + # Ville / secteur (taxonomie property_city, ex. « Montréal Anjou »)
116 + city_name = next((cities[i] for i in (p.get("property_city") or [])
117 + if i in cities), "")
118 + if not city_name or not self._city_allowed(city_name):
119 + return None # hors Grand Montréal
120 + city, sector = self._split_city(city_name)
121 +
122 + title = _clean((p.get("title") or {}).get("rendered") or "")
123 +
124 + # Adresse géocodée : « 7340, Avenue Guy, Anjou, Montréal, ... Canada »
125 + map_addr = m1("fave_property_map_address")
126 + address = ", ".join(s.strip() for s in map_addr.split(",")[:2]) if map_addr else ""
127 + address = re.sub(r"\s+", " ", address).strip()
128 +
129 + # Type d'unité (taxonomie « 3 1/2 », « 4 1/2 Penthouse », ...)
130 + type_name = next((types[i] for i in (p.get("property_type") or [])
131 + if i in types), "")
132 + unit_type = normalize_unit_type(type_name)
133 +
134 + # Prix mensuel (champ Houzez)
135 + price = None
136 + price_label = ""
137 + raw_price = m1("fave_property_price")
138 + if raw_price:
139 + try:
140 + price = float(re.sub(r"[^\d.]", "", raw_price))
141 + except ValueError:
142 + price = None
143 + postfix = m1("fave_property_price_postfix") or "mois"
144 + price_label = f"{raw_price}$ / {postfix}"
145 + if price is not None and not (100 <= price <= 20000):
146 + price = None
147 +
148 + # Disponibilité (taxonomie property_status, « 08 - Disponible pour août »)
149 + avail = next((statuses[i] for i in (p.get("property_status") or [])
150 + if i in statuses), "")
151 + availability = re.sub(r"^\d+\s*-\s*", "", avail)
152 +
153 + amenities = [features[i] for i in (p.get("property_feature") or [])
154 + if i in features]
155 +
156 + # Coordonnées
157 + lat = lng = None
158 + try:
159 + lat = float(m1("houzez_geolocation_lat"))
160 + lng = float(m1("houzez_geolocation_long"))
161 + except (ValueError, TypeError):
162 + lat = lng = None
163 +
164 + # Galerie d'images (IDs de médias -> URLs)
165 + images = self._media_urls(meta.get("fave_property_images") or [])
166 + if not images:
167 + thumb = m1("_thumbnail_id")
168 + if thumb:
169 + images = self._media_urls([thumb])
170 +
171 + description = _clean((p.get("content") or {}).get("rendered") or "")[:600]
172 +
173 + return Listing(
174 + source=self.source_id,
175 + external_id=str(p.get("id")),
176 + url=p.get("link") or "",
177 + title=title or f"Logement {p.get('id')}",
178 + address=address,
179 + sector=sector,
180 + city=city,
181 + unit_type=unit_type,
182 + price=price,
183 + price_label=price_label,
184 + availability=availability,
185 + description=description,
186 + amenities=amenities,
187 + images=images,
188 + lat=lat,
189 + lng=lng,
190 + )
added louka/connectors/boardwalk.py +229 −0
@@ -0,0 +1,229 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/boardwalk.py : connecteur Boardwalk REIT (bwalk.com)
5 +# Pages de villes de la région de Montréal rendues serveur (HubSpot CMS) :
6 +# Longueuil, Laval, Île-des-Sœurs/Verdun, Ville Saint-Laurent. Fiches
7 +# propriétés avec cartes de types de suites (« À partir de … $ ») et galerie
8 +# photo ; les pages de suites ajoutent sdb/pi² et les commodités.
9 +# REIT pancanadien : seules les propriétés du Grand Montréal sont couvertes.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Listing, normalize_unit_type, parse_price
18 +from .base import BaseConnector
19 +
20 +BASE = "https://www.bwalk.com"
21 +
22 +# Pages de villes du Grand Montréal -> (ville réelle, secteur par défaut)
23 +CITY_PAGES = {
24 + f"{BASE}/fr-ca/appartements-a-louer-a-montreal-longueuil":
25 + ("Longueuil", ""),
26 + f"{BASE}/fr-ca/appartements-a-louer-a-montreal-laval":
27 + ("Laval", ""),
28 + f"{BASE}/fr-ca/appartements-a-louer-a-montreal-nuns-island-verdun":
29 + ("Montréal", "Île-des-Sœurs (Verdun)"),
30 + f"{BASE}/fr-ca/appartements-a-louer-a-montreal-ville-saint-laurent":
31 + ("Montréal", "Saint-Laurent"),
32 +}
33 +
34 +PROP_RE = re.compile(
35 + r'href="(/fr-ca/appartements-a-louer-a-montreal-[a-z0-9\-]+/'
36 + r'[a-z0-9\-]+/[a-z0-9\-]+)"')
37 +PRICE_RE = re.compile(r'À partir de\s*([\d\s\u00a0,.]+\$)')
38 +
39 +
40 +_BED_TYPES = {1: "3½", 2: "4½", 3: "5½", 4: "6½"}
41 +
42 +
43 +def _suite_type(name: str) -> str:
44 + """« 3 1/2 Pièces » -> 3½ ; « Maison en Rangée avec N Chambres » -> Maison ;
45 + « 2 Chambres » -> 4½."""
46 + low = name.lower()
47 + if "maison" in low or "rang" in low:
48 + return "Maison"
49 + m = re.search(r"(\d)\s*chambre", low)
50 + if m:
51 + return _BED_TYPES.get(int(m.group(1)), "")
52 + return normalize_unit_type(name)
53 +
54 +
55 +class BoardwalkConnector(BaseConnector):
56 + source_id = "boardwalk"
57 + request_delay = 0.6
58 + max_properties = 25 # garde-fou
59 + max_images = 25
60 +
61 + def fetch(self) -> list[Listing]:
62 + listings: list[Listing] = []
63 + seen: set[str] = set()
64 + self._suite_cache: dict[str, str] = {}
65 + count = 0
66 + for city_url, (city, default_sector) in CITY_PAGES.items():
67 + try:
68 + html = self.get(city_url).text
69 + except Exception:
70 + continue
71 + for path in dict.fromkeys(PROP_RE.findall(html)):
72 + if path in seen:
73 + continue
74 + seen.add(path)
75 + if count >= self.max_properties:
76 + break
77 + count += 1
78 + try:
79 + listings.extend(
80 + self._property_listings(path, city, default_sector))
81 + except Exception:
82 + continue
83 +
84 + # Unicité des external_id (deux cartes peuvent pointer vers la même
85 + # page de type de suite avec des prix différents)
86 + used: dict[str, int] = {}
87 + for lst in listings:
88 + n = used.get(lst.external_id, 0)
89 + used[lst.external_id] = n + 1
90 + if n:
91 + lst.external_id = f"{lst.external_id}-{n + 1}"
92 + return listings
93 +
94 + def _property_listings(self, path: str, city: str,
95 + default_sector: str) -> list[Listing]:
96 + url = BASE + path
97 + prop_slug = path.rstrip("/").split("/")[-1]
98 + html = self.get(url).text
99 + soup = BeautifulSoup(html, "html.parser")
100 +
101 + t = soup.find("title")
102 + name = (t.get_text(strip=True).split("|")[0].strip()
103 + if t else prop_slug.replace("-", " "))
104 +
105 + # Adresse : texte alt des photos « photo de la propriété pour le … »
106 + address = ""
107 + am = re.search(
108 + r'photo de la propri[ée]t[ée] pour le\s*([^"<>]{5,90})', html)
109 + if am:
110 + address = am.group(1).strip()
111 +
112 + # Galerie photo (swiper)
113 + images: list[str] = []
114 + for img in soup.select("img[src]"):
115 + src = img.get("src", "")
116 + if re.search(r"hubfs/.*(bw_properties|Web%20Photos|Web Photos)",
117 + src) and src.startswith("http"):
118 + if src not in images:
119 + images.append(src)
120 + images = images[: self.max_images]
121 +
122 + # Description de la propriété
123 + desc = ""
124 + dh = soup.find(string=re.compile("Description de la propriété"))
125 + if dh:
126 + sec = dh.find_parent()
127 + nxt = sec.find_next("p") if sec else None
128 + if nxt:
129 + desc = nxt.get_text(" ", strip=True)[:600]
130 + if not desc:
131 + og = soup.find("meta", attrs={"property": "og:description"})
132 + if og and og.get("content"):
133 + desc = og["content"].strip()[:600]
134 +
135 + # Commodités (« Caractéristiques et espaces d'agrément »)
136 + amenities: list[str] = []
137 + ah = soup.find(string=re.compile("Caractéristiques et espaces"))
138 + if ah:
139 + cont = ah.find_parent()
140 + for _ in range(3):
141 + if cont is None:
142 + break
143 + lis = cont.find_all("li")
144 + if lis:
145 + break
146 + cont = cont.parent
147 + if cont:
148 + for li in cont.find_all("li")[:25]:
149 + txt = li.get_text(" ", strip=True)
150 + if txt and len(txt) < 50 and txt not in amenities:
151 + amenities.append(txt)
152 +
153 + # Cartes de types de suites : <h4 class="highlight-suite"><a href=...>
154 + listings: list[Listing] = []
155 + for h4 in soup.select("h4.highlight-suite"):
156 + try:
157 + a = h4.find("a")
158 + if not a:
159 + continue
160 + suite_name = a.get_text(" ", strip=True)
161 + suite_href = a.get("href") or ""
162 + suite_url = (BASE + suite_href
163 + if suite_href.startswith("/") else suite_href)
164 + card = h4.parent
165 + price_label = ""
166 + pm = PRICE_RE.search(card.get_text(" ", strip=True)
167 + if card else "")
168 + if pm:
169 + price_label = "À partir de " + pm.group(1).strip()
170 + price = parse_price(price_label)
171 +
172 + # Fiche de la suite : sdb + superficie
173 + detail_bits: list[str] = []
174 + suite_imgs: list[str] = []
175 + if suite_url.startswith(BASE):
176 + try:
177 + sh = self._suite_cache.get(suite_url)
178 + if sh is None:
179 + sh = self.get(suite_url).text
180 + self._suite_cache[suite_url] = sh
181 + sm = re.search(
182 + r'(\d)\s*</[^>]+>\s*sdb[^<]*Superficie[^:]*:\s*'
183 + r'(?:</[^>]+>)?\s*([\d\s\u00a0-]+)', sh)
184 + txt = re.sub(r"<[^>]+>", " ", sh)
185 + txt = re.sub(r"\s+", " ", txt)
186 + m2 = re.search(
187 + r'(\d)\s*sdb\s*\|\s*Superficie\s*\(pi ca\)'
188 + r'[\s:\u00a0]*([\d\s\u00a0-]+\d)', txt)
189 + if m2:
190 + detail_bits.append(f"{m2.group(1)} sdb")
191 + sqft = re.sub(r"[\s\u00a0]+", " ",
192 + m2.group(2)).strip()
193 + detail_bits.append(f"{sqft} pi²")
194 + elif sm:
195 + detail_bits.append(f"{sm.group(1)} sdb")
196 + ssoup = BeautifulSoup(sh, "html.parser")
197 + for img in ssoup.select("img[src]"):
198 + src = img.get("src", "")
199 + if (re.search(r"hubfs/.*(bw_properties|"
200 + r"Web%20Photos|Web Photos)", src)
201 + and src.startswith("http")
202 + and src not in suite_imgs):
203 + suite_imgs.append(src)
204 + except Exception:
205 + pass
206 +
207 + type_slug = (suite_href.rstrip("/").split("/")[-1]
208 + or re.sub(r"[^a-z0-9]+", "-",
209 + suite_name.lower()).strip("-"))
210 + listings.append(Listing(
211 + source=self.source_id,
212 + external_id=f"{prop_slug}-{type_slug}",
213 + url=suite_url or url,
214 + title=f"{name}{suite_name}",
215 + address=address,
216 + sector=default_sector,
217 + city=city,
218 + unit_type=_suite_type(suite_name),
219 + price=price,
220 + price_label=price_label,
221 + availability="Disponible",
222 + description=" — ".join(
223 + x for x in [desc] + detail_bits if x)[:600],
224 + amenities=amenities,
225 + images=(suite_imgs or images)[: self.max_images],
226 + ))
227 + except Exception:
228 + continue
229 + return listings
added louka/connectors/bribourg.py +170 −0
@@ -0,0 +1,170 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/bribourg.py : connecteur Bri Bourg (bribourg.com)
5 +# Site PHP simple (Charlesbourg, Vanier, Beauport). Les annonces de
6 +# logements à louer sont publiées via un blogue DropInBlog embarqué dans
7 +# Logements-a-louer.php ; on lit le flux RSS DropInBlog, puis on complète
8 +# les images avec la page d'immeuble correspondante (Immeubles.php).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import html as htmllib
13 +import re
14 +import unicodedata
15 +from urllib.parse import unquote, urlparse, parse_qs
16 +
17 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
18 +from .base import BaseConnector
19 +
20 +BASE = "https://bribourg.com"
21 +RENT_PAGE = f"{BASE}/Logements-a-louer.php"
22 +# identifiant du blogue DropInBlog (repli si non détecté dans la page)
23 +DEFAULT_BLOG_ID = "dcc6791b-355d-4d9b-bb91-a30d855f6cdf"
24 +FEED_URL = "https://io.dropinblog.com/feed/{blog_id}/?limit=100"
25 +
26 +
27 +def _strip_tags(s: str) -> str:
28 + return re.sub(r"\s+", " ", htmllib.unescape(re.sub(r"<[^>]+>", " ", s))).strip()
29 +
30 +
31 +def _norm(s: str) -> str:
32 + s = unicodedata.normalize("NFD", s.lower())
33 + return "".join(c for c in s if unicodedata.category(c) != "Mn")
34 +
35 +
36 +class BribourgConnector(BaseConnector):
37 + source_id = "bribourg"
38 + request_delay = 0.6
39 +
40 + def fetch(self) -> list[Listing]:
41 + # 1) Détecter l'identifiant du blogue DropInBlog dans la page
42 + blog_id = DEFAULT_BLOG_ID
43 + try:
44 + page = self.get(RENT_PAGE).text
45 + m = re.search(r"embedjs/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
46 + r"[0-9a-f]{4}-[0-9a-f]{12})", page)
47 + if m:
48 + blog_id = m.group(1)
49 + except Exception:
50 + pass
51 +
52 + # 2) Flux RSS des annonces
53 + try:
54 + feed = self.get(FEED_URL.format(blog_id=blog_id)).text
55 + except Exception:
56 + return []
57 +
58 + # 3) Pages d'immeubles (pour compléter les photos)
59 + building_pages = self._building_pages()
60 +
61 + listings: list[Listing] = []
62 + for item in re.findall(r"<item>(.*?)</item>", feed, re.S):
63 + try:
64 + lst = self._parse_item(item, building_pages)
65 + if lst:
66 + listings.append(lst)
67 + except Exception:
68 + continue
69 + return listings
70 +
71 + # -- inventaire des pages d'immeubles ---------------------------------------
72 + def _building_pages(self) -> list[str]:
73 + try:
74 + html = self.get(f"{BASE}/Immeubles.php").text
75 + except Exception:
76 + return []
77 + pages = re.findall(r'href="((?:\./)?\d[\w\-]+\.php)"', html)
78 + return sorted({p.lstrip("./") for p in pages})
79 +
80 + # -- une annonce du flux -----------------------------------------------------
81 + def _parse_item(self, item: str, building_pages: list[str]) -> Listing | None:
82 + def tag(name: str) -> str:
83 + m = re.search(r"<%s>(.*?)</%s>" % (name, name), item, re.S)
84 + return htmllib.unescape(m.group(1).strip()) if m else ""
85 +
86 + title = _strip_tags(tag("title"))
87 + link = tag("link") or RENT_PAGE
88 + m = re.search(r"<content:encoded>\s*<!\[CDATA\[(.*?)\]\]>", item, re.S)
89 + content = m.group(1) if m else tag("description")
90 +
91 + # exclusions : stationnement / commercial / rangement
92 + if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t",
93 + title, re.I):
94 + return None
95 +
96 + text = _strip_tags(content)
97 + # "Adresse : 510, avenue Claude-Martin, Québec (Vanier)"
98 + addr_m = re.search(r"Adresse\s*:\s*([^A-Z]*?[\w\s,.'’\-()]+?)"
99 + r"(?=\s*(?:Loyer|Disponibilit|Caract|$))", text)
100 + address = addr_m.group(1).strip(" ,") if addr_m else ""
101 + price_m = re.search(r"Loyer\s*:\s*([\d\s,]+\$[^A-Z]*?)(?=\s*(?:Disponibilit|Caract|$))",
102 + text)
103 + price_label = price_m.group(1).strip() if price_m else ""
104 + avail_m = re.search(r"Disponibilit[ée]\s*:\s*(.+?)(?=\s*Caract|$)", text)
105 + availability = avail_m.group(1).strip() if avail_m else ""
106 +
107 + # secteur : "(Vanier)" dans l'adresse ou le titre
108 + sector = ""
109 + m = re.search(r"\(([^)]+)\)", address) or re.search(r"\(([^)]+)\)", title)
110 + if m:
111 + sector = m.group(1).strip()
112 +
113 + # caractéristiques (liste <li>)
114 + amenities = []
115 + for li in re.findall(r"<li[^>]*>(.*?)</li>", content, re.S):
116 + t = _strip_tags(li)
117 + if t and t not in amenities:
118 + amenities.append(t)
119 + unit_type = normalize_unit_type(title) or normalize_unit_type(" ".join(amenities))
120 +
121 + # images de l'annonce (hébergées chez DropInBlog)
122 + images = [u for u in dict.fromkeys(
123 + re.findall(r'src="(https?://[^"]+\.(?:jpg|jpeg|png|webp))"', content, re.I))]
124 +
125 + # compléter avec la page de l'immeuble correspondant (numéro civique + rue)
126 + civic_m = re.match(r"(\d+)", address)
127 + if civic_m and building_pages:
128 + civic = civic_m.group(1)
129 + street_words = [w for w in re.findall(r"[a-z]{4,}", _norm(address))
130 + if w not in ("avenue", "boulevard", "quebec")]
131 + for pg in building_pages:
132 + pg_n = _norm(pg)
133 + if re.match(r"%s\D" % re.escape(civic), pg) and \
134 + any(w in pg_n for w in street_words):
135 + try:
136 + bhtml = self.get(f"{BASE}/{pg}").text
137 + extra = re.findall(
138 + r'(?:src|href)="((?:\./)?images/[\w\-]+\.(?:jpg|jpeg|png|webp))"',
139 + bhtml, re.I)
140 + for u in dict.fromkeys(extra):
141 + if re.search(r"logo|favicon|apropos|ico-", u, re.I):
142 + continue
143 + full = f"{BASE}/{u.lstrip('./')}"
144 + if full not in images:
145 + images.append(full)
146 + except Exception:
147 + pass
148 + break
149 +
150 + # identifiant stable : slug du paramètre ?p= du lien
151 + qs = parse_qs(urlparse(link).query)
152 + ext_id = unquote(qs.get("p", [""])[0]) or _norm(re.sub(r"\W+", "-", address or title))
153 +
154 + city = infer_city(sector, default="Québec")
155 + return Listing(
156 + source=self.source_id,
157 + external_id=ext_id,
158 + url=link,
159 + title=title,
160 + address=address,
161 + sector=sector,
162 + city=city,
163 + unit_type=unit_type,
164 + price=parse_price(price_label),
165 + price_label=price_label,
166 + availability=availability,
167 + description=text[:600],
168 + amenities=amenities,
169 + images=images[:25],
170 + )
added louka/connectors/brio.py +126 −0
@@ -0,0 +1,126 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/brio.py : connecteur Les Immeubles Brio (immeublesbrio.com)
5 +# Projet mono-immeuble « Le Brio » à Val-Bélair (boul. Pie-XI, Québec).
6 +# Les unités sont affichées par étage (hotspots Divi) avec statut
7 +# Disponible / Loué ; les prix « à partir de » par type (3½/4½/5½)
8 +# sont affichés sur la page d'accueil.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
17 +from .base import BaseConnector
18 +
19 +BASE = "https://immeublesbrio.com"
20 +HOME_URL = f"{BASE}/"
21 +LIST_URL = f"{BASE}/appartements-a-louer-val-belair/"
22 +SECTOR = "Val-Bélair"
23 +ADDRESS = "boulevard Pie-XI, Val-Bélair"
24 +
25 +
26 +class BrioConnector(BaseConnector):
27 + source_id = "brio"
28 + request_delay = 0.6
29 +
30 + def fetch(self) -> list[Listing]:
31 + # 1) Prix « à partir de » par type, affichés sur la page d'accueil
32 + # (ex. « 3½ à partir de 1500$ »)
33 + type_prices: dict[str, tuple[float | None, str]] = {}
34 + try:
35 + home = self.get(HOME_URL).text
36 + home_txt = re.sub(r"<[^>]+>", " ", home)
37 + for m in re.finditer(r"(\d)\s*½\s*à\s*partir\s*de\s*([\d\s ]+)\$",
38 + home_txt):
39 + label = f"{m.group(1)}½ à partir de {m.group(2).strip()}$"
40 + type_prices[m.group(1)] = (parse_price(f"{m.group(2)}$"), label)
41 + except Exception:
42 + pass
43 +
44 + # 2) Page des appartements : hotspots par étage
45 + html = self.get(LIST_URL).text
46 + soup = BeautifulSoup(html, "html.parser")
47 +
48 + # Galerie générale de l'immeuble (photos des pièces)
49 + gallery = []
50 + for img in soup.select("img"):
51 + src = img.get("data-src") or img.get("src") or ""
52 + if re.search(r"/wp-content/uploads/.*(chambre|salle|salon|cuisine)"
53 + r"[^\"]*\.(?:jpg|jpeg|png|webp)$", src, re.I):
54 + gallery.append(src if src.startswith("http") else BASE + src)
55 + gallery = list(dict.fromkeys(gallery))[:8]
56 +
57 + listings: list[Listing] = []
58 + for info in soup.select("div.hotspot-info"):
59 + try:
60 + title_el = info.select_one(".hotspot-title")
61 + if not title_el:
62 + continue
63 + title = title_el.get_text(" ", strip=True)
64 + m = re.match(r"(Disponible|Lou[ée])\s*-?\s*Appartement\s*(\d+)"
65 + r"\s*:\s*(.+)", title, re.I)
66 + if not m:
67 + continue
68 + status, num, raw_type = m.groups()
69 + if not status.lower().startswith("dispo"):
70 + continue # on ne garde que les unités disponibles
71 +
72 + unit_type = normalize_unit_type(raw_type)
73 + digit = re.search(r"(\d)", unit_type or "")
74 + price, price_label = (None, "")
75 + if digit and digit.group(1) in type_prices:
76 + price, price_label = type_prices[digit.group(1)]
77 +
78 + # Contenu : superficie + caractéristiques
79 + content = info.select_one(".hotspot-content")
80 + amenities: list[str] = []
81 + description = ""
82 + if content:
83 + sup = content.find("strong")
84 + if sup:
85 + description = sup.get_text(strip=True)
86 + amenities = [li.get_text(" ", strip=True)
87 + for li in content.select("li")]
88 +
89 + # Images : plan de l'unité (vignette + plan complet) + galerie
90 + images: list[str] = []
91 + thumb = info.select_one(".hotspot-thumb img")
92 + if thumb:
93 + src = thumb.get("data-src") or thumb.get("src") or ""
94 + if src.startswith("http"):
95 + # version pleine grandeur (retirer le suffixe -300x284)
96 + full = re.sub(r"-\d+x\d+(\.(?:jpg|jpeg|png|webp))$",
97 + r"\1", src)
98 + images.append(full)
99 + if content:
100 + for a in content.select("a[href]"):
101 + href = a.get("href", "")
102 + if re.search(r"\.(?:jpg|jpeg|png|webp)$", href, re.I):
103 + images.append(href)
104 + images.extend(gallery)
105 + images = list(dict.fromkeys(images))
106 +
107 + listings.append(Listing(
108 + source=self.source_id,
109 + external_id=f"appartement-{num}",
110 + url=LIST_URL,
111 + title=f"Le Brio — Appartement {num} ({unit_type})",
112 + address=ADDRESS,
113 + sector=SECTOR,
114 + city=infer_city(SECTOR),
115 + unit_type=unit_type,
116 + price=price,
117 + price_label=price_label,
118 + availability="Disponible",
119 + description=description,
120 + amenities=amenities,
121 + images=images,
122 + ))
123 + except Exception:
124 + continue
125 +
126 + return listings
added louka/connectors/brivia_1sp.py +116 −0
@@ -0,0 +1,116 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/brivia_1sp.py : connecteur 1 Square Phillips (Groupe Brivia)
5 +# (1squarephillips.ca/locatif — tour locative au centre-ville de Montréal,
6 +# 1205 rue du Square-Phillips, Ville-Marie). Le site n'affiche pas
7 +# d'inventaire unité par unité : la page /locatif présente les trois
8 +# typologies offertes (Studio / 1 Chambre / 2 Chambres) avec loyer
9 +# "à partir de" -> une annonce par typologie, photos tirées des pages
10 +# /locatif et /galerie.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +
16 +from bs4 import BeautifulSoup
17 +
18 +from ..schema import Listing, parse_price
19 +from .base import BaseConnector
20 +
21 +BASE = "https://www.1squarephillips.ca"
22 +LOCATIF_URL = f"{BASE}/locatif"
23 +GALERIE_URL = f"{BASE}/galerie"
24 +
25 +ADDRESS = "1205, rue du Square-Phillips, Montréal"
26 +
27 +_TYPE_MAP = {"studio": "Studio", "1 chambre": "3½", "2 chambres": "4½",
28 + "3 chambres": "5½"}
29 +
30 +DESCRIPTION = ("Condos locatifs de luxe au centre-ville de Montréal, formule "
31 + "tout inclus : électroménagers, climatisation, chauffage, "
32 + "électricité, eau chaude et Wi-Fi.")
33 +
34 +AMENITIES = ["Tout inclus (électricité, chauffage, climatisation, eau chaude, "
35 + "Wi-Fi)", "Électroménagers inclus", "Piscine, sauna et bain "
36 + "vapeur", "Salles d'entraînement", "Espace de cotravail",
37 + "Salle de cinéma", "Terrasse", "Gardien 24 h", "Lounge du 21e "
38 + "étage", "Stationnement souterrain"]
39 +
40 +
41 +def _unit_type(label: str) -> str:
42 + key = re.sub(r"\s+", " ", (label or "").strip().lower())
43 + return _TYPE_MAP.get(key, label.strip())
44 +
45 +
46 +class Brivia1SPConnector(BaseConnector):
47 + source_id = "brivia_1sp"
48 + request_delay = 0.6
49 +
50 + def fetch(self) -> list[Listing]:
51 + listings: list[Listing] = []
52 + try:
53 + html = self.get(LOCATIF_URL).text
54 + except Exception:
55 + return listings
56 + soup = BeautifulSoup(html, "html.parser")
57 +
58 + # Photos : perspectives de la page locatif + galerie du site
59 + images = self._collect_images(html)
60 + try:
61 + images += self._collect_images(self.get(GALERIE_URL).text)
62 + except Exception:
63 + pass
64 + images = list(dict.fromkeys(images))[:30]
65 +
66 + # Typologies (ul.grid3cols : h4 = type, p = "à partir de X $/mois")
67 + for li in soup.select("ul.grid3cols li"):
68 + try:
69 + h4 = li.select_one("h4")
70 + p = li.select_one("p")
71 + if not h4 or not p:
72 + continue
73 + typology = h4.get_text(" ", strip=True).replace("\xa0", " ")
74 + price_label = re.sub(r"\s+", " ",
75 + p.get_text(" ", strip=True))
76 + if "$" not in price_label:
77 + continue
78 + type_slug = re.sub(r"[^a-z0-9]+", "-",
79 + typology.lower()).strip("-")
80 + listings.append(Listing(
81 + source=self.source_id,
82 + external_id=f"1sp-{type_slug}",
83 + url=LOCATIF_URL,
84 + title=f"1 Square Phillips — {typology} locatif",
85 + address=ADDRESS,
86 + sector="Centre-ville (Ville-Marie)",
87 + city="Montréal",
88 + unit_type=_unit_type(typology),
89 + price=parse_price(price_label),
90 + price_label=price_label,
91 + availability="Disponible (tour locative en location)",
92 + description=DESCRIPTION,
93 + amenities=AMENITIES,
94 + images=images,
95 + ))
96 + except Exception:
97 + continue
98 + return listings
99 +
100 + @staticmethod
101 + def _collect_images(html: str) -> list[str]:
102 + """Images pleine taille du site (perspectives + galerie)."""
103 + urls = re.findall(
104 + r'(?:https://www\.1squarephillips\.ca)?/?2022/images/'
105 + r'[^"\'\s\)]+\.(?:jpg|jpeg|png|webp)', html)
106 + out = []
107 + for u in urls:
108 + if not u.startswith("http"):
109 + u = f"{BASE}/{u.lstrip('/')}"
110 + # exclure variantes portrait (doublons) et visuels non pertinents
111 + if re.search(r"-portrait\.|ico-|logo|favicon|bckg-contact|"
112 + r"bckg-project-(1|4)\b", u, re.I):
113 + continue
114 + if re.search(r"gallery|rental|persp|condo", u, re.I):
115 + out.append(u)
116 + return list(dict.fromkeys(out))
added louka/connectors/brochu.py +130 −0
@@ -0,0 +1,130 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/brochu.py : connecteur Groupe Immobilier Brochu
5 +# (groupeimmobilierbrochu.com — Lévis : St-Romuald, Charny, St-Nicolas,
6 +# centre-ville + Québec : Les Saules). Une annonce par projet/immeuble
7 +# (pas d'unités individuelles listées sur le site).
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://groupeimmobilierbrochu.com"
19 +LIST_URL = f"{BASE}/projets/"
20 +
21 +# Hors agglomération Québec/Lévis
22 +EXCLUDED_SECTORS = {"saint-lambert-de-lauzon", "st-lambert-de-lauzon"}
23 +
24 +ADDR_RE = re.compile(
25 + r"\d{1,5},?\s+(?:rue|avenue|av\.|boulevard|boul\.?|chemin|route|rang|"
26 + r"place|mont[ée]e)\s+[^,<>]{2,50},?\s*(?:L[ée]vis|Qu[ée]bec)"
27 + r"(?:\s*\(Qu[ée]bec\))?(?:\s*,?\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d)?", re.I)
28 +IMG_RE = re.compile(r"https://groupeimmobilierbrochu\.com/wp-content/uploads/"
29 + r'[^"\s\\)]+\.(?:jpe?g|png|webp|avif)', re.I)
30 +
31 +
32 +class BrochuConnector(BaseConnector):
33 + source_id = "brochu"
34 + request_delay = 0.6
35 + max_projects = 20 # garde-fou de crawl
36 +
37 + def fetch(self) -> list[Listing]:
38 + listings: list[Listing] = []
39 + try:
40 + html = self.get(LIST_URL).text
41 + except Exception:
42 + return listings
43 + soup = BeautifulSoup(html, "html.parser")
44 +
45 + seen: set[str] = set()
46 + for card in soup.select("div.project"):
47 + if len(seen) >= self.max_projects:
48 + break
49 + try:
50 + a = card.select_one('a[href*="/projets/"]')
51 + if not a:
52 + continue
53 + url = a.get("href", "").split("?")[0]
54 + slug = url.rstrip("/").split("/")[-1]
55 + if not slug or slug in seen:
56 + continue
57 + seen.add(slug)
58 +
59 + label_el = card.select_one(".uk-label")
60 + label = label_el.get_text(" ", strip=True) if label_el else ""
61 + meta_el = card.select_one(".el-meta")
62 + sector = meta_el.get_text(" ", strip=True) if meta_el else ""
63 + title_el = card.select_one(".el-title")
64 + name = title_el.get_text(" ", strip=True) if title_el else slug
65 + bold_el = card.select_one(".uk-text-bold")
66 + bold = (re.sub(r"\s+", " ", bold_el.get_text(" ", strip=True))
67 + if bold_el else "")
68 + ps = [p.get_text(" ", strip=True) for p in card.select("p")
69 + if p.get_text(strip=True)]
70 +
71 + # Projets complets ou hors Québec/Lévis : exclure
72 + full_text = f"{label} {' '.join(ps)}"
73 + if re.search(r"\bComplet\b", full_text, re.I) and \
74 + not re.search(r"disponible|libre", full_text, re.I):
75 + continue
76 + key = sector.lower().replace(" ", "-").replace("--", "-")
77 + if key in EXCLUDED_SECTORS or "lauzon" in key:
78 + continue
79 +
80 + availability = label or (ps[0] if ps else "")
81 + price_label = bold if "$" in bold else ""
82 + unit_type = normalize_unit_type(bold)
83 + # ne garder qu'un vrai type d'unité (ex. "4½"), pas le texte brut
84 + if not re.match(r"^\d½$|^Studio$|^Loft$|^Maison$", unit_type):
85 + unit_type = ""
86 + city = infer_city(sector,
87 + default="Québec" if "saules" in key else "Québec")
88 +
89 + # Page projet : adresse, photos, description
90 + address, images, desc = "", [], ""
91 + try:
92 + detail = self.get(url).text
93 + dsoup = BeautifulSoup(detail, "html.parser")
94 + dtext = re.sub(r"\s+", " ", dsoup.get_text(" ", strip=True))
95 + m = ADDR_RE.search(dtext)
96 + if m:
97 + address = m.group(0).strip().rstrip(",")
98 + images = [u for u in dict.fromkeys(IMG_RE.findall(detail))
99 + if not re.search(r"logo|icon|favicon|cropped-|"
100 + r"-\d{2,3}x\d{2,3}\.", u, re.I)][:25]
101 + og = dsoup.find("meta", attrs={"property": "og:description"})
102 + if og and og.get("content"):
103 + desc = og["content"].strip()[:600]
104 + if not desc:
105 + h = dsoup.find(["h2", "h3"],
106 + string=re.compile("qualité|confort", re.I))
107 + if h:
108 + desc = h.get_text(" ", strip=True)[:600]
109 + except Exception:
110 + pass
111 +
112 + listings.append(Listing(
113 + source=self.source_id,
114 + external_id=slug,
115 + url=url,
116 + title=name,
117 + address=address,
118 + sector=sector,
119 + city=city,
120 + unit_type=unit_type,
121 + price=parse_price(price_label),
122 + price_label=price_label,
123 + availability=availability,
124 + description=desc or bold,
125 + images=images,
126 + ))
127 + except Exception:
128 + continue
129 +
130 + return listings
added louka/connectors/capreit.py +219 −0
@@ -0,0 +1,219 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/capreit.py : connecteur CAPREIT (capreit.ca)
5 +# Flux JSON officiel du moteur de recherche (admin-ajax `property_json`)
6 +# filtré sur les villes de la région de Québec ET du Grand Montréal (île de
7 +# Montréal, Laval, Rive-Sud, Rive-Nord proche); les fiches propriétés (rendu
8 +# serveur) fournissent les types d'unités, prix, disponibilités, commodités
9 +# et la galerie photo. Une annonce par type d'unité disponible.
10 +# Exclus : hors-province (province != QC) et villes hors des deux régions.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +
16 +from bs4 import BeautifulSoup
17 +
18 +from ..schema import (Listing, infer_city, normalize_unit_type, parse_price,
19 + strip_accents)
20 +from .base import BaseConnector
21 +
22 +BASE = "https://www.capreit.ca"
23 +FEED_URL = f"{BASE}/wp-admin/admin-ajax.php?action=property_json&language=fr"
24 +
25 +# Villes du flux correspondant à la région Québec/Lévis
26 +_QC_CITIES = {
27 + "ville de quebec", "quebec", "beauport", "levis", "sainte-foy",
28 + "charlesbourg", "loretteville", "sillery", "cap-rouge", "val-belair",
29 + "l-ancienne-lorette", "saint-augustin-de-desmaures", "wendake",
30 + "saint-romuald", "saint-nicolas", "charny",
31 +}
32 +
33 +# Villes du Grand Montréal : clé normalisée du flux -> nom d'affichage
34 +_GM_CITIES = {
35 + # Île de Montréal
36 + "montreal": "Montréal",
37 + "cote saint-luc": "Côte Saint-Luc",
38 + "cote-saint-luc": "Côte Saint-Luc",
39 + "westmount": "Westmount",
40 + "dorval": "Dorval",
41 + "pointe-claire": "Pointe-Claire",
42 + "mont-royal": "Mont-Royal",
43 + "dollard-des-ormeaux": "Dollard-des-Ormeaux",
44 + # Laval
45 + "laval": "Laval",
46 + # Longueuil / Rive-Sud
47 + "longueuil": "Longueuil",
48 + "brossard": "Brossard",
49 + "boucherville": "Boucherville",
50 + "saint-lambert": "Saint-Lambert",
51 + "saint-hubert": "Longueuil",
52 + "candiac": "Candiac",
53 + "chateauguay": "Châteauguay",
54 + # Rive-Nord proche
55 + "boisbriand": "Boisbriand",
56 + "repentigny": "Repentigny",
57 + "terrebonne": "Terrebonne",
58 + "mascouche": "Mascouche",
59 + "rosemere": "Rosemère",
60 + "sainte-therese": "Sainte-Thérèse",
61 + "blainville": "Blainville",
62 +}
63 +_IMG_RE = re.compile(
64 + r'https://www\.capreit\.ca/wp-content/uploads/[^"\'\s\\]+'
65 + r'\.(?:jpg|jpeg|png|webp)', re.I)
66 +_SKIP_IMG = re.compile(
67 + r"logo|icon|favicon|cropped|-\d{2,4}x\d{2,4}\.|BIL|Phone|badge", re.I)
68 +
69 +
70 +class CapreitConnector(BaseConnector):
71 + source_id = "capreit"
72 + request_delay = 0.6
73 + max_properties = 60 # garde-fou (région de Québec + Grand Montréal)
74 + max_images = 25
75 +
76 + @staticmethod
77 + def _city_key(city: str) -> str:
78 + return strip_accents((city or "").strip().lower())
79 +
80 + def fetch(self) -> list[Listing]:
81 + props = self.get(FEED_URL).json()
82 +
83 + listings: list[Listing] = []
84 + count = 0
85 + for p in props:
86 + try:
87 + if (p.get("province") or "").strip().upper() != "QC":
88 + continue
89 + ck = self._city_key(p.get("city", ""))
90 + if ck not in _QC_CITIES and ck not in _GM_CITIES:
91 + continue
92 + if not p.get("has_vacancies"):
93 + continue
94 + if count >= self.max_properties:
95 + break
96 + count += 1
97 + listings.extend(self._property_listings(p))
98 + except Exception:
99 + continue
100 + return listings
101 +
102 + def _property_listings(self, p: dict) -> list[Listing]:
103 + pid = str(p.get("id"))
104 + url = p.get("url") or ""
105 + title = (p.get("title") or "").strip()
106 + address = (p.get("address") or "").strip()
107 + feed_city = (p.get("city") or "").strip()
108 + # secteur : ville précise du flux (ex. Beauport) sinon intersection
109 + city_key = self._city_key(feed_city)
110 + if city_key in _GM_CITIES:
111 + # Grand Montréal : la ville du flux est la vraie ville
112 + sector = (p.get("nearest_intersection") or "").strip()
113 + city = _GM_CITIES[city_key]
114 + elif city_key in ("ville de quebec", "quebec"):
115 + sector = (p.get("nearest_intersection") or "").strip()
116 + city = infer_city(sector, default="Québec")
117 + else:
118 + sector = feed_city
119 + city = infer_city(sector, default="Québec")
120 +
121 + desc, amenities, images, rows = "", [], [], []
122 + try:
123 + page = self.get(url).text
124 + soup = BeautifulSoup(page, "html.parser")
125 +
126 + # galerie photos (héro + blocs JSON de la page)
127 + for u in _IMG_RE.findall(page):
128 + if _SKIP_IMG.search(u):
129 + continue
130 + if u not in images:
131 + images.append(u)
132 + images = images[: self.max_images]
133 +
134 + # commodités (listes à icônes)
135 + seen = set()
136 + for li in soup.select("li"):
137 + if not li.find("div", class_="icon"):
138 + continue
139 + t = li.get_text(" ", strip=True)
140 + if t and len(t) < 60 and t not in seen:
141 + seen.add(t)
142 + amenities.append(t)
143 + amenities = amenities[:25]
144 +
145 + # description (« Caractéristiques de l'immeuble »)
146 + h = soup.find(["h2", "h3"], string=re.compile(
147 + "Caractéristiques de l['’]immeuble"))
148 + if h:
149 + nxt = h.find_next(["p", "div"])
150 + if nxt:
151 + desc = nxt.get_text(" ", strip=True)[:600]
152 +
153 + # types d'unités disponibles
154 + for li in soup.select("li.property-options-list-item"):
155 + avail_el = li.select_one(
156 + ".property-options-list-item-availability")
157 + price_el = li.select_one(
158 + ".property-options-list-item-price")
159 + details = [d.get_text(" ", strip=True)
160 + for d in li.select(".property-options-item")]
161 + unit_raw = details[0] if details else ""
162 + sqft = details[1] if len(details) > 1 else ""
163 + if li.get("data-available") == "false":
164 + continue
165 + rows.append({
166 + "unit_raw": unit_raw,
167 + "sqft": sqft,
168 + "price": price_el.get_text(" ", strip=True)
169 + if price_el else "",
170 + "avail": avail_el.get_text(" ", strip=True)
171 + if avail_el else "",
172 + })
173 + except Exception:
174 + pass
175 +
176 + out: list[Listing] = []
177 + if rows:
178 + for r in rows:
179 + ut = normalize_unit_type(r["unit_raw"])
180 + slug = re.sub(r"[^a-z0-9]+", "-",
181 + strip_accents(r["unit_raw"].lower())).strip("-")
182 + out.append(Listing(
183 + source=self.source_id,
184 + external_id=f"{pid}-{slug or 'u'}",
185 + url=url,
186 + title=f"{title}{r['unit_raw']}" if r["unit_raw"]
187 + else title,
188 + address=address,
189 + sector=sector,
190 + city=city,
191 + unit_type=ut,
192 + price=parse_price(r["price"]),
193 + price_label=r["price"],
194 + availability=r["avail"],
195 + description=" — ".join(x for x in [desc, r["sqft"]] if x)[:600],
196 + amenities=amenities,
197 + images=images,
198 + ))
199 + else:
200 + # repli : annonce par propriété avec le prix plancher du flux
201 + min_rent = p.get("min_rent")
202 + out.append(Listing(
203 + source=self.source_id,
204 + external_id=pid,
205 + url=url,
206 + title=title,
207 + address=address,
208 + sector=sector,
209 + city=city,
210 + unit_type=normalize_unit_type(
211 + (p.get("bedroom_range") or "").split("-")[0]),
212 + price=float(min_rent) if min_rent else None,
213 + price_label=p.get("price_range") or "",
214 + availability=p.get("vacancy_message") or "",
215 + description=desc,
216 + amenities=amenities,
217 + images=images,
218 + ))
219 + return out
added louka/connectors/cogir.py +229 −0
@@ -0,0 +1,229 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/cogir.py : connecteur Cogir Immobilier (cogir.net)
5 +# Page liste « gestion-immeubles-residentiels » (rendu serveur) filtrée sur
6 +# les villes de la région de Québec/Lévis ET du Grand Montréal (île de
7 +# Montréal, Laval, Longueuil/Rive-Sud, Rive-Nord proche); chaque immeuble a
8 +# une fiche avec un tableau « Modèles disponibles » (type + prix à partir de)
9 +# et une galerie photos (DATA/PHOTO). Une annonce par modèle d'unité, sinon
10 +# par immeuble.
11 +# Exclus : Ontario/Halifax/etc., résidences pour aînés/retraités/étudiantes.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +
17 +from bs4 import BeautifulSoup
18 +
19 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
20 +from .base import BaseConnector
21 +
22 +BASE = "https://www.cogir.net"
23 +LIST_URL = f"{BASE}/gestion-immeubles-residentiels.html"
24 +
25 +# Slugs d'URL correspondant à la région de Québec/Lévis
26 +_QC_SLUGS = {
27 + "quebec", "sainte-foy", "ste-foy", "levis", "saint-romuald", "charny",
28 + "loretteville", "neufchatel", "beauport", "charlesbourg", "cap-rouge",
29 + "val-belair", "l-ancienne-lorette", "saint-augustin", "sillery",
30 + "saint-nicolas", "wendake", "vanier", "lebourgneuf",
31 +}
32 +_SLUG_TO_SECTOR = {
33 + "quebec": "", "sainte-foy": "Sainte-Foy", "ste-foy": "Sainte-Foy",
34 + "levis": "Lévis", "saint-romuald": "Saint-Romuald", "charny": "Charny",
35 + "loretteville": "Loretteville", "neufchatel": "Neufchâtel",
36 + "beauport": "Beauport", "charlesbourg": "Charlesbourg",
37 + "cap-rouge": "Cap-Rouge", "val-belair": "Val-Bélair",
38 + "l-ancienne-lorette": "L'Ancienne-Lorette",
39 + "saint-augustin": "Saint-Augustin", "sillery": "Sillery",
40 + "saint-nicolas": "Saint-Nicolas", "wendake": "Wendake",
41 + "vanier": "Vanier", "lebourgneuf": "Lebourgneuf",
42 +}
43 +
44 +# Slugs d'URL du Grand Montréal -> (secteur, ville). Les autres slugs
45 +# (Toronto, London, Ottawa, Halifax, Gatineau, Sherbrooke...) restent exclus.
46 +_GM_SLUGS = {
47 + # Île de Montréal
48 + "montreal": ("", "Montréal"),
49 + "vieux-montreal": ("Vieux-Montréal", "Montréal"),
50 + "rosemont": ("Rosemont", "Montréal"),
51 + "lasalle": ("LaSalle", "Montréal"),
52 + "pierrefonds": ("Pierrefonds", "Montréal"),
53 + "ville-st-laurent": ("Saint-Laurent", "Montréal"),
54 + "montreal-est": ("", "Montréal-Est"),
55 + "westmount": ("", "Westmount"),
56 + "pointe-claire": ("", "Pointe-Claire"),
57 + "dorval": ("", "Dorval"),
58 + # Laval
59 + "laval": ("", "Laval"),
60 + # Longueuil / Rive-Sud
61 + "longueuil": ("", "Longueuil"),
62 + "saint-hubert": ("Saint-Hubert", "Longueuil"),
63 + "brossard": ("", "Brossard"),
64 + "boucherville": ("", "Boucherville"),
65 + "saint-lambert": ("", "Saint-Lambert"),
66 + "saint-bruno": ("", "Saint-Bruno-de-Montarville"),
67 + "sainte-julie": ("", "Sainte-Julie"),
68 + "beloeil": ("", "Belœil"),
69 + "varennes": ("", "Varennes"),
70 + "saint-constant": ("", "Saint-Constant"),
71 + "delson": ("", "Delson"),
72 + "candiac": ("", "Candiac"),
73 + "chateauguay": ("", "Châteauguay"),
74 + # Rive-Nord proche
75 + "repentigny": ("", "Repentigny"),
76 + "terrebonne": ("", "Terrebonne"),
77 + "mascouche": ("", "Mascouche"),
78 +}
79 +# Slugs génériques : remplacés par un slug plus précis quand disponible
80 +_GENERIC_SLUGS = {"quebec", "montreal"}
81 +_BUILDING_RE = re.compile(
82 + r'href="(immeuble-residentiel-([a-z0-9\-]+)/(\d+)-[^"]+\.html)"')
83 +_EXCLUDE_RE = re.compile(
84 + r"résidence[s]? pour (aîné|retrait)|résidence[s]? étudiante|"
85 + r"stationnement|commercial|rangement|entreposage", re.I)
86 +
87 +
88 +class CogirConnector(BaseConnector):
89 + source_id = "cogir"
90 + request_delay = 0.6
91 + max_buildings = 150 # garde-fou (région de Québec + Grand Montréal)
92 + max_images = 25
93 +
94 + def fetch(self) -> list[Listing]:
95 + html = self.get(LIST_URL).text
96 +
97 + # 1) Immeubles des régions couvertes (dédupliqués par id numérique;
98 + # on privilégie le slug le plus précis, ex. sainte-foy > quebec)
99 + buildings: dict[str, dict] = {}
100 + for m in _BUILDING_RE.finditer(html):
101 + path, slug, bid = m.group(1), m.group(2), m.group(3)
102 + if slug not in _QC_SLUGS and slug not in _GM_SLUGS:
103 + continue
104 + cur = buildings.get(bid)
105 + if cur is None or (cur["slug"] in _GENERIC_SLUGS
106 + and slug not in _GENERIC_SLUGS):
107 + buildings[bid] = {"path": path, "slug": slug, "id": bid}
108 +
109 + listings: list[Listing] = []
110 + for i, b in enumerate(buildings.values()):
111 + if i >= self.max_buildings:
112 + break
113 + try:
114 + page = self.get(f"{BASE}/{b['path']}").text
115 + except Exception:
116 + continue
117 + soup = BeautifulSoup(page, "html.parser")
118 +
119 + h1 = soup.select_one("h1")
120 + name = h1.get_text(strip=True) if h1 else f"Immeuble {b['id']}"
121 + addr_el = soup.select_one("p.adresse")
122 + address = ""
123 + if addr_el:
124 + address = addr_el.get_text(" ", strip=True)
125 + address = re.sub(r"Coordonnées complètes.*|Visite virtuelle.*",
126 + "", address).strip(" »")
127 +
128 + # description
129 + desc = ""
130 + d_h2 = soup.find("h2", string=re.compile("Description", re.I))
131 + if d_h2 and d_h2.find_parent():
132 + desc = d_h2.find_parent().get_text(" ", strip=True)
133 + desc = re.sub(r"^Description\s*", "", desc)[:600]
134 +
135 + # exclusion aînés / étudiantes / commercial
136 + if _EXCLUDE_RE.search(name) or _EXCLUDE_RE.search(desc[:200]):
137 + continue
138 +
139 + # commodités
140 + amenities: list[str] = []
141 + s_h2 = soup.find("h2", string=re.compile(
142 + "Services dans l'immeuble", re.I))
143 + if s_h2:
144 + ul = s_h2.find_next("ul")
145 + if ul:
146 + amenities = [li.get_text(strip=True)
147 + for li in ul.select("li")][:20]
148 +
149 + # photos (relatives DATA/PHOTO/... -> absolues)
150 + images: list[str] = []
151 + for im in soup.select("img[src]"):
152 + src = im["src"]
153 + if "DATA/PHOTO" not in src:
154 + continue
155 + absu = src if src.startswith("http") else f"{BASE}/{src.lstrip('/')}"
156 + if absu not in images:
157 + images.append(absu)
158 + images = images[: self.max_images]
159 +
160 + if b["slug"] in _GM_SLUGS:
161 + sector, city = _GM_SLUGS[b["slug"]]
162 + else:
163 + sector = _SLUG_TO_SECTOR.get(b["slug"], "")
164 + if not sector:
165 + # essaie de déduire le secteur du nom (« Loretteville »)
166 + for s in _SLUG_TO_SECTOR.values():
167 + if s and s.lower() in name.lower():
168 + sector = s
169 + break
170 + city = infer_city(sector or b["slug"], default="Québec")
171 + url = f"{BASE}/{b['path']}"
172 +
173 + # 2) Une annonce par modèle du tableau « Modèles disponibles »
174 + rows = []
175 + for tr in soup.select("table tr"):
176 + tds = tr.select("td")
177 + if not tds:
178 + continue
179 + typ = tds[0].get_text(" ", strip=True)
180 + price_txt = ""
181 + for td in tds[1:]:
182 + t = td.get_text(" ", strip=True)
183 + if "$" in t:
184 + price_txt = t
185 + break
186 + if typ and (re.match(r"^\d\s*1/2|^\d\s*½|^Studio|^Loft", typ,
187 + re.I)):
188 + rows.append((typ, price_txt))
189 +
190 + if rows:
191 + for typ, price_txt in rows:
192 + ut = normalize_unit_type(typ)
193 + ext = f"{b['id']}-{re.sub(r'[^a-z0-9]+', '', ut.lower()) or 'u'}"
194 + listings.append(Listing(
195 + source=self.source_id,
196 + external_id=ext,
197 + url=url,
198 + title=f"{name}{ut}",
199 + address=address,
200 + sector=sector,
201 + city=city,
202 + unit_type=ut,
203 + price=parse_price(price_txt),
204 + price_label=(f"À partir de {price_txt}"
205 + if price_txt else ""),
206 + availability="",
207 + description=desc,
208 + amenities=amenities,
209 + images=images,
210 + ))
211 + else:
212 + listings.append(Listing(
213 + source=self.source_id,
214 + external_id=b["id"],
215 + url=url,
216 + title=name,
217 + address=address,
218 + sector=sector,
219 + city=city,
220 + unit_type="",
221 + price=None,
222 + price_label="",
223 + availability="",
224 + description=desc,
225 + amenities=amenities,
226 + images=images,
227 + ))
228 +
229 + return listings
added louka/connectors/contraste.py +141 −0
@@ -0,0 +1,141 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/contraste.py : connecteur Contraste Immobilier
5 +# (contrasteimmobilier.ca — Beauport, Limoilou, Ste-Foy, Val-Bélair,
6 +# Lévis, St-Nicolas, St-Romuald). Site WordPress rendu serveur :
7 +# page d'accueil = cartes d'immeubles, pages immeubles = unités
8 +# individuelles (div.building-stack-unit avec attributs data-*).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import json
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
18 +from .base import BaseConnector
19 +
20 +BASE = "https://contrasteimmobilier.ca"
21 +
22 +# Villes admissibles (agglomération Québec / Lévis) — ex. Beaupré est exclue.
23 +ALLOWED_CITIES = {"quebec", "québec", "levis", "lévis"}
24 +
25 +ADDR_RE = re.compile(
26 + r"\d{1,5}[^,<>]{2,60},\s*[^,<>]{2,40},\s*Qu[ée]bec(?:,\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d)?"
27 +)
28 +
29 +
30 +class ContrasteConnector(BaseConnector):
31 + source_id = "contraste"
32 + request_delay = 0.6
33 + max_buildings = 30 # garde-fou de crawl
34 +
35 + def fetch(self) -> list[Listing]:
36 + listings: list[Listing] = []
37 + try:
38 + home = self.get(BASE).text
39 + except Exception:
40 + return listings
41 +
42 + # 1) Cartes d'immeubles sur la page d'accueil : nom + ville + lien
43 + soup = BeautifulSoup(home, "html.parser")
44 + buildings: dict[str, dict] = {}
45 + for a in soup.select('a[href*="/appartements/"]'):
46 + href = (a.get("href") or "").split("?")[0]
47 + m = re.search(r"/appartements/([a-z0-9\-]+)/?$", href)
48 + if not m:
49 + continue
50 + slug = m.group(1)
51 + text = re.sub(r"\s+", " ", a.get_text(" ", strip=True))
52 + if not text or slug in buildings:
53 + continue
54 + # "Quartier Élévation I Lévis 6 unités disponibles ... Découvrir"
55 + name = re.split(r"\s(?:Québec|Lévis|Beaupré)\b", text)[0].strip()
56 + city_m = re.search(r"\b(Québec|Lévis|Beaupré)\b", text)
57 + city = city_m.group(1) if city_m else ""
58 + buildings[slug] = {"name": name or slug, "city": city}
59 +
60 + # 2) Pages d'immeubles : unités individuelles
61 + for i, (slug, meta) in enumerate(buildings.items()):
62 + if i >= self.max_buildings:
63 + break
64 + if meta["city"] and meta["city"].lower() not in ALLOWED_CITIES:
65 + continue # hors Québec/Lévis (ex. Beaupré)
66 + url = f"{BASE}/appartements/{slug}/"
67 + try:
68 + html = self.get(url).text
69 + except Exception:
70 + continue
71 + bsoup = BeautifulSoup(html, "html.parser")
72 +
73 + # Adresse civique de l'immeuble (bloc Elementor)
74 + address = sector = ""
75 + for el in bsoup.select(".elementor-heading-title"):
76 + m = ADDR_RE.search(el.get_text(" ", strip=True))
77 + if m:
78 + address = m.group(0).strip()
79 + break
80 + if not address:
81 + m = ADDR_RE.search(html)
82 + if m:
83 + address = m.group(0).strip()
84 + if address:
85 + parts = [p.strip() for p in address.split(",")]
86 + if len(parts) >= 2:
87 + sector = parts[1]
88 + city = infer_city(sector, default=meta["city"] or "Québec")
89 +
90 + # Description (meta og:description)
91 + desc = ""
92 + og = bsoup.find("meta", attrs={"property": "og:description"})
93 + if og and og.get("content"):
94 + desc = og["content"].strip()[:600]
95 +
96 + for unit in bsoup.select("div.building-stack-unit"):
97 + try:
98 + pid = unit.get("data-pid") or ""
99 + name = unit.get("data-name") or pid
100 + rooms = unit.get("data-rooms") or ""
101 + price_raw = unit.get("data-price") or ""
102 + if not pid:
103 + continue
104 + # Images : data-images = JSON [{urlPreview, url}, ...]
105 + images: list[str] = []
106 + cover = unit.get("data-image") or ""
107 + if cover:
108 + images.append(cover)
109 + try:
110 + for img in json.loads(unit.get("data-images") or "[]"):
111 + u = (img or {}).get("url") or ""
112 + if u:
113 + images.append(u)
114 + except (ValueError, TypeError):
115 + pass
116 + images = [u for u in dict.fromkeys(images)
117 + if not re.search(r"logo|icon|favicon", u, re.I)]
118 +
119 + avail_el = unit.select_one(".building-stack-available-soon")
120 + availability = (avail_el.get_text(" ", strip=True)
121 + if avail_el else "Disponible")
122 + price_label = f"{price_raw}$/mois" if price_raw else ""
123 + listings.append(Listing(
124 + source=self.source_id,
125 + external_id=f"{slug}-{pid}",
126 + url=url,
127 + title=f"{meta['name']} — unité {name}",
128 + address=address,
129 + sector=sector,
130 + city=city,
131 + unit_type=normalize_unit_type(rooms),
132 + price=parse_price(price_label),
133 + price_label=price_label,
134 + availability=availability,
135 + description=desc,
136 + images=images,
137 + ))
138 + except Exception:
139 + continue
140 +
141 + return listings
added louka/connectors/copley.py +168 −0
@@ -0,0 +1,168 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/copley.py : connecteur Groupe Copley (groupecopley.com)
5 +# Locations haut de gamme — Westmount, Mont-Royal, Saint-Laurent,
6 +# centre-ville de Montréal, NDG (le site couvre aussi Toronto/Ottawa,
7 +# exclus ici). Webflow CMS rendu serveur : /properties paginé
8 +# (?15d7d54c_page=N), cartes avec champs fs-cmsfilter-*, fiches
9 +# détaillées pour les photos.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Listing, normalize_unit_type
18 +from .base import BaseConnector
19 +
20 +BASE = "https://www.groupecopley.com"
21 +LIST_URL = f"{BASE}/properties"
22 +
23 +# Dossier d'assets Webflow des photos d'annonces (≠ dossier du thème)
24 +IMG_RE = re.compile(
25 + r"https://cdn\.prod\.website-files\.com/6449860fc17b160d22960284/"
26 + r"[^\"\s]+?\.(?:jpg|jpeg|png|webp)", re.I)
27 +
28 +# Quartiers de Montréal qui sont en fait des villes distinctes
29 +_CITY_FROM_NEIGHBOURHOOD = {
30 + "westmount": "Westmount",
31 + "mount royal": "Mont-Royal",
32 + "town of mount royal": "Mont-Royal",
33 +}
34 +
35 +
36 +def _bedrooms_to_type(raw: str) -> str:
37 + """0 → Studio, 1 → 3½, 2 → 4½, 3 → 5½, 4 → 6½."""
38 + m = re.search(r"\d+", raw or "")
39 + if not m:
40 + return normalize_unit_type(raw)
41 + n = int(m.group(0))
42 + return {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}.get(
43 + n, f"{n} chambres")
44 +
45 +
46 +class CopleyConnector(BaseConnector):
47 + source_id = "copley"
48 + request_delay = 0.6
49 + max_pages = 15 # garde-fou de pagination
50 + max_details = 60 # garde-fou de fetch des fiches
51 +
52 + def fetch(self) -> list[Listing]:
53 + listings: dict[str, Listing] = {}
54 +
55 + # 1) Pages de la liste (Webflow pagine avec ?15d7d54c_page=N)
56 + for page_no in range(1, self.max_pages + 1):
57 + url = LIST_URL if page_no == 1 else f"{LIST_URL}?15d7d54c_page={page_no}"
58 + try:
59 + html = self.get(url).text
60 + except Exception:
61 + break
62 + soup = BeautifulSoup(html, "html.parser")
63 + items = soup.select("div.property_item")
64 + if not items:
65 + break
66 + new = 0
67 + for it in items:
68 + try:
69 + lst = self._parse_card(it)
70 + except Exception:
71 + continue
72 + if lst and lst.external_id not in listings:
73 + listings[lst.external_id] = lst
74 + new += 1
75 + # plus de page suivante annoncée -> stop
76 + if f"?15d7d54c_page={page_no + 1}" not in html:
77 + break
78 + if new == 0 and page_no > 1:
79 + break
80 +
81 + # 2) Fiches détaillées : toutes les photos + description
82 + for i, lst in enumerate(listings.values()):
83 + if i >= self.max_details:
84 + break
85 + try:
86 + detail = self.get(lst.url).text
87 + except Exception:
88 + continue
89 + imgs = [u for u in dict.fromkeys(IMG_RE.findall(detail))
90 + if "-p-" not in u # variantes responsive
91 + and not re.search(r"logo|icon|favicon|comingsoon", u, re.I)]
92 + lst.images = imgs[:30]
93 + dsoup = BeautifulSoup(detail, "html.parser")
94 + rich = dsoup.select_one(".w-richtext")
95 + if rich:
96 + lst.description = rich.get_text(" ", strip=True)[:600]
97 +
98 + return list(listings.values())
99 +
100 + # -- parsing d'une carte ---------------------------------------------------
101 + def _parse_card(self, it) -> Listing | None:
102 + link = it.select_one("a.property_item-link")
103 + if not link:
104 + return None
105 + href = (link.get("href") or "").split("?")[0]
106 + m = re.match(r"/properties/([\w\-%.]+)$", href)
107 + if not m:
108 + return None
109 + slug = m.group(1)
110 +
111 + fields: dict[str, list[str]] = {}
112 + for f in it.select("[fs-cmsfilter-field]"):
113 + key = f.get("fs-cmsfilter-field", "")
114 + fields.setdefault(key, []).append(f.get_text(" ", strip=True))
115 +
116 + cities = fields.get("city", [])
117 + raw_city = cities[-1] if cities else ""
118 + if raw_city.lower() != "montreal":
119 + return None # Toronto / Ottawa : hors périmètre
120 + neighbourhood = (fields.get("neighbourhood") or [""])[0]
121 + ptype = (fields.get("type") or [""])[0]
122 + if re.search(r"parking|stationnement|commercial|office|storage",
123 + ptype, re.I):
124 + return None
125 + title = (fields.get("title") or [""])[0]
126 + bedrooms = (fields.get("bedrooms") or [""])[0]
127 + available = (fields.get("available") or [""])[0].strip().lower()
128 +
129 + price = None
130 + price_label = ""
131 + price_el = it.select_one(".property_item-price-text")
132 + if price_el:
133 + num = re.sub(r"[^\d.]", "", price_el.get_text(strip=True))
134 + if num:
135 + try:
136 + val = float(num)
137 + if 100 <= val <= 20000:
138 + price = val
139 + price_label = f"${num} / month"
140 + except ValueError:
141 + pass
142 +
143 + city = _CITY_FROM_NEIGHBOURHOOD.get(neighbourhood.lower(), "Montréal")
144 + sector = "" if city != "Montréal" else neighbourhood
145 + if sector.lower() == "downtown montreal":
146 + sector = "Centre-ville"
147 + elif sector.lower() == "nuns' island":
148 + sector = "Île-des-Sœurs"
149 +
150 + img = it.select_one("img.property_image")
151 + images = [img["src"]] if img and img.get("src") else []
152 +
153 + return Listing(
154 + source=self.source_id,
155 + external_id=slug,
156 + url=f"{BASE}/properties/{slug}",
157 + title=title or slug.replace("-", " ").title(),
158 + address=title,
159 + sector=sector,
160 + city=city,
161 + unit_type=_bedrooms_to_type(bedrooms),
162 + price=price,
163 + price_label=price_label,
164 + # le champ « available » du CMS n'est pas fiable au niveau carte
165 + availability="Disponible" if available == "true" else "",
166 + amenities=[],
167 + images=images,
168 + )
added louka/connectors/cromwell.py +211 −0
@@ -0,0 +1,211 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/cromwell.py : connecteur Cromwell Management (cromwellmgt.ca)
5 +# ~20 immeubles à Montréal (Plateau, Outremont, Westmount, centre-ville,
6 +# Côte-des-Neiges, Hampstead). Les unités individuelles avec prix sont
7 +# publiées sur le site jumeau cromwellmontreal.ca (WordPress + thème
8 +# Houzez, rendu serveur) : cartes .item-listing-wrap avec data-listid,
9 +# fiches /property/<slug>/ pour photos, statut et description.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Listing, normalize_unit_type
18 +from .base import BaseConnector
19 +
20 +BASE = "https://cromwellmontreal.ca"
21 +LIST_URL = f"{BASE}/apartments-for-rent-montreal/"
22 +
23 +IMG_RE = re.compile(
24 + r"https://cromwellmontreal\.ca/wp-content/uploads/"
25 + r"[^\"\s\\]+?\.(?:jpg|jpeg|png|webp)", re.I)
26 +
27 +# Municipalités de l'île qui ne sont pas des arrondissements de Montréal
28 +_INDEPENDENT_CITIES = {
29 + "westmount": "Westmount", "hampstead": "Hampstead",
30 + "mont-royal": "Mont-Royal", "mount royal": "Mont-Royal",
31 + "côte-saint-luc": "Côte-Saint-Luc", "cote-saint-luc": "Côte-Saint-Luc",
32 + "montréal-ouest": "Montréal-Ouest", "montreal west": "Montréal-Ouest",
33 +}
34 +
35 +
36 +def _parse_price_us(raw: str) -> float | None:
37 + """'Starting at $1,995 /month' -> 1995.0 (symbole $ devant le nombre)."""
38 + m = re.search(r"\$\s*([\d,\s]+(?:\.\d{2})?)", raw or "")
39 + if not m:
40 + return None
41 + num = m.group(1).replace(",", "").replace(" ", "").replace(" ", "")
42 + try:
43 + val = float(num)
44 + except ValueError:
45 + return None
46 + return val if 100 <= val <= 20000 else None
47 +
48 +
49 +def _unit_type(ptype: str, beds: str) -> str:
50 + """'1 Bedroom (3 1/2)' -> 3½ ; sinon via nb de chambres (0.5 = studio)."""
51 + m = re.search(r"(\d)\s*1/2", ptype or "")
52 + if m:
53 + return f"{m.group(1)}½"
54 + if re.search(r"studio", ptype or "", re.I):
55 + return "Studio"
56 + m = re.search(r"[\d.]+", beds or "")
57 + if m:
58 + n = float(m.group(0))
59 + if n < 1:
60 + return "Studio"
61 + return {1: "3½", 2: "4½", 3: "5½", 4: "6½"}.get(int(n), f"{int(n)} chambres")
62 + return normalize_unit_type(ptype)
63 +
64 +
65 +class CromwellConnector(BaseConnector):
66 + source_id = "cromwell"
67 + request_delay = 0.6
68 + max_pages = 8 # garde-fou de pagination
69 + max_details = 60 # garde-fou de fetch des fiches
70 +
71 + def fetch(self) -> list[Listing]:
72 + listings: dict[str, Listing] = {}
73 +
74 + # 1) Liste paginée des unités disponibles
75 + url = LIST_URL
76 + for _ in range(self.max_pages):
77 + try:
78 + html = self.get(url).text
79 + except Exception:
80 + break
81 + soup = BeautifulSoup(html, "html.parser")
82 + for it in soup.select("div.item-listing-wrap"):
83 + try:
84 + lst = self._parse_card(it)
85 + except Exception:
86 + continue
87 + if lst and lst.external_id not in listings:
88 + listings[lst.external_id] = lst
89 + nxt = soup.select_one("a.page-link[rel=next], .pagination a.next,"
90 + " a[rel=next]")
91 + if not nxt or not nxt.get("href"):
92 + break
93 + url = nxt["href"]
94 +
95 + # 2) Fiches détaillées : photos, description, statut, type exact
96 + for i, lst in enumerate(listings.values()):
97 + if i >= self.max_details:
98 + break
99 + try:
100 + detail = self.get(lst.url).text
101 + except Exception:
102 + continue
103 + imgs = [u for u in dict.fromkeys(IMG_RE.findall(detail))
104 + if not re.search(r"logo|favicon|icon|-\d+x\d+\.", u, re.I)]
105 + if imgs:
106 + lst.images = imgs[:30]
107 + dsoup = BeautifulSoup(detail, "html.parser")
108 + og = dsoup.find("meta", attrs={"property": "og:description"})
109 + desc_el = dsoup.select_one("#property-description-wrap .block-content-wrap")
110 + if desc_el:
111 + lst.description = desc_el.get_text(" ", strip=True)[:600]
112 + elif og and og.get("content"):
113 + lst.description = og["content"].strip()[:600]
114 + labels = [a.get_text(" ", strip=True)
115 + for a in dsoup.select(".property-labels-wrap a")]
116 + labels = list(dict.fromkeys(l for l in labels if l))
117 + if labels:
118 + lst.availability = ", ".join(labels)[:120].title()
119 + # type exact (« 1 Bedroom (3 1/2) ») dans le bloc Détails
120 + for li in dsoup.select(".detail-wrap li"):
121 + txt = li.get_text(" ", strip=True)
122 + if txt.lower().startswith("property type"):
123 + lst.unit_type = _unit_type(txt, "") or lst.unit_type
124 +
125 + return list(listings.values())
126 +
127 + # -- parsing d'une carte ---------------------------------------------------
128 + def _parse_card(self, it) -> Listing | None:
129 + a = it.select_one('a[href*="/property/"]')
130 + if not a:
131 + return None
132 + url = a["href"].split("?")[0]
133 + m = re.search(r"/property/([a-z0-9\-]+)/?$", url)
134 + if not m:
135 + return None
136 + slug = m.group(1)
137 + lid = it.get("data-listid") or ""
138 + if not lid:
139 + el = it.select_one("[data-listid]")
140 + lid = el.get("data-listid") if el else ""
141 +
142 + title_el = it.select_one(".item-title")
143 + addr_el = it.select_one(".item-address")
144 + price_el = it.select_one(".item-price")
145 + title = title_el.get_text(" ", strip=True) if title_el else slug
146 + address = addr_el.get_text(" ", strip=True) if addr_el else ""
147 + price_label = price_el.get_text(" ", strip=True) if price_el else ""
148 +
149 + if re.search(r"parking|stationnement|commercial|garage", title, re.I):
150 + return None
151 +
152 + beds = baths = ""
153 + amenities: list[str] = []
154 + for li in it.select(".item-amenities li"):
155 + txt = li.get_text(" ", strip=True)
156 + if re.match(r"bed", txt, re.I):
157 + beds = txt
158 + elif re.match(r"bath", txt, re.I):
159 + baths = txt
160 + elif txt:
161 + amenities.append(txt)
162 +
163 + # « 3605 Rue Saint-Urbain, Montréal, QC, Canada » -> secteur/ville.
164 + # NB : « Mont-Royal » dans un titre désigne l'avenue/le Plateau, pas VMR ;
165 + # on ne détecte les villes défusionnées que dans l'adresse (+ Westmount/
166 + # Hampstead dans le titre, non ambigus).
167 + sector, city = "", "Montréal"
168 + parts = [p.strip() for p in address.split(",")]
169 + locality = parts[1] if len(parts) >= 2 else ""
170 + city = _INDEPENDENT_CITIES.get(locality.lower(), "")
171 + if not city:
172 + for key in ("westmount", "hampstead"):
173 + if key in f"{address} {title}".lower():
174 + city = _INDEPENDENT_CITIES[key]
175 + break
176 + if not city:
177 + city = "Montréal"
178 + if (locality and not locality.lower().startswith(("montr", "qc"))
179 + and not re.search(r"\d", locality)):
180 + sector = locality
181 +
182 + # secteur depuis le titre si absent (quartiers connus de Cromwell)
183 + if city == "Montréal" and not sector:
184 + m2 = re.search(r"(Plateau(?:\s+Mont-Royal)?|Outremont|Downtown|"
185 + r"Golden Square Mile|Mile End|C[oô]te-des-Neiges|"
186 + r"Snowdon)", title, re.I)
187 + if m2:
188 + sector = m2.group(1)
189 +
190 + images: list[str] = []
191 + img = it.select_one("img[data-src], img[src^='https']")
192 + if img:
193 + src = img.get("data-src") or img.get("src") or ""
194 + if src.startswith("https"):
195 + images.append(src)
196 +
197 + return Listing(
198 + source=self.source_id,
199 + external_id=lid or slug,
200 + url=url,
201 + title=title,
202 + address=address,
203 + sector=sector,
204 + city=city,
205 + unit_type=_unit_type(title, beds),
206 + price=_parse_price_us(price_label),
207 + price_label=price_label,
208 + availability="",
209 + amenities=amenities,
210 + images=images,
211 + )
added louka/connectors/denux.py +234 −0
@@ -0,0 +1,234 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/denux.py : connecteur Groupe Denux (groupedenux.com)
5 +# Portefeuille pancanadien (plateforme Rentsync / The Lift System).
6 +# Seules les villes québécoises sont interrogées (Montréal, Saint-Lambert,
7 +# Mascouche) — la Colombie-Britannique, l'Alberta et la France sont exclues
8 +# d'office. Découverte des immeubles via l'API JSON du site
9 +# (api.theliftsystem.com/v2/search, jeton public embarqué dans le JS du
10 +# site), puis parsing des fiches /residential/<slug> rendues serveur :
11 +# suites disponibles (div.suite[data-suite-id] avec type, prix, chambres,
12 +# date de disponibilité), commodités et galerie photos.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import html as htmllib
17 +import re
18 +
19 +from bs4 import BeautifulSoup
20 +
21 +from ..schema import Listing
22 +from .base import BaseConnector
23 +
24 +SITE = "https://www.groupedenux.com"
25 +API = "https://api.theliftsystem.com/v2/search"
26 +AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # jeton public (scripts/main.js du site)
27 +CLIENT_ID = "654"
28 +
29 +# Villes québécoises desservies (id Lift System -> nom normalisé)
30 +QC_CITIES = {
31 + "1863": "Montréal",
32 + "2789": "Saint-Lambert",
33 + "1741": "Mascouche",
34 +}
35 +
36 +_GALLERY_RE = re.compile(
37 + r'https://assets\.rentsync\.com/groupe_denux/images/gallery/'
38 + r'[0-9]+/[^"\'\s\\)]+\.(?:jpg|jpeg|png|webp)', re.I)
39 +_HALF_RE = re.compile(r"(\d)\s*(?:½|1/2|[.,]5)")
40 +
41 +# Nombre de chambres -> type d'unité (à défaut d'un « X.5 » dans le libellé)
42 +_BED_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}
43 +
44 +
45 +def _clean(txt: str) -> str:
46 + txt = htmllib.unescape(htmllib.unescape(txt or ""))
47 + txt = re.sub(r"<[^>]+>", " ", txt)
48 + return re.sub(r"\s+", " ", txt).strip()
49 +
50 +
51 +class DenuxConnector(BaseConnector):
52 + source_id = "denux"
53 + request_delay = 0.6
54 + max_buildings = 40 # garde-fou de crawl
55 +
56 + # -- API de recherche (backend officiel du site) ---------------------------
57 + def _search_city(self, city_id: str) -> list[dict]:
58 + params = {
59 + "locale": "fr",
60 + "client_id": CLIENT_ID,
61 + "auth_token": AUTH_TOKEN,
62 + "city_id": city_id,
63 + "geocode": "",
64 + "min_bed": "-1", "max_bed": "100",
65 + "min_bath": "-1", "max_bath": "10",
66 + "min_rate": "0", "max_rate": "100000",
67 + "property_types": "apartments, houses",
68 + "order": "min_rate ASC",
69 + "limit": "66", "offset": "0",
70 + "count": "false",
71 + "show_all_properties": "true",
72 + }
73 + data = self.get(API, params=params).json()
74 + return data if isinstance(data, list) else []
75 +
76 + def fetch(self) -> list[Listing]:
77 + listings: list[Listing] = []
78 + buildings: list[dict] = []
79 + for city_id in QC_CITIES:
80 + try:
81 + buildings.extend(self._search_city(city_id))
82 + except Exception:
83 + continue
84 +
85 + seen: set = set()
86 + for i, b in enumerate(buildings):
87 + if i >= self.max_buildings:
88 + break
89 + try:
90 + bid = b.get("id")
91 + if bid in seen:
92 + continue
93 + seen.add(bid)
94 + addr = b.get("address") or {}
95 + # Garde-fou : Québec seulement (exclut C.-B., Alberta, France)
96 + if (addr.get("province_code") or "").upper() != "QC":
97 + continue
98 + if int(b.get("availability_count") or 0) <= 0:
99 + continue # aucune unité disponible
100 + listings.extend(self._parse_building(b))
101 + except Exception:
102 + continue
103 + return listings
104 +
105 + # -- fiche immeuble : suites rendues serveur --------------------------------
106 + def _parse_building(self, b: dict) -> list[Listing]:
107 + slug = (b.get("permalink") or "").rstrip("/").split("/")[-1]
108 + if not slug:
109 + return []
110 + url = f"{SITE}/residential/{slug}"
111 +
112 + addr = b.get("address") or {}
113 + name = _clean(b.get("name") or slug)
114 + address = _clean(addr.get("address") or "")
115 + city = _clean(addr.get("city") or "")
116 + sector = _clean(addr.get("neighbourhood") or "")
117 + if sector.isupper():
118 + sector = sector.title()
119 + if sector.lower() in ("", city.lower(), "montreal", "montréal"):
120 + sector = ""
121 +
122 + details = b.get("details") or {}
123 + description = _clean(details.get("overview") or "")[:600]
124 +
125 + geo = b.get("geocode") or {}
126 + try:
127 + lat, lng = float(geo.get("latitude")), float(geo.get("longitude"))
128 + except (TypeError, ValueError):
129 + lat = lng = None
130 +
131 + html = self.get(url).text
132 + soup = BeautifulSoup(html, "html.parser")
133 +
134 + # Commodités (suite + immeuble)
135 + amenities = [el.get_text(" ", strip=True)
136 + for el in soup.select(".amenities .amenity-holder")]
137 + amenities = [a for a in dict.fromkeys(amenities) if a][:25]
138 +
139 + # Galerie photos (dédupliquée par nom de fichier, tailles multiples)
140 + images: list[str] = []
141 + seen_files: set[str] = set()
142 + for u in _GALLERY_RE.findall(html):
143 + fname = u.rsplit("/", 1)[-1]
144 + if fname not in seen_files:
145 + seen_files.add(fname)
146 + images.append(u)
147 + images = images[:25]
148 +
149 + # Détails par suite (ul.suite-info) indexés par data-suite-id
150 + info_by_id: dict[str, dict[str, str]] = {}
151 + for ul in soup.select("ul.suite-info[data-suite-id]"):
152 + fields: dict[str, str] = {}
153 + for li in ul.select("li.info-block"):
154 + lab = li.select_one(".label")
155 + val = li.select_one(".info")
156 + if not (lab and val):
157 + continue
158 + # Le champ « Availability » contient un lien + un modal de
159 + # formulaire : ne garder que le libellé du lien.
160 + link = val.select_one("a.open-suite-modal")
161 + text = (link.get_text(" ", strip=True) if link
162 + else val.get_text(" ", strip=True))
163 + fields[lab.get_text(strip=True).lower()] = text[:80].strip()
164 + info_by_id[ul.get("data-suite-id") or ""] = fields
165 +
166 + results: list[Listing] = []
167 + for div in soup.select("div.suite[data-suite-id]"):
168 + sid = div.get("data-suite-id") or ""
169 + if not sid:
170 + continue
171 + type_el = div.select_one(".suite-type")
172 + rate_el = div.select_one(".suite-rate")
173 + suite_label = _clean(type_el.get_text(" ", strip=True)
174 + if type_el else "")
175 + # Nettoyage du libellé (certains contiennent dispo + prix)
176 + suite_label = re.sub(r"\s*[-–]?\s*Starting at\s*\$[\d,]+", "",
177 + suite_label, flags=re.I)
178 + suite_label = re.sub(r"\s*[-–]?\s*Available\s+(now|immediately)\b",
179 + "", suite_label, flags=re.I).strip(" -–,")
180 + info = info_by_id.get(sid, {})
181 +
182 + # Type d'unité : « Grand 5.5, balcon... » -> 5½, sinon nb chambres
183 + unit_type = ""
184 + hm = _HALF_RE.search(suite_label)
185 + if hm:
186 + unit_type = f"{hm.group(1)}½"
187 + else:
188 + beds_txt = (info.get("bedrooms") or
189 + (div.get("class") and
190 + next((c.replace("beds_", "")
191 + for c in div.get("class")
192 + if c.startswith("beds_")), "")) or "")
193 + try:
194 + unit_type = _BED_TYPE.get(int(beds_txt), "")
195 + except (ValueError, TypeError):
196 + unit_type = ""
197 +
198 + # Prix : « $1,320 » (à partir de)
199 + price = None
200 + price_label = ""
201 + if rate_el:
202 + raw = rate_el.get_text(" ", strip=True)
203 + digits = re.sub(r"[^\d.]", "", raw)
204 + if digits:
205 + try:
206 + price = float(digits)
207 + except ValueError:
208 + price = None
209 + price_label = f"À partir de {raw}/mois"
210 + if price is not None and not (100 <= price <= 20000):
211 + price = None
212 +
213 + availability = info.get("availability", "") or \
214 + _clean(b.get("availability_status_label") or "")
215 +
216 + results.append(Listing(
217 + source=self.source_id,
218 + external_id=str(sid),
219 + url=url,
220 + title=f"{name}{suite_label}" if suite_label else name,
221 + address=address,
222 + sector=sector,
223 + city=city or QC_CITIES.get(str(addr.get("city_id")), ""),
224 + unit_type=unit_type,
225 + price=price,
226 + price_label=price_label,
227 + availability=availability,
228 + description=description,
229 + amenities=amenities,
230 + images=images,
231 + lat=lat,
232 + lng=lng,
233 + ))
234 + return results
added louka/connectors/devimco.py +180 −0
@@ -0,0 +1,180 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/devimco.py : connecteur Devimco Appartements
5 +# (devimco.com/appartements — St Ann & Hexagone à Griffintown, Alexander &
6 +# Maestria au centre-ville de Montréal, Éolia / Luméo / Nobel à Brossard,
7 +# Ostral à Longueuil).
8 +# Chaque page projet embarque un iframe Planpoint (app.planpoint.io) ;
9 +# l'inventaire complet s'obtient via l'API JSON de Planpoint :
10 +# POST https://app.planpoint.io/api/projects/find {namespace, hostName}
11 +# -> floors[] -> units[] (prix, chambres, photos, disponibilité...).
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +import time
17 +
18 +from ..schema import Listing
19 +from .base import BaseConnector
20 +
21 +BASE = "https://devimco.com"
22 +HUB_URL = f"{BASE}/appartements"
23 +PLANPOINT_FIND = "https://app.planpoint.io/api/projects/find"
24 +PLANPOINT_GROUP_FIND = "https://app.planpoint.io/api/groups/find"
25 +
26 +PROJECT_RE = re.compile(
27 + r"https://devimco\.com/appartements/a-louer/"
28 + r"(montreal|rive-sud-de-montreal)/([a-z\-]+)/([a-z0-9\-]+)")
29 +
30 +# secteur d'URL -> (ville, secteur affiché)
31 +SECTORS = {
32 + "centre-ville": ("Montréal", "Centre-ville"),
33 + "griffintown": ("Montréal", "Griffintown"),
34 + "brossard": ("Brossard", "Quartier DIX30"),
35 + "longueuil": ("Longueuil", "Vieux-Longueuil"),
36 +}
37 +
38 +
39 +def _unit_type(bedrooms: str) -> str:
40 + s = (bedrooms or "").lower()
41 + if "studio" in s:
42 + return "Studio"
43 + m = re.search(r"(\d+)", s)
44 + if not m:
45 + return bedrooms or ""
46 + n = int(m.group(1))
47 + return "Studio" if n == 0 else f"{n + 2}½"
48 +
49 +
50 +class DevimcoConnector(BaseConnector):
51 + source_id = "devimco"
52 + request_delay = 0.6
53 + max_projects = 15 # garde-fou
54 +
55 + def fetch(self) -> list[Listing]:
56 + listings: list[Listing] = []
57 + try:
58 + hub = self.get(HUB_URL).text
59 + except Exception:
60 + return listings
61 +
62 + # 1) Pages projets (a-louer/<région>/<secteur>/<projet>)
63 + projects: dict[str, tuple[str, str]] = {}
64 + for m in PROJECT_RE.finditer(hub):
65 + url = m.group(0)
66 + sector_slug, proj_slug = m.group(2), m.group(3)
67 + # ignorer les pages de secteur (sans slug projet "…-appartements")
68 + if proj_slug.endswith("-appartements"):
69 + projects[url] = (sector_slug, proj_slug)
70 +
71 + for i, (proj_url, (sector_slug, proj_slug)) in \
72 + enumerate(projects.items()):
73 + if i >= self.max_projects:
74 + break
75 + try:
76 + listings.extend(
77 + self._fetch_project(proj_url, sector_slug))
78 + except Exception:
79 + continue
80 + return listings
81 +
82 + # -- un projet ---------------------------------------------------------------
83 + def _fetch_project(self, proj_url: str, sector_slug: str) -> list[Listing]:
84 + out: list[Listing] = []
85 + html = self.get(proj_url).text
86 +
87 + # iframe Planpoint : soit un projet (/<ns>/<host>), soit un groupe
88 + # de phases (/g/<ns>)
89 + projects: list[dict] = []
90 + m = re.search(
91 + r"https://app\.planpoint\.io/g/([a-z0-9\-]+)", html)
92 + if m:
93 + group = self._planpoint(PLANPOINT_GROUP_FIND,
94 + {"namespace": m.group(1)})
95 + projects = group.get("projects") or []
96 + else:
97 + m = re.search(
98 + r"https://app\.planpoint\.io/([a-z0-9\-]+)/([a-z0-9\-]+)\?",
99 + html)
100 + if not m:
101 + return out
102 + projects = [self._planpoint(
103 + PLANPOINT_FIND,
104 + {"namespace": m.group(1), "hostName": m.group(2)})]
105 +
106 + city, sector = SECTORS.get(sector_slug, ("Montréal", sector_slug))
107 + for project in projects:
108 + try:
109 + out.extend(self._parse_units(project, proj_url, city, sector))
110 + except Exception:
111 + continue
112 + return out
113 +
114 + def _planpoint(self, endpoint: str, payload: dict) -> dict:
115 + wait = self.request_delay - (time.time() - self._last_request)
116 + if wait > 0:
117 + time.sleep(wait)
118 + resp = self.session.post(endpoint, json=payload, timeout=60)
119 + self._last_request = time.time()
120 + resp.raise_for_status()
121 + return resp.json() or {}
122 +
123 + def _parse_units(self, project: dict, proj_url: str,
124 + city: str, sector: str) -> list[Listing]:
125 + out: list[Listing] = []
126 + namespace = project.get("namespace") or ""
127 + name = project.get("name") or namespace
128 + address = (project.get("address") or "").split(", Quebec")[0]
129 + lat, lng = project.get("lat"), project.get("lng") or project.get("lon")
130 + for floor in project.get("floors") or []:
131 + floor_name = floor.get("name") or ""
132 + for u in floor.get("units") or []:
133 + try:
134 + if (u.get("availability") or "").lower() != "available":
135 + continue # loué / réservé / à venir
136 + price = u.get("price")
137 + if u.get("unitPriceTBD") or not price:
138 + continue # pas de prix affiché
139 + unit_name = str(u.get("name") or "")
140 + images = [img for img in
141 + (u.get("images") or []) +
142 + (u.get("layoutGallery") or [])
143 + if isinstance(img, str) and img.startswith("http")]
144 + inclusions = [s.strip() for s in
145 + (u.get("inclusions") or "").split(",")
146 + if s.strip()]
147 + sqft = u.get("squareFeet")
148 + amenities = list(inclusions)
149 + if sqft:
150 + amenities.append(f"{sqft} pi²")
151 + if u.get("bathrooms"):
152 + amenities.append(f"{u['bathrooms']} salle(s) de bain")
153 + if u.get("furnished"):
154 + amenities.append("Meublé")
155 + availability = "Disponible"
156 + if u.get("deliveryDate"):
157 + availability = f"Disponible : {u['deliveryDate']}"
158 + out.append(Listing(
159 + source=self.source_id,
160 + external_id=u.get("_id") or f"{namespace}-{unit_name}",
161 + url=proj_url,
162 + title=f"{name} — unité {unit_name}",
163 + address=address,
164 + sector=sector,
165 + city=city,
166 + unit_type=_unit_type(u.get("bedrooms") or ""),
167 + price=float(price)
168 + if 100 <= float(price) <= 20000 else None,
169 + price_label=f"{int(price)} $/mois",
170 + availability=availability,
171 + description=(f"Étage {floor_name} — "
172 + f"{u.get('orientation') or ''}").strip(" —"),
173 + amenities=list(dict.fromkeys(amenities)),
174 + images=list(dict.fromkeys(images)),
175 + lat=float(lat) if lat else None,
176 + lng=float(lng) if lng else None,
177 + ))
178 + except Exception:
179 + continue
180 + return out
added louka/connectors/dma_locago.py +207 −0
@@ -0,0 +1,207 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/dma_locago.py : connecteur DMA / Locago (Douville, Moffet & Associés)
5 +# locago.ca présente les complexes; les unités (avec prix/disponibilité) sont
6 +# embarquées dans les pages MapSVG de v1.dma.immo (JSON `data_db.objects`).
7 +# Une annonce par unité disponible; une annonce « projet » pour les complexes
8 +# sans sélecteur d'unités (IDOLA, Le Pivot, Tour Frontenac).
9 +# Luxo Place (Ottawa) est exclu — hors région Québec/Lévis.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import json
14 +import re
15 +
16 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
17 +from .base import BaseConnector
18 +
19 +V1 = "https://www.v1.dma.immo"
20 +LOCAGO = "https://locago.ca"
21 +
22 +_MONTHS = {
23 + "01": "janvier", "02": "février", "03": "mars", "04": "avril",
24 + "05": "mai", "06": "juin", "07": "juillet", "08": "août",
25 + "09": "septembre", "10": "octobre", "11": "novembre", "12": "décembre",
26 +}
27 +
28 +# Complexes avec sélecteur d'unités MapSVG sur v1.dma.immo
29 +# (slug, nom, secteur, mot-clé image locago, site vitrine pour photos)
30 +_UNIT_PAGES = [
31 + ("le-wow-unites", "Le WOW", "Sainte-Foy", "WOW", "https://lewow.ca"),
32 + ("la-suite-unites", "La Suite", "Sainte-Foy", "SUITE", "https://lasuite.ca"),
33 + ("domaine-des-meandres-unites", "Domaine des Méandres", "Lebourgneuf",
34 + "DDM", "https://info.domainedesmeandres.com"),
35 + ("vc-unites", "Villas Cortina", "Charlesbourg", "CORTINA",
36 + "https://info.villascortina.com"),
37 + ("le-divin-unites", "Le Divin", "Beauport", "DIVIN", "https://ledivin.ca"),
38 + ("le-divin-2-unites", "Le Divin (phase 2)", "Beauport", "DIVIN",
39 + "https://ledivin.ca"),
40 + ("ar-unites-01-02", "L'Aristocrate", "Lebourgneuf", "ARISTOCRATE",
41 + "https://info.laristocrate.ca"),
42 + ("ar-unites-03", "L'Aristocrate", "Lebourgneuf", "ARISTOCRATE",
43 + "https://info.laristocrate.ca"),
44 + ("ar-unites-04", "L'Aristocrate", "Lebourgneuf", "ARISTOCRATE",
45 + "https://info.laristocrate.ca"),
46 +]
47 +
48 +# Complexes présentés sur locago.ca sans sélecteur d'unités public
49 +# (id, nom, secteur, mot-clé image locago, url projet)
50 +_PROJECT_ONLY = [
51 + ("idola", "IDOLA", "Sainte-Foy", "IDOLA", "https://idola.ca/"),
52 + ("le-pivot", "Le Pivot", "Vanier", "PIVOT", "https://pivotqc.com/"),
53 + ("tour-frontenac", "Tour Frontenac", "Sainte-Foy", "frontenac",
54 + "https://appartsfrontenac.ca/"),
55 +]
56 +
57 +_IMG_RE = re.compile(
58 + r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|png|webp)',
59 + re.I)
60 +_SKIP_IMG = re.compile(r"logo|icon|favicon|dma-|-\d{2,3}x\d{2,3}\.", re.I)
61 +
62 +
63 +class DMALocagoConnector(BaseConnector):
64 + source_id = "dma_locago"
65 + request_delay = 0.6
66 + max_site_images = 12
67 +
68 + # -- helpers ---------------------------------------------------------------
69 + def _page_images(self, url: str) -> list[str]:
70 + """Photos (wp-content/uploads) d'une page, sans logos ni vignettes."""
71 + try:
72 + html = self.get(url).text
73 + except Exception:
74 + return []
75 + imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))
76 + if not _SKIP_IMG.search(u)]
77 + return imgs[: self.max_site_images]
78 +
79 + @staticmethod
80 + def _mapsvg_objects(html: str) -> list[dict]:
81 + """Extrait les unités des blocs `mapsvg_options = {...};` (data_db)."""
82 + objects: list[dict] = []
83 + dec = json.JSONDecoder()
84 + for m in re.finditer(r"mapsvg_options\s*=\s*", html):
85 + try:
86 + opts, _ = dec.raw_decode(html[m.end():])
87 + except Exception:
88 + continue
89 + data_db = opts.get("data_db") or {}
90 + if isinstance(data_db, dict):
91 + objects.extend(data_db.get("objects") or [])
92 + return objects
93 +
94 + @staticmethod
95 + def _unit_type(obj: dict) -> str:
96 + """'3.5' -> '3½' (avec repli sur typlog/descript)."""
97 + nb = str(obj.get("nbpiece") or "").strip().replace(",", ".")
98 + m = re.match(r"^(\d+)(\.5)?$", nb)
99 + if m:
100 + return f"{m.group(1)}½"
101 + raw = obj.get("descript") or obj.get("typlog") or ""
102 + if re.search(r"studio", str(raw), re.I):
103 + return "Studio"
104 + return normalize_unit_type(str(raw))
105 +
106 + @staticmethod
107 + def _availability(obj: dict) -> str:
108 + if obj.get("available") == "1":
109 + return "Libre immédiatement"
110 + month = _MONTHS.get(str(obj.get("available_month") or ""), "")
111 + year = str(obj.get("available_year") or "").strip()
112 + if month and year:
113 + return f"{month} {year}"
114 + return "Disponible prochainement"
115 +
116 + # -- fetch -----------------------------------------------------------------
117 + def fetch(self) -> list[Listing]:
118 + # 1) Images « héro » des complexes depuis locago.ca
119 + img_keys = {row[3] for row in _UNIT_PAGES + _PROJECT_ONLY}
120 + locago_imgs: dict[str, str] = {}
121 + try:
122 + home = self.get(LOCAGO).text
123 + for u in _IMG_RE.findall(home):
124 + if _SKIP_IMG.search(u):
125 + continue
126 + for key in img_keys:
127 + if key.lower() in u.lower() and key not in locago_imgs:
128 + locago_imgs[key] = u
129 + except Exception:
130 + pass
131 +
132 + # Photos supplémentaires par site vitrine (1 requête par site)
133 + site_imgs: dict[str, list[str]] = {}
134 +
135 + listings: dict[str, Listing] = {}
136 +
137 + # 2) Unités disponibles des pages MapSVG (v1.dma.immo)
138 + for slug, name, sector, img_key, site in _UNIT_PAGES:
139 + try:
140 + html = self.get(f"{V1}/{slug}/").text
141 + except Exception:
142 + continue
143 + if site not in site_imgs:
144 + site_imgs[site] = self._page_images(site)
145 + images = ([locago_imgs[img_key]] if img_key in locago_imgs else [])
146 + images += [u for u in site_imgs[site] if u not in images]
147 +
148 + for obj in self._mapsvg_objects(html):
149 + if str(obj.get("available")) not in ("1", "2"):
150 + continue # unité louée
151 + uid = str(obj.get("title") or "").strip()
152 + unite = str(obj.get("unite") or "").strip()
153 + if not uid or uid in listings:
154 + continue
155 + sup = str(obj.get("superficie") or "").strip()
156 + desc_parts = [str(obj.get("descript") or "").strip()]
157 + if sup:
158 + desc_parts.append(f"{sup} pi²")
159 + elif obj.get("sup_range"):
160 + desc_parts.append(str(obj["sup_range"]))
161 + if obj.get("orientation"):
162 + desc_parts.append(f"Orientation : {obj['orientation']}")
163 + startfrom = str(obj.get("startfrom") or "").strip()
164 + listings[uid] = Listing(
165 + source=self.source_id,
166 + external_id=uid,
167 + url=f"{V1}/{slug}/#unite-{unite or uid}",
168 + title=f"{name} — Unité {unite or uid}",
169 + address="",
170 + sector=sector,
171 + city=infer_city(sector),
172 + unit_type=self._unit_type(obj),
173 + price=parse_price(startfrom),
174 + price_label=f"À partir de {startfrom}" if startfrom else "",
175 + availability=self._availability(obj),
176 + description=" — ".join(p for p in desc_parts if p)[:600],
177 + images=images,
178 + )
179 +
180 + # 3) Annonces « projet » pour les complexes sans sélecteur d'unités
181 + for pid, name, sector, img_key, url in _PROJECT_ONLY:
182 + try:
183 + images = ([locago_imgs[img_key]] if img_key in locago_imgs
184 + else [])
185 + images += [u for u in self._page_images(url)
186 + if u not in images]
187 + listings[f"projet-{pid}"] = Listing(
188 + source=self.source_id,
189 + external_id=f"projet-{pid}",
190 + url=url,
191 + title=name,
192 + address="",
193 + sector=sector,
194 + city=infer_city(sector),
195 + unit_type="",
196 + price=None,
197 + price_label="",
198 + availability="",
199 + description=(f"Complexe locatif {name} ({sector}) géré par "
200 + "DMA / Locago — voir le site du projet pour "
201 + "les unités disponibles."),
202 + images=images,
203 + )
204 + except Exception:
205 + continue
206 +
207 + return list(listings.values())
added louka/connectors/elements.py +126 −0
@@ -0,0 +1,126 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/elements.py : connecteur Quartier les Éléments
5 +# (quartierleselements.com — Lévis, secteur Saint-Romuald, 5 phases).
6 +# Navigation : phase -> étage (plan interactif <area data-available>)
7 +# -> fiche d'unité (type, superficie, prix).
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://www.quartierleselements.com"
19 +ROOT = f"{BASE}/appartements-condos-locatifs-levis"
20 +SECTOR = "Saint-Romuald"
21 +ADDRESS = "1432, rue de Jupiter, Lévis"
22 +
23 +
24 +class ElementsConnector(BaseConnector):
25 + source_id = "elements"
26 + request_delay = 0.5
27 + max_floor_pages = 40 # garde-fou
28 +
29 + def fetch(self) -> list[Listing]:
30 + # 1) Découvrir les pages d'étages de chaque phase
31 + floor_urls: list[str] = []
32 + for phase in range(1, 6):
33 + try:
34 + html = self.get(f"{ROOT}/phase-{phase}/").text
35 + except Exception:
36 + continue
37 + for path in sorted(set(re.findall(
38 + rf'href="(/appartements-condos-locatifs-levis/'
39 + rf'phase-{phase}/etage-\d+/)"', html))):
40 + floor_urls.append(BASE + path)
41 +
42 + # 2) Repérer les unités disponibles sur les plans d'étages
43 + unit_urls: list[str] = []
44 + for url in floor_urls[:self.max_floor_pages]:
45 + try:
46 + html = self.get(url).text
47 + except Exception:
48 + continue
49 + for tag in re.findall(r"<area\b[^>]*>", html, re.S):
50 + if 'data-available="1"' not in tag:
51 + continue
52 + m = re.search(r'href="([^"]+)"', tag)
53 + if m and m.group(1) not in unit_urls:
54 + unit_urls.append(m.group(1))
55 +
56 + # 3) Fiche de chaque unité disponible
57 + listings: list[Listing] = []
58 + for path in unit_urls:
59 + full_url = path if path.startswith("http") else BASE + path
60 + try:
61 + html = self.get(full_url).text
62 + except Exception:
63 + continue
64 + try:
65 + soup = BeautifulSoup(html, "html.parser")
66 + text = soup.get_text("\n", strip=True)
67 +
68 + num = re.search(r"Unité\s+(\w+)", text)
69 + unit_no = num.group(1) if num else path.strip("/").split("/")[-1]
70 + phase_m = re.search(r"phase-(\d)", path)
71 + phase = phase_m.group(1) if phase_m else "?"
72 +
73 + type_m = re.search(r"Grandeur\s*:\s*([^\n]+)", text)
74 + unit_type = normalize_unit_type(type_m.group(1)) if type_m else ""
75 +
76 + prix_line = ""
77 + pm = re.search(r"Prix\s*:\s*([^\n]+)", text)
78 + price = None
79 + if pm:
80 + prix_line = pm.group(1).strip()
81 + price = parse_price(prix_line)
82 +
83 + availability = "Disponible"
84 + am = re.search(r"pour\s+([a-zû]+\s+20\d\d)", prix_line, re.I)
85 + if am:
86 + availability = f"Disponible ({am.group(1)})"
87 +
88 + desc_parts = []
89 + for label in ("Superficie brute", "Superficie terrasse",
90 + "Superficie totale"):
91 + dm = re.search(rf"{label}\s*:\s*([^\n]+)", text)
92 + if dm:
93 + desc_parts.append(f"{label} : {dm.group(1).strip()}")
94 + extra = re.search(r"Avec boudoir|Avec bureau", text)
95 + amenities = [extra.group(0)] if extra else []
96 +
97 + imgs = re.findall(
98 + r'(?:src|href)="((?:https?://[^"]+|/)?uploads/[^"]+'
99 + r'\.(?:jpg|jpeg|png|webp))"', html, re.I)
100 + images = []
101 + for u in dict.fromkeys(imgs):
102 + if not u.startswith("http"):
103 + u = BASE + ("/" + u.lstrip("/"))
104 + images.append(u)
105 +
106 + listings.append(Listing(
107 + source=self.source_id,
108 + external_id=f"phase-{phase}-unite-{unit_no}",
109 + url=full_url,
110 + title=f"Quartier les Éléments — Phase {phase}, "
111 + f"unité {unit_no} ({unit_type})",
112 + address=ADDRESS,
113 + sector=SECTOR,
114 + city=infer_city(SECTOR),
115 + unit_type=unit_type,
116 + price=price,
117 + price_label=f"Prix : {prix_line}" if prix_line else "",
118 + availability=availability,
119 + description=" | ".join(desc_parts),
120 + amenities=amenities,
121 + images=images,
122 + ))
123 + except Exception:
124 + continue
125 +
126 + return listings
added louka/connectors/equinoxe_batimo.py +200 −0
@@ -0,0 +1,200 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/equinoxe_batimo.py : connecteur Collection Équinoxe (Batimo)
5 +# (collectionequinoxe.com — Équinoxe Saint-Elzéar / Lévesque / Lévesque O. /
6 +# Daniel-Johnson à Laval, Bois-Franc à Saint-Laurent (Montréal),
7 +# Le Carlyle à Mont-Royal, Le Westpark à Pointe-Claire).
8 +# La page /disponibilites/ affiche un tableau JetEngine paginé (10 unités
9 +# par page). La pagination passe par l'AJAX JetSmartFilters :
10 +# POST /wp-admin/admin-ajax.php action=jet_smart_filters,
11 +# provider=jet-data-table/result, paged=N -> tbody HTML complet
12 +# (photos de l'unité incluses dans chaque rangée).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import json
17 +import re
18 +import time
19 +
20 +from bs4 import BeautifulSoup
21 +
22 +from ..schema import Listing, parse_price
23 +from .base import BaseConnector
24 +
25 +BASE = "https://collectionequinoxe.com"
26 +DISPO_URL = f"{BASE}/disponibilites/"
27 +AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"
28 +
29 +# Projet (colonne du tableau) -> (ville, secteur, page projet)
30 +PROJECTS = {
31 + "saint-elzear": ("Laval", "Chomedey", f"{BASE}/saint-elzear/"),
32 + "levesque ouest": ("Laval", "Laval-des-Rapides", f"{BASE}/levesqueouest/"),
33 + "levesque": ("Laval", "Duvernay", f"{BASE}/levesque/"),
34 + "daniel-johnson": ("Laval", "Chomedey", f"{BASE}/daniel-johnson/"),
35 + "bois-franc": ("Montréal", "Bois-Franc (Saint-Laurent)",
36 + f"{BASE}/boisfranc/"),
37 + "le carlyle": ("Mont-Royal", "Ville de Mont-Royal", f"{BASE}/carlyle/"),
38 + "carlyle": ("Mont-Royal", "Ville de Mont-Royal", f"{BASE}/carlyle/"),
39 + "le westpark": ("Pointe-Claire", "West Island", f"{BASE}/westpark/"),
40 + "westpark": ("Pointe-Claire", "West Island", f"{BASE}/westpark/"),
41 +}
42 +
43 +
44 +def _project_meta(project: str) -> tuple[str, str, str]:
45 + key = project.strip().lower()
46 + key = (key.replace("é", "e").replace("è", "e").replace("ê", "e")
47 + .replace("ô", "o"))
48 + for name, meta in PROJECTS.items():
49 + if name in key:
50 + return meta
51 + return ("", "", DISPO_URL)
52 +
53 +
54 +def _unit_type(bedrooms: str, type_label: str) -> str:
55 + s = (type_label or "").lower()
56 + if "studio" in s:
57 + return "Studio"
58 + try:
59 + n = int(str(bedrooms).strip())
60 + except (TypeError, ValueError):
61 + return ""
62 + if n <= 0:
63 + return "Studio"
64 + return f"{n + 2}½"
65 +
66 +
67 +class EquinoxeBatimoConnector(BaseConnector):
68 + source_id = "equinoxe_batimo"
69 + request_delay = 0.6
70 + max_pages = 80 # garde-fou (~10 unités/page)
71 +
72 + def fetch(self) -> list[Listing]:
73 + listings: dict[str, Listing] = {}
74 +
75 + # 1) Page initiale : rangées 1-10 + nombre total de pages
76 + try:
77 + html = self.get(DISPO_URL).text
78 + except Exception:
79 + return []
80 + max_pages = 1
81 + m = re.search(r"var JetSmartFilterSettings = (\{.*?\});", html, re.S)
82 + if m:
83 + try:
84 + settings, _ = json.JSONDecoder().raw_decode(m.group(1))
85 + max_pages = int(settings["props"]["jet-data-table"]["result"]
86 + ["max_num_pages"])
87 + except Exception:
88 + max_pages = 1
89 + self._parse_rows(html, listings)
90 +
91 + # 2) Pages suivantes via l'AJAX JetSmartFilters
92 + for page in range(2, min(max_pages, self.max_pages) + 1):
93 + try:
94 + frag = self._ajax_page(page)
95 + except Exception:
96 + continue
97 + if not frag.strip(): # plus de contenu -> stop
98 + break
99 + # NB : le tri du site n'est pas stable (égalités de loyer),
100 + # on ne s'arrête donc pas sur une page sans nouveauté.
101 + self._parse_rows(frag, listings)
102 +
103 + return list(listings.values())
104 +
105 + # -- AJAX -----------------------------------------------------------------
106 + def _ajax_page(self, page: int) -> str:
107 + wait = self.request_delay - (time.time() - self._last_request)
108 + if wait > 0:
109 + time.sleep(wait)
110 + resp = self.session.post(AJAX_URL, data={
111 + "action": "jet_smart_filters",
112 + "provider": "jet-data-table/result",
113 + "settings[table_id]": "6",
114 + "settings[thead]": "yes",
115 + "paged": str(page),
116 + }, headers={"X-Requested-With": "XMLHttpRequest"},
117 + timeout=self.timeout)
118 + self._last_request = time.time()
119 + resp.raise_for_status()
120 + return (resp.json() or {}).get("content") or ""
121 +
122 + # -- parsing du tableau -----------------------------------------------------
123 + def _parse_rows(self, html: str, listings: dict[str, Listing]) -> None:
124 + soup = BeautifulSoup(html, "html.parser")
125 + for row in soup.select("tr[data-item-object]"):
126 + try:
127 + ext_id = row.get("data-item-object") or ""
128 + if not ext_id or ext_id in listings:
129 + continue
130 +
131 + def col(name: str) -> str:
132 + el = row.select_one(f"td.jet-dynamic-table__col--{name}")
133 + return el.get_text(" ", strip=True) if el else ""
134 +
135 + unit_no = col("unit")
136 + type_label = col("type")
137 + sqft = col("pi")
138 + project = col("projet")
139 + phase = col("phase")
140 + rent = col("loyer")
141 + bedrooms = col("dechambres")
142 + bathrooms = col("desdb")
143 + dispo = col("disponibilit")
144 +
145 + if not project or not rent:
146 + continue
147 + city, sector, proj_url = _project_meta(project)
148 + if not city:
149 + continue # projet inconnu -> prudence
150 +
151 + # La colonne dispo contient parfois 2 dates (dispo + du jour)
152 + m = re.match(
153 + r"((?:\d{1,2}(?:er)?\s+\S+\s+\d{4})|Maintenant|Immédiate)",
154 + dispo, re.I)
155 + availability = m.group(1) if m else dispo.split(" ")[0]
156 +
157 + # Photos de l'unité (galerie de la rangée) + plan
158 + images = []
159 + for img in row.select("img"):
160 + u = img.get("src") or img.get("data-src") or ""
161 + if u.startswith("http") and not re.search(
162 + r"logo|icon|favicon", u, re.I):
163 + images.append(u)
164 + for a in row.select("a[href]"):
165 + href = a.get("href") or ""
166 + if re.search(r"/wp-content/uploads/.*\.(jpe?g|png|webp)$",
167 + href):
168 + images.append(href)
169 + images = list(dict.fromkeys(images))
170 +
171 + amenities = []
172 + if sqft:
173 + amenities.append(f"Superficie {sqft}")
174 + if bathrooms:
175 + amenities.append(f"{bathrooms} salle(s) de bain")
176 +
177 + phase_txt = f" (phase {phase})" if phase else ""
178 + title = (f"Équinoxe {project}{phase_txt} — unité {unit_no}"
179 + if "carlyle" not in project.lower()
180 + and "westpark" not in project.lower()
181 + else f"{project}{phase_txt} — unité {unit_no}")
182 +
183 + listings[ext_id] = Listing(
184 + source=self.source_id,
185 + external_id=ext_id,
186 + url=proj_url,
187 + title=title,
188 + address="",
189 + sector=sector,
190 + city=city,
191 + unit_type=_unit_type(bedrooms, type_label),
192 + price=parse_price(rent),
193 + price_label=f"{rent}/mois" if rent else "",
194 + availability=availability,
195 + description=type_label,
196 + amenities=amenities,
197 + images=images,
198 + )
199 + except Exception:
200 + continue
added louka/connectors/firma.py +227 −0
@@ -0,0 +1,227 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/firma.py : connecteur Groupe Firma (groupefirma.ca)
5 +# Condos locatifs en Montérégie / banlieue sud-ouest de Montréal
6 +# (Salaberry-de-Valleyfield, Saint-Zotique, Sainte-Barbe, L'Île-Perrot...).
7 +# Découverte des immeubles via l'API REST WordPress (post type "housing"),
8 +# puis parsing des fiches /logement/<slug>/ : unités <li id="unit-NNN">
9 +# avec type, disponibilité, étage, superficie, commodités (icônes bjm-active)
10 +# et images (plan + galerie de l'unité). Le prix est présent dans un
11 +# commentaire HTML "<!-- - 1380$ / mois -->" (souvent vide).
12 +# Lachute (Laurentides, hors Grand Montréal) est exclue.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import html as htmllib
17 +import re
18 +
19 +from bs4 import BeautifulSoup
20 +
21 +from ..schema import Listing, normalize_unit_type, parse_price, strip_accents
22 +from .base import BaseConnector
23 +
24 +BASE = "https://groupefirma.ca"
25 +API_URL = f"{BASE}/wp-json/wp/v2/housing?per_page=100"
26 +
27 +# Municipalités admissibles (Montérégie sud-ouest / Grand Montréal).
28 +_ALLOWED_CITY_KEYS = (
29 + "valleyfield", "saint-zotique", "st-zotique", "sainte-barbe", "ste-barbe",
30 + "saint-louis-de-gonzague", "st-louis-de-gonzague", "ile-perrot",
31 + "saint-timothee", "st-timothee", "les-coteaux", "coteau-du-lac",
32 + "vaudreuil", "pincourt", "beauharnois", "chateauguay",
33 +)
34 +
35 +# Normalisation des noms de villes rencontrés dans les adresses.
36 +_CITY_CANON = {
37 + "valleyfield": "Salaberry-de-Valleyfield",
38 + "salaberry-de-valleyfield": "Salaberry-de-Valleyfield",
39 + "saint-zotique": "Saint-Zotique",
40 + "st-zotique": "Saint-Zotique",
41 + "ste-barbe": "Sainte-Barbe",
42 + "sainte-barbe": "Sainte-Barbe",
43 + "st-louis-de-gonzague": "Saint-Louis-de-Gonzague",
44 + "saint-louis-de-gonzague": "Saint-Louis-de-Gonzague",
45 + "st-timothee": "Saint-Timothée",
46 + "saint-timothee": "Saint-Timothée",
47 + "l'ile-perrot": "L'Île-Perrot",
48 + "lile-perrot": "L'Île-Perrot",
49 + "ile-perrot": "L'Île-Perrot",
50 +}
51 +
52 +_PRICE_COMMENT_RE = re.compile(r"<!--\s*-?\s*([\d\s,.]*)\$\s*/\s*mois\s*-->")
53 +_IMG_RE = re.compile(r"\.(?:jpg|jpeg|png|webp)(?:$|\?)", re.I)
54 +_ICON_RE = re.compile(r"-ico\.|icon|logo|favicon", re.I)
55 +
56 +
57 +def _canon_city(raw: str) -> str:
58 + key = strip_accents(raw.strip().lower()).replace("’", "'")
59 + key = re.sub(r"\s+qc\.?$", "", key).strip(" ,")
60 + return _CITY_CANON.get(key, raw.strip(" ,"))
61 +
62 +
63 +def _city_allowed(raw: str) -> bool:
64 + key = strip_accents(raw.strip().lower()).replace(" ", "-").replace("’", "'")
65 + return any(tok in key for tok in _ALLOWED_CITY_KEYS)
66 +
67 +
68 +class FirmaConnector(BaseConnector):
69 + source_id = "firma"
70 + request_delay = 0.6
71 + max_buildings = 40 # garde-fou de crawl
72 +
73 + def fetch(self) -> list[Listing]:
74 + listings: list[Listing] = []
75 + try:
76 + buildings = self.get(API_URL).json()
77 + except Exception:
78 + return listings
79 + if not isinstance(buildings, list):
80 + return listings
81 +
82 + for i, b in enumerate(buildings):
83 + if i >= self.max_buildings:
84 + break
85 + try:
86 + listings.extend(self._parse_building(b))
87 + except Exception:
88 + continue
89 + return listings
90 +
91 + # -- une fiche d'immeuble --------------------------------------------------
92 + def _parse_building(self, b: dict) -> list[Listing]:
93 + slug = b.get("slug") or ""
94 + url = b.get("link") or f"{BASE}/logement/{slug}/"
95 + name = htmllib.unescape((b.get("title") or {}).get("rendered") or slug)
96 + name = re.sub(r"\s+", " ", name).strip()
97 +
98 + html = self.get(url).text
99 + soup = BeautifulSoup(html, "html.parser")
100 +
101 + # Bloc d'en-tête : nom + adresse civique de l'immeuble
102 + address = ""
103 + addr_el = soup.select_one(".bjm-singlehousing-details-bluebox-address")
104 + if addr_el:
105 + address = addr_el.get_text(" ", strip=True)
106 + city_raw = address.split(",")[-1].strip() if "," in address else ""
107 + if not city_raw or not _city_allowed(city_raw):
108 + return [] # hors Grand Montréal (ex. Lachute)
109 + city = _canon_city(city_raw)
110 +
111 + desc_el = soup.select_one(".bjm-singlehousing-details-description")
112 + description = (desc_el.get_text(" ", strip=True)[:600] if desc_el else "")
113 +
114 + # Commodités de l'immeuble (icônes actives du bloc de détails)
115 + building_amenities: list[str] = []
116 + for item in soup.select(".bjm-singlehousing-details-icons-item"):
117 + img = item.select_one("img.bjm-active")
118 + if img:
119 + label = item.get_text(" ", strip=True)
120 + if label:
121 + building_amenities.append(label)
122 +
123 + # Images de secours : galerie « photos du projet »
124 + fallback_imgs = self._section_images(soup.find(id="bjm-singlehousing-photos"))
125 +
126 + results: list[Listing] = []
127 + for li in soup.select('li[id^="unit-"]'):
128 + lst = self._parse_unit(li, slug, url, name, address, city,
129 + description, building_amenities,
130 + fallback_imgs)
131 + if lst:
132 + results.append(lst)
133 + return results
134 +
135 + # -- une unité (li id="unit-NNN") -----------------------------------------
136 + def _parse_unit(self, li, slug: str, url: str, name: str, address: str,
137 + city: str, description: str, building_amenities: list[str],
138 + fallback_imgs: list[str]) -> Listing | None:
139 + unit_no = (li.get("id") or "").replace("unit-", "").strip()
140 + if not unit_no:
141 + return None
142 +
143 + num_el = li.select_one(".bjm-unit-number")
144 + label = num_el.get_text(strip=True).lstrip("#") if num_el else unit_no
145 +
146 + h3 = li.find("h3")
147 + type_raw = h3.get_text(" ", strip=True) if h3 else ""
148 + unit_type = normalize_unit_type(type_raw)
149 +
150 + # Prix : masqué dans un commentaire HTML "<!-- - 1380$ / mois -->"
151 + price = None
152 + price_label = ""
153 + m = _PRICE_COMMENT_RE.search(str(li))
154 + if m and re.search(r"\d", m.group(1)):
155 + price_label = f"{m.group(1).strip()}$ / mois"
156 + price = parse_price(price_label)
157 +
158 + # Disponibilité : badge + date du bloc de texte
159 + avail_el = li.select_one(".bjm-det-label")
160 + availability = avail_el.get_text(" ", strip=True) if avail_el else ""
161 + details_txt = li.get_text(" ", strip=True)
162 + dm = re.search(r"Disponibilit[ée]\s*:\s*([\d/]+)", details_txt)
163 + if dm:
164 + availability = (f"{availability} ({dm.group(1)})"
165 + if availability else dm.group(1))
166 +
167 + # Description enrichie : étage / superficie
168 + extras = []
169 + fm = re.search(r"[ÉE]tage\s*:\s*([^\s].{0,30}?)(?:\s{2,}|Superficie)",
170 + details_txt)
171 + if fm:
172 + extras.append(f"Étage : {fm.group(1).strip()}")
173 + sm = re.search(r"Superficie\s*:\s*(\d[\d\s.,]*)", details_txt)
174 + if sm:
175 + extras.append(f"Superficie : {sm.group(1).strip()} pi²")
176 + unit_desc = " | ".join(extras)
177 + full_desc = " — ".join(p for p in (unit_desc, description) if p)[:600]
178 +
179 + # Commodités de l'unité : icônes actives seulement
180 + amenities = [img.get("alt", "").strip()
181 + for img in li.select("img.bjm-active[alt]")
182 + if img.get("alt", "").strip()]
183 + amenities = list(dict.fromkeys(amenities + building_amenities))
184 +
185 + # Images : plan + galerie propres à l'unité, sinon photos du projet
186 + images = self._section_images(li)
187 + if not images:
188 + images = fallback_imgs
189 +
190 + return Listing(
191 + source=self.source_id,
192 + external_id=f"{slug}-{unit_no}",
193 + url=url,
194 + title=f"{name}{unit_type or 'unité'} #{label}".strip(),
195 + address=address,
196 + sector="",
197 + city=city,
198 + unit_type=unit_type,
199 + price=price,
200 + price_label=price_label,
201 + availability=availability,
202 + description=full_desc,
203 + amenities=amenities,
204 + images=images,
205 + )
206 +
207 + # -- images d'une section (liens lightbox + <img>) -------------------------
208 + @staticmethod
209 + def _section_images(node) -> list[str]:
210 + if node is None:
211 + return []
212 + urls: list[str] = []
213 + for a in node.select("a[href]"):
214 + href = a.get("href") or ""
215 + if _IMG_RE.search(href):
216 + urls.append(href)
217 + for img in node.select("img[src]"):
218 + src = img.get("src") or ""
219 + if _IMG_RE.search(src) and "uploads" in src:
220 + urls.append(src)
221 + out: list[str] = []
222 + for u in urls:
223 + if not u.startswith("http"):
224 + u = BASE + u
225 + if not _ICON_RE.search(u):
226 + out.append(u)
227 + return list(dict.fromkeys(out))[:25]
added louka/connectors/gestion_montreal.py +200 −0
@@ -0,0 +1,200 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/gestion_montreal.py : connecteur Gestion Montréal
5 +# (gestion-montreal.com — Montréal, Longueuil, Repentigny, La Prairie).
6 +# Site immosquare rendu serveur : la page /fr/inscriptions embarque un
7 +# GeoJSON complet (window.properties_geojson) avec prix, adresse, photos,
8 +# description et coordonnées — une seule requête suffit.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import html as html_lib
13 +import json
14 +import re
15 +
16 +from ..schema import Listing, normalize_unit_type, strip_accents
17 +from .base import BaseConnector
18 +
19 +BASE = "https://gestion-montreal.com"
20 +LIST_URL = f"{BASE}/fr/inscriptions"
21 +
22 +# Garde-fou géographique : bounding box du Grand Montréal (CMM approx.)
23 +BBOX = (-74.30, 45.15, -73.10, 45.85) # lng_min, lat_min, lng_max, lat_max
24 +
25 +# Nombre de chambres -> type d'unité normalisé
26 +_BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}
27 +
28 +
29 +def _fix_mojibake(s: str) -> str:
30 + """Répare 'Montrã©Al' -> 'Montréal' (UTF-8 lu en latin-1 chez la source,
31 + parfois re-capitalisé ensuite, d'où le 'ã©' minuscule)."""
32 + if not s:
33 + return ""
34 + for bad, good in (("é", "é"), ("ã©", "é"), ("è", "è"), ("ã¨", "è"),
35 + ("ô", "ô"), ("ã´", "ô"), ("î", "î"), ("ã®", "î"),
36 + ("É", "É"), ("à ", "à ")):
37 + s = s.replace(bad, good)
38 + return s.strip()
39 +
40 +
41 +def _norm_city(raw: str) -> str:
42 + city = _fix_mojibake(raw)
43 + if strip_accents(city).lower().startswith("montreal"):
44 + return "Montréal"
45 + return city
46 +
47 +
48 +# Quartiers/arrondissements connus (repérés dans le titre de l'annonce)
49 +_SECTORS = [
50 + "Centre-ville", "Vieux-Montréal", "Plateau-Mont-Royal", "Plateau",
51 + "Mile-End", "Mile End", "Griffintown", "Saint-Henri", "Petite-Italie",
52 + "Petite-Patrie", "Rosemont", "Hochelaga-Maisonneuve", "Hochelaga",
53 + "Mercier", "Tétreaultville", "Verdun", "Île-des-Sœurs", "LaSalle",
54 + "Lachine", "Villeray", "Parc-Extension", "Ahuntsic", "Cartierville",
55 + "Saint-Laurent", "Saint-Léonard", "Saint-Michel", "Anjou",
56 + "Montréal-Nord", "Rivière-des-Prairies", "Pointe-aux-Trembles",
57 + "Côte-des-Neiges", "Notre-Dame-de-Grâce", "NDG", "Outremont",
58 + "Westmount", "Ville-Marie", "Sud-Ouest", "Pointe-Saint-Charles",
59 + "Quartier latin", "Quartier des spectacles", "Vieux-Longueuil",
60 +]
61 +_SECTOR_RE = re.compile(
62 + "|".join(re.escape(s) for s in _SECTORS), re.IGNORECASE)
63 +
64 +
65 +def _strip_html(s: str) -> str:
66 + s = html_lib.unescape(html_lib.unescape(s or "")) # &amp;eacute; -> é
67 + s = re.sub(r"<[^>]+>", " ", s)
68 + return re.sub(r"\s+", " ", s).strip()
69 +
70 +
71 +def _sector_from_text(*texts: str) -> str:
72 + """Repère un quartier connu dans le titre (puis la description)."""
73 + for text in texts:
74 + m = _SECTOR_RE.search(_fix_mojibake(text or ""))
75 + if m:
76 + sector = m.group(0)
77 + # recapitalisation propre à partir de la liste de référence
78 + for ref in _SECTORS:
79 + if ref.lower() == sector.lower():
80 + return ref
81 + return sector
82 + return ""
83 +
84 +
85 +class GestionMontrealConnector(BaseConnector):
86 + source_id = "gestion_montreal"
87 + request_delay = 0.6
88 +
89 + def __init__(self) -> None:
90 + super().__init__()
91 + # gestion-montreal.com coupe la connexion dès que le User-Agent
92 + # contient un suffixe de type bot ("LouKaBot/1.0 (+courriel)") ;
93 + # on se présente donc avec un UA navigateur standard.
94 + self.session.headers["User-Agent"] = (
95 + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
96 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36")
97 +
98 + def fetch(self) -> list[Listing]:
99 + listings: list[Listing] = []
100 + try:
101 + html = self.get(LIST_URL).text
102 + except Exception:
103 + return listings
104 +
105 + m = re.search(r"window\.properties_geojson\s*=\s*", html)
106 + if not m:
107 + return listings
108 + try:
109 + features, _ = json.JSONDecoder().raw_decode(html[m.end():])
110 + except ValueError:
111 + return listings
112 +
113 + for feat in features:
114 + try:
115 + p = feat.get("properties") or {}
116 + ext_id = str(p.get("id") or "")
117 + slug = p.get("slug_link") or ""
118 + if not ext_id or not slug:
119 + continue
120 +
121 + # exclusions : déjà loué, non résidentiel
122 + flag = (p.get("property_flag") or {})
123 + if isinstance(flag, dict) and flag.get("fr") == "Loué":
124 + continue
125 + if p.get("sold_rented"):
126 + continue
127 + classification = ((p.get("property_classification") or {})
128 + .get("fr") or "")
129 + if re.search(r"stationnement|commercial|bureau|local|terrain|"
130 + r"industriel|garage|entrep[oô]t", classification, re.I):
131 + continue
132 +
133 + # garde-fou géographique (Grand Montréal seulement)
134 + coords = (feat.get("geometry") or {}).get("coordinates") or []
135 + lng, lat = (coords + [None, None])[:2]
136 + if lng is not None and lat is not None and not (
137 + BBOX[0] <= lng <= BBOX[2] and BBOX[1] <= lat <= BBOX[3]):
138 + continue
139 +
140 + city = _norm_city(p.get("locality") or "")
141 + title_i18n = p.get("title") or {}
142 + title = _fix_mojibake(title_i18n.get("fr")
143 + or title_i18n.get("en")
144 + or p.get("address_short") or "")
145 + desc_fr = ((p.get("description") or {}).get("fr") or "")
146 + sector = (p.get("sublocality") or "").strip() or \
147 + _sector_from_text(title, desc_fr[:800])
148 +
149 + # type d'unité : Studio/Chambre/Maison direct, sinon chambres
150 + if re.search(r"studio", classification, re.I):
151 + unit_type = "Studio"
152 + elif re.search(r"chambre", classification, re.I):
153 + unit_type = "Chambre"
154 + elif re.search(r"maison", classification, re.I):
155 + unit_type = "Maison"
156 + else:
157 + unit_type = _BEDROOMS_TO_TYPE.get(
158 + p.get("bedrooms"),
159 + normalize_unit_type(classification))
160 +
161 + price = p.get("price")
162 + price = float(price) if isinstance(price, (int, float)) else None
163 + price_label = ((p.get("prices_formatted") or {}).get("fr")
164 + or p.get("price_formatted") or "")
165 +
166 + desc_i18n = p.get("description") or {}
167 + description = _strip_html(desc_i18n.get("fr")
168 + or desc_i18n.get("en") or "")[:600]
169 +
170 + amenities: list[str] = []
171 + if re.search(r"meubl", classification, re.I):
172 + amenities.append("Meublé")
173 +
174 + images = [u for u in (p.get("assets") or [])
175 + if isinstance(u, str)
176 + and not re.search(r"placehold|logo|icon", u, re.I)]
177 +
178 + listings.append(Listing(
179 + source=self.source_id,
180 + external_id=ext_id,
181 + url=f"{BASE}/fr/inscriptions/{slug}",
182 + title=title or _fix_mojibake(p.get("address_short") or ""),
183 + address=_fix_mojibake(p.get("address_short")
184 + or p.get("address") or ""),
185 + sector=sector,
186 + city=city,
187 + unit_type=unit_type,
188 + price=price,
189 + price_label=price_label,
190 + availability=(p.get("availability_date") or "")[:10],
191 + description=description,
192 + amenities=amenities,
193 + images=list(dict.fromkeys(images)),
194 + lat=lat,
195 + lng=lng,
196 + ))
197 + except Exception:
198 + continue
199 +
200 + return listings
added louka/connectors/gestipro.py +161 −0
@@ -0,0 +1,161 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/gestipro.py : connecteur Gestipro (gestipro.info)
5 +# Site WordPress (thème Houzez) : liste paginée /a-louer/ avec fiches
6 +# « propriete ». Une annonce par unité; pages détail pour la galerie photos.
7 +# Stationnements, locaux commerciaux et rangements exclus.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type
16 +from .base import BaseConnector
17 +
18 +BASE = "https://gestipro.info"
19 +LIST_URL = f"{BASE}/a-louer/"
20 +
21 +IMG_RE = re.compile(
22 + r"https://gestipro\.info/wp-content/uploads/[^\"'\\\s\)]+"
23 + r"\.(?:jpg|jpeg|png|webp)", re.I)
24 +IMG_NOISE_RE = re.compile(r"logo|favicon|icon|cluster|-\d+x\d+\.", re.I)
25 +EXCLUDE_RE = re.compile(
26 + r"stationnement|parking|garage|rangement|entrepos|commercial|bureau|local",
27 + re.I)
28 +
29 +
30 +def _parse_price(text: str) -> float | None:
31 + """Gère « 2,427$/mois » (virgule = séparateur de milliers)."""
32 + if not text:
33 + return None
34 + s = text.replace(" ", " ").replace(" ", " ")
35 + s = re.sub(r"(\d),(\d{3})", r"\1\2", s)
36 + m = re.search(r"(\d[\d\s]*(?:[.,]\d{2})?)\s*\$", s)
37 + if not m:
38 + return None
39 + try:
40 + val = float(m.group(1).replace(" ", "").replace(",", "."))
41 + except ValueError:
42 + return None
43 + return val if 100 <= val <= 20000 else None
44 +
45 +
46 +class GestiproConnector(BaseConnector):
47 + source_id = "gestipro"
48 + request_delay = 0.5
49 + max_list_pages = 15 # garde-fou pagination
50 + max_details = 150 # garde-fou fiches détail
51 +
52 + def fetch(self) -> list[Listing]:
53 + # 1) Pagination : /a-louer/ puis /a-louer/page/N/
54 + first = self.get(LIST_URL).text
55 + pages = [first]
56 + nums = [int(n) for n in re.findall(r"/a-louer/page/(\d+)/", first)]
57 + last = min(max(nums) if nums else 1, self.max_list_pages)
58 + for n in range(2, last + 1):
59 + try:
60 + pages.append(self.get(f"{LIST_URL}page/{n}/").text)
61 + except Exception:
62 + continue
63 +
64 + # 2) Cartes Houzez
65 + listings: dict[str, Listing] = {}
66 + for page in pages:
67 + soup = BeautifulSoup(page, "html.parser")
68 + for card in soup.select("div.item-listing-wrap[data-hz-id]"):
69 + try:
70 + lst = self._parse_card(card)
71 + except Exception:
72 + continue
73 + if lst and lst.external_id not in listings:
74 + listings[lst.external_id] = lst
75 +
76 + # 3) Fiches détail : galerie complète + description
77 + for i, lst in enumerate(listings.values()):
78 + if i >= self.max_details:
79 + break
80 + try:
81 + self._enrich(lst)
82 + except Exception:
83 + continue
84 +
85 + return list(listings.values())
86 +
87 + def _parse_card(self, card) -> Listing | None:
88 + ext_id = card.get("data-hz-id", "").strip()
89 + title_a = card.select_one(".item-title a")
90 + if not ext_id or not title_a:
91 + return None
92 + url = title_a.get("href", "")
93 + title = title_a.get_text(" ", strip=True)
94 +
95 + type_el = card.select_one(".h-type span")
96 + unit_raw = type_el.get_text(" ", strip=True) if type_el else ""
97 +
98 + # Exclusions : stationnement, commercial, rangement...
99 + if EXCLUDE_RE.search(f"{title} {unit_raw} {url}"):
100 + return None
101 +
102 + addr_el = card.select_one(".item-address span") or \
103 + card.select_one(".item-address")
104 + addr_raw = addr_el.get_text(" ", strip=True) if addr_el else ""
105 + # « 7170 Boulevard Cloutier, Québec, QC, Canada, Charlesbourg, Québec »
106 + parts = [p.strip() for p in addr_raw.split(",") if p.strip()]
107 + address = parts[0] if parts else ""
108 + sector = ""
109 + for p in reversed(parts[1:]):
110 + if p not in ("Québec", "QC", "Canada", "Quebec", "Lévis", "Levis"):
111 + sector = p
112 + break
113 + city = "Lévis" if re.search(r"l[ée]vis", addr_raw, re.I) else "Québec"
114 +
115 + price_el = card.select_one(".item-price")
116 + price_label = price_el.get_text(" ", strip=True) if price_el else ""
117 +
118 + avail = ", ".join(a.get_text(" ", strip=True)
119 + for a in card.select(".label-status")[:2])
120 +
121 + img_el = card.select_one(".listing-thumb img")
122 + img = ""
123 + if img_el:
124 + img = img_el.get("data-src") or img_el.get("src") or ""
125 + if img.startswith("data:"):
126 + img = img_el.get("data-src") or ""
127 +
128 + return Listing(
129 + source=self.source_id,
130 + external_id=ext_id,
131 + url=url,
132 + title=title,
133 + address=address,
134 + sector=sector,
135 + city=infer_city(sector, default=city),
136 + unit_type=normalize_unit_type(unit_raw),
137 + price=_parse_price(price_label),
138 + price_label=price_label,
139 + availability=avail,
140 + images=[img] if img else [],
141 + )
142 +
143 + def _enrich(self, lst: Listing) -> None:
144 + html = self.get(lst.url).text
145 + soup = BeautifulSoup(html, "html.parser")
146 +
147 + og = soup.find("meta", attrs={"property": "og:description"}) or \
148 + soup.find("meta", attrs={"name": "description"})
149 + if og and og.get("content"):
150 + lst.description = og["content"].strip()[:600]
151 +
152 + # Commodités (bloc « Caractéristiques » Houzez)
153 + amen = [a.get_text(" ", strip=True)
154 + for a in soup.select("#property-features-wrap li a")]
155 + if amen:
156 + lst.amenities = [a for a in amen if a][:20]
157 +
158 + imgs = [u for u in dict.fromkeys(IMG_RE.findall(html))
159 + if not IMG_NOISE_RE.search(u)]
160 + if imgs:
161 + lst.images = imgs[:25]
added louka/connectors/gimcote.py +149 −0
@@ -0,0 +1,149 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/gimcote.py : connecteur GIM Côté inc. (gimcote.com)
5 +# WordPress + thème immobilier Houzez. Archive /property-type/appartement
6 +# paginée : chaque carte contient prix, adresse, statut, type et la galerie
7 +# complète d'images (attribut data-images).
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import html as htmllib
12 +import json
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
18 +from .base import BaseConnector
19 +
20 +BASE = "https://gimcote.com"
21 +LIST_URL = f"{BASE}/property-type/appartement/"
22 +
23 +_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)
24 +
25 +
26 +def _clean_price_label(label: str) -> str:
27 + """'1,450$/Par mois' -> '1450$' compatible parse_price (virgule = milliers)."""
28 + return re.sub(r"(\d),(\d{3})", r"\1\2", label)
29 +
30 +
31 +class GimCoteConnector(BaseConnector):
32 + source_id = "gimcote"
33 + request_delay = 0.6
34 + max_pages = 20 # garde-fou de pagination
35 +
36 + def fetch(self) -> list[Listing]:
37 + listings: dict[str, Listing] = {}
38 + for page in range(1, self.max_pages + 1):
39 + url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/"
40 + try:
41 + html = self.get(url).text
42 + except Exception:
43 + break
44 + soup = BeautifulSoup(html, "html.parser")
45 + cards = soup.select("div.item-listing-wrap")
46 + if not cards:
47 + break
48 + for card in cards:
49 + try:
50 + self._parse_card(card, listings)
51 + except Exception:
52 + continue
53 + return list(listings.values())
54 +
55 + # -- carte Houzez -----------------------------------------------------------
56 + def _parse_card(self, card, listings: dict[str, Listing]) -> None:
57 + link = card.select_one("h2.item-title a[href]")
58 + if not link:
59 + return
60 + url = link["href"]
61 + title = link.get_text(strip=True)
62 + m = re.search(r"/property/([^/]+)/?", url)
63 + slug = m.group(1) if m else ""
64 + listid_el = card.select_one("[data-listid]")
65 + ext_id = (listid_el.get("data-listid") if listid_el else "") or slug
66 + if not ext_id or ext_id in listings:
67 + return
68 +
69 + # exclusions : stationnement / commercial / rangement
70 + if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t",
71 + title, re.I):
72 + return
73 +
74 + # adresse complète (Nominatim) : "3345, Avenue du Colisée, Lairet,
75 + # La Cité-Limoilou, Quebec, Urban agglomeration of Québec, ..."
76 + addr_el = card.select_one("address.item-address")
77 + full_addr = addr_el.get_text(" ", strip=True) if addr_el else ""
78 + parts = [p.strip() for p in full_addr.split(",") if p.strip()]
79 + address = ", ".join(parts[:2]) if len(parts) >= 2 else full_addr
80 + if "lévis" in full_addr.lower() or "levis" in full_addr.lower():
81 + city = "Lévis"
82 + elif "québec" in full_addr.lower() or "quebec" in full_addr.lower() or not full_addr:
83 + city = "Québec"
84 + else:
85 + return # hors Québec / Lévis
86 + # secteur = micro-quartier + arrondissement (avant les mentions génériques)
87 + sector_parts = [p for p in parts[2:]
88 + if not re.search(r"^(quebec|québec|urban agglomeration|"
89 + r"capitale-nationale|chaudière-appalaches|"
90 + r"canada|g\d[a-z]\s?\d[a-z]\d)", p, re.I)]
91 + sector = ", ".join(sector_parts[:2])
92 + city = infer_city(sector, default=city)
93 +
94 + # statut / disponibilité — on saute les logements déjà loués
95 + status_el = card.select_one("a[href*='/status/']")
96 + availability = status_el.get_text(strip=True) if status_el else ""
97 + if re.search(r"lou[ée]", availability, re.I):
98 + return
99 +
100 + # type d'unité : le titre (rédigé par l'agence) prime sur l'étiquette,
101 + # parfois erronée ; repli sur l'étiquette /label/ de la carte
102 + type_el = card.select_one("a[href*='/label/']")
103 + unit_type = normalize_unit_type(title)
104 + if not re.fullmatch(r"\d½|Studio|Loft|Chambre|Maison", unit_type or ""):
105 + unit_type = normalize_unit_type(type_el.get_text(strip=True) if type_el else "")
106 + price_el = card.select_one("li.item-price")
107 + price_label = price_el.get_text(strip=True) if price_el else ""
108 +
109 + amenities = []
110 + for li in card.select("ul.item-amenities li"):
111 + t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))
112 + if t and t not in amenities:
113 + amenities.append(t)
114 +
115 + # galerie complète : attribut data-images (JSON, URLs redimensionnées)
116 + images: list[str] = []
117 + raw = card.get("data-images") or ""
118 + if raw:
119 + try:
120 + urls = json.loads(htmllib.unescape(raw))
121 + except Exception:
122 + urls = re.findall(r"https?:[^\"',\\]+", htmllib.unescape(raw))
123 + for u in urls:
124 + u = u.replace("\\/", "/").strip()
125 + if not u.startswith("http"):
126 + continue
127 + u = _SIZE_SUFFIX.sub("", u) # version pleine taille (WordPress)
128 + if u not in images:
129 + images.append(u)
130 + if not images:
131 + thumb = card.select_one("img.wp-post-image[src]")
132 + if thumb:
133 + images = [_SIZE_SUFFIX.sub("", thumb["src"])]
134 +
135 + listings[str(ext_id)] = Listing(
136 + source=self.source_id,
137 + external_id=str(ext_id),
138 + url=url,
139 + title=title,
140 + address=address,
141 + sector=sector,
142 + city=city,
143 + unit_type=unit_type,
144 + price=parse_price(_clean_price_label(price_label)),
145 + price_label=price_label,
146 + availability=availability,
147 + amenities=amenities,
148 + images=images[:30],
149 + )
added louka/connectors/gparadis.py +153 −0
@@ -0,0 +1,153 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/gparadis.py : connecteur GParadis (gparadis.com)
5 +# Secteurs : Montcalm, St-Sauveur, St-Roch, Limoilou, Vieux-Québec,
6 +# Ste-Foy, Duberger, Lévis. Les pages de secteurs (WordPress) décrivent
7 +# chaque immeuble (bloc wp-block-group + h5) avec adresses, disponibilités
8 +# (« 4 1/2 disponible MAINTENANT »), commodités et galeries de photos.
9 +# Une annonce par immeuble ayant des unités disponibles.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import re
14 +
15 +from bs4 import BeautifulSoup
16 +
17 +from ..schema import Listing, infer_city, normalize_unit_type, strip_accents
18 +from .base import BaseConnector
19 +
20 +BASE = "https://gparadis.com"
21 +SECTOR_PAGES = {
22 + "montcalm": "Montcalm",
23 + "saint-sauveur": "Saint-Sauveur",
24 + "limoilou": "Limoilou",
25 + "levis": "Lévis",
26 + "vieux-quebec": "Vieux-Québec",
27 + "ste-foy": "Ste-Foy",
28 + "duberger": "Duberger",
29 + "saint-roch": "Saint-Roch",
30 +}
31 +BAD_IMG_RE = re.compile(r"logo|icon|favicon|cropped-|gparadis-69fb", re.I)
32 +
33 +
34 +def _slugify(s: str) -> str:
35 + s = strip_accents(s.lower())
36 + return re.sub(r"[^a-z0-9]+", "-", s).strip("-")[:60]
37 +
38 +
39 +class GParadisConnector(BaseConnector):
40 + source_id = "gparadis"
41 + request_delay = 0.6
42 +
43 + def fetch(self) -> list[Listing]:
44 + listings: dict[str, Listing] = {}
45 + for slug, sector in SECTOR_PAGES.items():
46 + url = f"{BASE}/{slug}/"
47 + try:
48 + html = self.get(url).text
49 + except Exception:
50 + continue
51 + try:
52 + self._parse_sector(html, url, sector, listings)
53 + except Exception:
54 + continue
55 + return list(listings.values())
56 +
57 + def _parse_sector(self, html: str, url: str, sector: str,
58 + listings: dict[str, Listing]) -> None:
59 + soup = BeautifulSoup(html, "html.parser")
60 + seen_groups: set[int] = set()
61 +
62 + for h in soup.select("h5.wp-block-heading"):
63 + # le bloc « carte d'immeuble » = plus proche ancêtre wp-block-group
64 + # (le nom et l'adresse peuvent être dans des h5 distincts du bloc)
65 + group = h.find_parent("div", class_="wp-block-group")
66 + if group is None or id(group) in seen_groups:
67 + continue
68 + seen_groups.add(id(group))
69 + headings = group.find_all("h5", class_="wp-block-heading")
70 +
71 + text = re.sub(r"\s+", " ", group.get_text(" ", strip=True))
72 + # Immeubles complets : rien à annoncer
73 + if re.search(r"\bComplet\b", text) and \
74 + not re.search(r"disponible", text, re.I):
75 + continue
76 + # Disponibilités : « 5 1/2 disponible MAINTENANT », « ... le 1er juillet »
77 + avail_parts = re.findall(
78 + r"((?:\d\s*1/2|\d\s*½|Studio|Loft)"
79 + r"(?:\s*(?:,|et)\s*(?:\d\s*1/2|\d\s*½))*"
80 + r"\s*disponibles?\s*(?:MAINTENANT|le\s*1\s*er\s*[a-zû]+|"
81 + r"d[èe]s\s+maintenant)?)", text, re.I)
82 + if not avail_parts:
83 + continue # aucune unité disponible décrite
84 + availability = " ; ".join(p.strip() for p in avail_parts)[:200]
85 +
86 + # nom + adresse(s) : lignes des h5 du bloc (séparées par <br>)
87 + lines: list[str] = []
88 + for hh in headings:
89 + for l in re.split(r"<br\s*/?>", hh.decode_contents()):
90 + l = re.sub(r"<[^>]+>", "", l)
91 + l = (l.replace("&#8211;", "–").replace("&amp;", "&")
92 + .replace("&#8217;", "'").strip())
93 + if l:
94 + lines.append(l)
95 + name = ""
96 + addresses = []
97 + for l in lines:
98 + if re.match(r"^\d", l):
99 + addresses.append(l)
100 + elif not name:
101 + name = l
102 + address = addresses[0] if addresses else ""
103 + title = name or address
104 + if not title:
105 + continue
106 +
107 + # type d'unité : premier type disponible mentionné
108 + tm = re.search(r"(\d)\s*(?:1/2|½)", availability)
109 + unit_type = f"{tm.group(1)}½" if tm else ""
110 +
111 + # commodités
112 + amenities = [li.get_text(" ", strip=True)
113 + for li in group.select("li")
114 + if li.get_text(strip=True)][:15]
115 +
116 + # images de la galerie de l'immeuble
117 + images = []
118 + for im in group.select("img"):
119 + u = im.get("data-orig-file") or im.get("src") or ""
120 + u = u.split("?")[0]
121 + if u and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I) \
122 + and not BAD_IMG_RE.search(u):
123 + images.append(u)
124 + images = list(dict.fromkeys(images))[:25]
125 + if not images and len(soup.select("h5.wp-block-heading")) == 1:
126 + # page à immeuble unique : galerie au niveau de la page
127 + for im in soup.select("img"):
128 + u = (im.get("data-orig-file") or im.get("src") or "")
129 + u = u.split("?")[0]
130 + if u and re.search(r"\.(?:jpe?g|png|webp)$", u, re.I) \
131 + and not BAD_IMG_RE.search(u):
132 + images.append(u)
133 + images = list(dict.fromkeys(images))[:25]
134 +
135 + ext_id = f"{_slugify(sector)}-{_slugify(title if not addresses else f'{title}-{address}')}"
136 + if ext_id in listings:
137 + continue
138 + listings[ext_id] = Listing(
139 + source=self.source_id,
140 + external_id=ext_id,
141 + url=url,
142 + title=title,
143 + address=address,
144 + sector=sector,
145 + city=infer_city(sector),
146 + unit_type=normalize_unit_type(unit_type),
147 + price=None, # GParadis n'affiche pas les prix
148 + price_label="",
149 + availability=availability,
150 + description=text[:600],
151 + amenities=amenities,
152 + images=images,
153 + )
added louka/connectors/groupe_dallaire.py +253 −0
@@ -0,0 +1,253 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/groupe_dallaire.py : connecteur Groupe Dallaire — Faubourg du
5 +# Moulin (faubourgdumoulin.ca). Complexes Alizé, Noroît (I-II), Sirocco,
6 +# Sonora, Zéphyr (I-II) — rue Lionel-Audet, Québec (Beauport).
7 +# La page /disponibilite charge les unités via JetEngine (admin-ajax.php) :
8 +# on rejoue l'appel `get_listing` (lazy-load) puis la pagination
9 +# `jet_smart_filters` pour récupérer toutes les unités disponibles
10 +# (numéro, étage, prix, date de disponibilité, plan).
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import html as htmllib
15 +import json
16 +import re
17 +import time
18 +
19 +from bs4 import BeautifulSoup
20 +
21 +from ..schema import Listing, normalize_unit_type, parse_price
22 +from .base import BaseConnector
23 +
24 +BASE = "https://faubourgdumoulin.ca"
25 +AJAX = f"{BASE}/wp-admin/admin-ajax.php"
26 +DISPO_URL = f"{BASE}/disponibilite/"
27 +SECTOR = "Beauport"
28 +CITY = "Québec"
29 +ADDRESS = "rue Lionel-Audet, Québec (Faubourg du Moulin)"
30 +
31 +COMPLEXES = {
32 + "alize": "Alizé", "alize-2": "Alizé (Phase II)",
33 + "noroit": "Noroît", "noroit-2": "Noroît II",
34 + "sirocco": "Sirocco", "sonora": "Sonora",
35 + "zephyr": "Zéphyr", "zephyr-2": "Zéphyr II",
36 +}
37 +
38 +# paramètres du widget « dispo » de la page /disponibilite
39 +PAGE_SETTINGS = {
40 + "page_settings[post_id]": "105",
41 + "page_settings[queried_id]": "105|WP_Post",
42 + "page_settings[element_id]": "cb310a3",
43 + "page_settings[page]": "1",
44 + "listing_type": "elementor",
45 + "isEditMode": "false",
46 +}
47 +
48 +
49 +def _flatten(prefix: str, obj, out: dict) -> None:
50 + """Aplati un dict/list en champs de formulaire PHP (a[b][0]=x)."""
51 + if isinstance(obj, dict):
52 + for k, v in obj.items():
53 + _flatten(f"{prefix}[{k}]", v, out)
54 + elif isinstance(obj, list):
55 + for i, v in enumerate(obj):
56 + _flatten(f"{prefix}[{i}]", v, out)
57 + elif isinstance(obj, bool):
58 + out[prefix] = "true" if obj else "false"
59 + else:
60 + out[prefix] = "" if obj is None else str(obj)
61 +
62 +
63 +class GroupeDallaireConnector(BaseConnector):
64 + source_id = "groupe_dallaire"
65 + request_delay = 0.6
66 + max_pages = 25 # garde-fou pagination
67 + max_type_details = 180 # garde-fou fiches consultées pour le type
68 +
69 + # -- POST throttlé ---------------------------------------------------
70 + def _post(self, url: str, data: dict):
71 + wait = self.request_delay - (time.time() - self._last_request)
72 + if wait > 0:
73 + time.sleep(wait)
74 + resp = self.session.post(url, data=data, timeout=self.timeout)
75 + self._last_request = time.time()
76 + resp.raise_for_status()
77 + return resp
78 +
79 + # ---------------------------------------------------------------------
80 + def fetch(self) -> list[Listing]:
81 + # 1) Premier appel lazy-load : items page 1 + query/widget_settings
82 + first = self._post(AJAX, {
83 + "action": "jet_engine_ajax",
84 + "handler": "get_listing",
85 + **PAGE_SETTINGS,
86 + }).json()
87 + html1 = (first.get("data") or {}).get("html", "")
88 +
89 + nav = None
90 + m = re.search(r'data-nav="([^"]*)"', html1)
91 + if m:
92 + try:
93 + nav = json.loads(htmllib.unescape(m.group(1)))
94 + except Exception:
95 + nav = None
96 +
97 + listings: dict[str, Listing] = {}
98 + for lst in self._parse_items(html1):
99 + listings.setdefault(lst.external_id, lst)
100 +
101 + # 2) Pagination via jet_smart_filters (si le data-nav est disponible)
102 + if nav and nav.get("query") and nav.get("widget_settings"):
103 + page = 2
104 + while page <= self.max_pages:
105 + data = {
106 + "action": "jet_smart_filters",
107 + "provider": "jet-engine/dispo",
108 + "paged": str(page),
109 + }
110 + _flatten("defaults", nav["query"], data)
111 + _flatten("settings", nav["widget_settings"], data)
112 + try:
113 + resp = self._post(AJAX, data).json()
114 + except Exception:
115 + break
116 + content = resp.get("content") or ""
117 + new = 0
118 + for lst in self._parse_items(content):
119 + if lst.external_id not in listings:
120 + listings[lst.external_id] = lst
121 + new += 1
122 + pag = resp.get("pagination") or {}
123 + max_pages = int(pag.get("max_num_pages") or 0)
124 + if new == 0 or (max_pages and page >= max_pages):
125 + break
126 + page += 1
127 +
128 + # 3) Compléter le type d'unité manquant depuis la fiche
129 + # (« 3½ TYPE D'UNITÉ » n'apparaît que sur la page de l'unité)
130 + checked = 0
131 + for lst in listings.values():
132 + if lst.unit_type or checked >= self.max_type_details:
133 + continue
134 + checked += 1
135 + try:
136 + dhtml = self.get(lst.url).text
137 + except Exception:
138 + continue
139 + dtext = re.sub(r"<script.*?</script>", "", dhtml, flags=re.S)
140 + dtext = re.sub(r"<style.*?</style>", "", dtext, flags=re.S)
141 + dtext = re.sub(r"<[^>]+>", "\n", dtext)
142 + tm = re.search(r"(\d\s*½\s*\+?|Studio)\s*\n+\s*TYPE D['’]UNITÉ",
143 + dtext)
144 + if tm:
145 + lst.unit_type = normalize_unit_type(tm.group(1))
146 + if lst.unit_type and "½" not in lst.title:
147 + lst.title += f" ({lst.unit_type})"
148 + sm = re.search(r"(\d{3,4})\s*\n+\s*Pieds carrés", dtext)
149 + if sm:
150 + lst.description = (lst.description +
151 + f" | {sm.group(1)} pi²").strip(" |")
152 +
153 + return list(listings.values())
154 +
155 + # ---------------------------------------------------------------------
156 + def _parse_items(self, content: str) -> list[Listing]:
157 + out: list[Listing] = []
158 + if not content:
159 + return out
160 + soup = BeautifulSoup(content, "html.parser")
161 + for item in soup.select(".jet-listing-grid__item[data-post-id]"):
162 + try:
163 + a = item.select_one('a[href*="/unites/"]')
164 + if not a:
165 + continue
166 + url = a["href"]
167 + slug = url.rstrip("/").split("/")[-1]
168 +
169 + for st in item.select("style"):
170 + st.decompose()
171 + text = item.get_text(" ", strip=True)
172 +
173 + # Complexe depuis le slug (ex. sonora-101, noroit-2-151)
174 + cslug = re.match(r"([a-z]+(?:-2)?)-\d+", slug)
175 + complexe = COMPLEXES.get(cslug.group(1) if cslug else "", "")
176 + if not complexe and cslug:
177 + complexe = cslug.group(1).replace("-", " ").title()
178 +
179 + num_m = re.search(r"Unité\s+(\w+)\s*-?", text)
180 + unit_no = num_m.group(1) if num_m else slug.split("-")[-1]
181 + floor_m = re.search(r"Étage\s+(\d+)", text)
182 +
183 + price = None
184 + price_label = ""
185 + pm = re.search(r"(\d{3,5})\s*\$\s*(/mois\*?)", text)
186 + if pm:
187 + price = parse_price(pm.group(1) + "$")
188 + price_label = (pm.group(1) + "$" + pm.group(2))
189 + if "*" in pm.group(2):
190 + price_label += (" (chauffé, éclairé, climatisé, "
191 + "stationnement int.)")
192 +
193 + avail_m = re.search(r"Disponibilité\s*:\s*([^|]+?)(?:Unité|$)",
194 + text)
195 + availability = (f"Disponibilité : {avail_m.group(1).strip()}"
196 + if avail_m else "Disponible")
197 +
198 + promo = "En promotion" in text
199 +
200 + # Images : logo du complexe exclu, plan de l'unité conservé
201 + images: list[str] = []
202 + unit_type = ""
203 + for img in item.select("img[src]"):
204 + src = img["src"]
205 + if not src.startswith("http"):
206 + continue
207 + if re.search(r"logo|_rgb_|descripteur|\.svg$", src, re.I):
208 + continue
209 + full = re.sub(r"-\d+x\d+(\.(?:jpg|jpeg|png|webp))$",
210 + r"\1", src)
211 + images.append(full)
212 + # type d'unité encodé dans le nom du plan
213 + tm = (re.search(r"pieces[-_](\d)\.5", src, re.I)
214 + or re.search(r"_(\d)\.5\b", src))
215 + if tm and not unit_type:
216 + unit_type = normalize_unit_type(f"{tm.group(1)} 1/2")
217 + images = list(dict.fromkeys(images))
218 +
219 + desc_parts = [f"Complexe {complexe}" if complexe else ""]
220 + if floor_m:
221 + desc_parts.append(f"Étage {floor_m.group(1)}")
222 + if promo:
223 + desc_parts.append("En promotion")
224 + desc_parts = [d for d in desc_parts if d]
225 +
226 + amenities = ["Eau chaude incluse",
227 + "Internet haute vitesse inclus",
228 + "Stationnement extérieur ou intérieur",
229 + "Accès aux aires communes"]
230 + if promo:
231 + amenities.append("En promotion")
232 +
233 + out.append(Listing(
234 + source=self.source_id,
235 + external_id=slug,
236 + url=url,
237 + title=f"Faubourg du Moulin ({complexe or 'complexe'}) — "
238 + f"Unité {unit_no}"
239 + f"{' (' + unit_type + ')' if unit_type else ''}",
240 + address=ADDRESS,
241 + sector=SECTOR,
242 + city=CITY,
243 + unit_type=unit_type,
244 + price=price,
245 + price_label=price_label,
246 + availability=availability,
247 + description=" | ".join(desc_parts),
248 + amenities=amenities,
249 + images=images,
250 + ))
251 + except Exception:
252 + continue
253 + return out
added louka/connectors/hazelview.py +164 −0
@@ -0,0 +1,164 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/hazelview.py : connecteur Hazelview Properties
5 +# (hazelviewproperties.com — ex-Timbercreek). Le site est rendu côté client
6 +# via l'API RentSync/LiftSystem (lift-api.rentsync.com/v2, client_id 497,
7 +# jeton public embarqué dans le JS du site). On interroge /v2/cities pour
8 +# les villes QC (toutes dans le Grand Montréal : Montréal, Verdun,
9 +# Côte-Saint-Luc, Pointe-Claire, Longueuil...), puis /v2/search par ville et
10 +# par nombre de chambres pour obtenir les types d'unités disponibles et leur
11 +# loyer. Une annonce par immeuble et par type d'unité.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +
17 +from ..schema import Listing, strip_accents
18 +from .base import BaseConnector
19 +
20 +API = "https://lift-api.rentsync.com/v2"
21 +CLIENT_ID = "497"
22 +AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # jeton public (présent dans main.js)
23 +
24 +SEARCH_PARAMS = ("only_available_suites=true&show_all_properties=false"
25 + "&min_bath=-1&max_bath=10&min_rate=0&max_rate=10000")
26 +
27 +# Villes QC admissibles (Grand Montréal) -> (ville, secteur imposé)
28 +_GM_CITIES = {
29 + "montreal": ("Montréal", None),
30 + "verdun": ("Montréal", "Verdun"),
31 + "cote-saint-luc": ("Côte-Saint-Luc", None),
32 + "dollard-des-ormeaux": ("Dollard-des-Ormeaux", None),
33 + "pointe-claire": ("Pointe-Claire", None),
34 + "longueuil": ("Longueuil", None),
35 + "lasalle": ("Montréal", "LaSalle"),
36 +}
37 +
38 +# (min_bed, max_bed, type d'unité)
39 +_BED_QUERIES = [(0, 0, "Studio"), (1, 1, "3½"), (2, 2, "4½"),
40 + (3, 3, "5½"), (4, 5, "6½")]
41 +_TAG_RE = re.compile(r"<[^>]+>")
42 +
43 +
44 +class HazelviewConnector(BaseConnector):
45 + source_id = "hazelview"
46 + request_delay = 0.6
47 + max_cities = 10 # garde-fou
48 +
49 + def _api(self, path: str, extra: str = "") -> list | dict:
50 + url = (f"{API}/{path}?client_id={CLIENT_ID}&auth_token={AUTH_TOKEN}"
51 + f"&locale=en{('&' + extra) if extra else ''}")
52 + return self.get(url).json()
53 +
54 + def fetch(self) -> list[Listing]:
55 + cities = self._api("cities")
56 + qc = []
57 + for c in cities if isinstance(cities, list) else []:
58 + if (c.get("province_code") or "").upper() != "QC":
59 + continue
60 + key = strip_accents((c.get("city_name") or "").strip().lower())
61 + if key in _GM_CITIES:
62 + qc.append((c.get("id"), key))
63 +
64 + listings: list[Listing] = []
65 + for city_id, key in qc[: self.max_cities]:
66 + city, forced_sector = _GM_CITIES[key]
67 + for min_bed, max_bed, unit_type in _BED_QUERIES:
68 + try:
69 + props = self._api(
70 + "search",
71 + f"city_ids={city_id}&min_bed={min_bed}"
72 + f"&max_bed={max_bed}&{SEARCH_PARAMS}&limit=50")
73 + except Exception:
74 + continue
75 + if not isinstance(props, list):
76 + continue
77 + for p in props:
78 + try:
79 + lst = self._prop_listing(p, unit_type, min_bed,
80 + city, forced_sector)
81 + if lst:
82 + listings.append(lst)
83 + except Exception:
84 + continue
85 + return listings
86 +
87 + def _prop_listing(self, p: dict, unit_type: str, beds: int,
88 + city: str, forced_sector: str | None) -> Listing | None:
89 + if not p.get("availability_count"):
90 + return None
91 + addr = p.get("address") or {}
92 + stats = ((p.get("statistics") or {}).get("suites") or {})
93 + rates = stats.get("rates") or {}
94 + rmin, rmax = rates.get("min"), rates.get("max")
95 + sq = stats.get("square_feet") or {}
96 +
97 + def _num(v):
98 + try:
99 + return float(v)
100 + except (TypeError, ValueError):
101 + return None
102 +
103 + rmin, rmax = _num(rmin), _num(rmax)
104 + price = rmin
105 + if rmin and rmax and rmax != rmin:
106 + price_label = f"À partir de {int(rmin)} $ (max {int(rmax)} $)"
107 + elif rmin:
108 + price_label = f"{int(rmin)} $/mois"
109 + else:
110 + price_label = ""
111 +
112 + sector = forced_sector or (addr.get("neighbourhood") or "").strip()
113 + details = p.get("details") or {}
114 + desc = _TAG_RE.sub(" ", details.get("overview") or "")
115 + desc = re.sub(r"\s+", " ", desc).strip()[:500]
116 + sbits = []
117 + sqmin, sqmax = _num(sq.get("min")), _num(sq.get("max"))
118 + if sqmin:
119 + sqtxt = (f"{int(sqmin)}-{int(sqmax)}"
120 + if sqmax and sqmax != sqmin else f"{int(sqmin)}")
121 + sbits.append(f"{sqtxt} pi²")
122 + sbits.append(f"{p['availability_count']} unité(s) disponible(s)")
123 +
124 + amenities = []
125 + feats = _TAG_RE.sub("|", details.get("features") or "")
126 + for f in feats.split("|"):
127 + f = f.strip()
128 + if 2 < len(f) < 60 and f not in amenities:
129 + amenities.append(f)
130 + amenities = amenities[:20]
131 +
132 + images = []
133 + if p.get("photo_path"):
134 + images.append(p["photo_path"])
135 +
136 + pid = p.get("id")
137 + name = (p.get("name") or "").strip()
138 + geo = p.get("geocode") or {}
139 + try:
140 + lat = float(geo.get("latitude"))
141 + lng = float(geo.get("longitude"))
142 + except (TypeError, ValueError):
143 + lat = lng = None
144 + return Listing(
145 + source=self.source_id,
146 + external_id=f"{pid}-{beds}bed",
147 + url=p.get("permalink") or "",
148 + title=f"{name}{unit_type}",
149 + address=", ".join(x for x in [
150 + (addr.get("address") or "").strip(), city,
151 + (addr.get("postal_code") or "").strip()] if x),
152 + sector=sector,
153 + city=city,
154 + unit_type=unit_type,
155 + price=price,
156 + price_label=price_label,
157 + availability=(p.get("min_availability_date")
158 + or p.get("availability_status_label") or ""),
159 + description=" — ".join([desc] + sbits if desc else sbits)[:600],
160 + amenities=amenities,
161 + images=images,
162 + lat=lat,
163 + lng=lng,
164 + )
added louka/connectors/headway.py +184 −0
@@ -0,0 +1,184 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/headway.py : connecteur La Corporation Headway (headwayltee.com)
5 +# Site vitrine Wix (rendu serveur) sans liste d'unités individuelles :
6 +# une annonce par complexe immobilier (Place Prévert à Vanier, Place
7 +# Versant Nord / Place l'Heureux / Domaine Versant Nord à Ste-Foy,
8 +# Complexe Renaissance à Charlesbourg, Thibault et Curé-Pelletier à Lévis).
9 +# Le Domaine Anjou (Montréal) est exclu (hors Québec/Lévis).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import html as htmllib
14 +import re
15 +import unicodedata
16 +
17 +from ..schema import Listing, normalize_unit_type
18 +from .base import BaseConnector
19 +
20 +BASE = "https://www.headwayltee.com"
21 +
22 +# (url de page, [(nom du complexe, secteur, ville), ...])
23 +PAGES: list[tuple[str, list[tuple[str, str, str]]]] = [
24 + (f"{BASE}/logements-a-louer/appartement-quebec",
25 + [("Place Prévert", "Vanier", "Québec")]),
26 + (f"{BASE}/ste-foy",
27 + [("Place Versant Nord", "Sainte-Foy", "Québec"),
28 + ("Place l'Heureux", "Sainte-Foy", "Québec"),
29 + ("Domaine Versant Nord", "Sainte-Foy", "Québec")]),
30 + (f"{BASE}/logements-a-louer/appartement-charlesbourg",
31 + [("Complexe Renaissance", "Charlesbourg", "Québec")]),
32 + (f"{BASE}/logements-a-louer/levis",
33 + [("Thibault", "Lévis", "Lévis"),
34 + ("Curé-Pelletier", "Lévis", "Lévis")]),
35 +]
36 +
37 +
38 +def _slug(s: str) -> str:
39 + s = unicodedata.normalize("NFD", s.lower())
40 + s = "".join(c for c in s if unicodedata.category(c) != "Mn")
41 + return re.sub(r"[^a-z0-9]+", "-", s).strip("-")
42 +
43 +
44 +def _wix_image(url: str) -> str:
45 + """URL wixstatic pleine résolution (sans les transformations /v1/fill/...)."""
46 + return url.split("/v1/")[0]
47 +
48 +
49 +class HeadwayConnector(BaseConnector):
50 + source_id = "headway"
51 + request_delay = 0.6
52 +
53 + def fetch(self) -> list[Listing]:
54 + listings: list[Listing] = []
55 + for url, complexes in PAGES:
56 + try:
57 + html = self.get(url).text
58 + except Exception:
59 + continue
60 + try:
61 + listings.extend(self._parse_page(url, html, complexes))
62 + except Exception:
63 + continue
64 + return listings
65 +
66 + # -- une page (1 à 3 complexes) ----------------------------------------------
67 + def _parse_page(self, url: str, html: str,
68 + complexes: list[tuple[str, str, str]]) -> list[Listing]:
69 + # segments de texte visibles, avec leur position dans le HTML
70 + segments = [(m.start(), htmllib.unescape(m.group(1)).replace("\xa0", " ").strip())
71 + for m in re.finditer(r">([^<>]{2,400})<", html)]
72 + segments = [(p, t) for p, t in segments if t and not t.startswith(("{", "var ", "window."))]
73 +
74 + # images : balises <img alt="Nom du complexe ..."> (wixstatic)
75 + img_tags = [(m.start(), m.group(0)) for m in re.finditer(r"<img [^>]+>", html)]
76 +
77 + # occurrences exactes des noms de complexes (titres de sections)
78 + name_slugs = {_slug(n): n for n, _, _ in complexes}
79 + name_events: list[tuple[int, str]] = [] # (pos, nom)
80 + for p, t in segments:
81 + if _slug(t) in name_slugs:
82 + name_events.append((p, name_slugs[_slug(t)]))
83 +
84 + # blocs "Adresse :" (libellé seul) -> associés au titre précédent le plus
85 + # proche ; les pages à complexe unique prennent le premier bloc trouvé.
86 + info_blocks: dict[str, tuple[str, list[str]]] = {} # nom -> (adresse, types)
87 + for i, (p, t) in enumerate(segments):
88 + if not re.match(r"^Adresse\s*:?\s*$", t):
89 + continue
90 + parts: list[str] = []
91 + unit_types: list[str] = []
92 + for _, t2 in segments[i + 1:i + 40]:
93 + if re.search(r"Num[ée]ro de t[ée]l[ée]phone|Heures d'ouverture", t2, re.I):
94 + break
95 + if re.fullmatch(r"\d\s*(?:½|1/2)(?:\s*pi[èe]ces?)?|\d\s*pi[èe]ces", t2):
96 + nt = normalize_unit_type(t2) or t2
97 + if nt not in unit_types:
98 + unit_types.append(nt)
99 + elif len(parts) < 3 and not re.search(
100 + r"Composition|Logements disponibles|sous-sol|Étage", t2, re.I):
101 + parts.append(t2)
102 + address = ", ".join(parts).strip(" ,")
103 + if "saint-sacrement" in address.lower(): # siège social, pas un immeuble
104 + continue
105 + owner = None
106 + for np, n in name_events:
107 + if np < p:
108 + owner = n
109 + if owner is None and len(complexes) == 1:
110 + owner = complexes[0][0]
111 + if owner and owner not in info_blocks:
112 + info_blocks[owner] = (address, unit_types)
113 +
114 + results = []
115 + for name, sector, city in complexes:
116 + name_flat = _slug(name)
117 +
118 + # position du titre du complexe (dernière occurrence = section détaillée)
119 + name_pos = None
120 + for p, n in name_events:
121 + if n == name:
122 + name_pos = p
123 + if name_pos is None:
124 + for p, t in segments:
125 + if name_flat in _slug(t):
126 + name_pos = p
127 + break
128 +
129 + # images dont l'attribut alt commence par le nom du complexe
130 + images: list[str] = []
131 + for _, tag in img_tags:
132 + alt_m = re.search(r'alt="([^"]*)"', tag)
133 + if not alt_m:
134 + continue
135 + alt = htmllib.unescape(alt_m.group(1))
136 + if not _slug(alt).startswith(name_flat):
137 + continue
138 + src_m = re.search(r'src="(https://static\.wixstatic\.com/media/[^"]+)"', tag)
139 + if src_m:
140 + u = _wix_image(src_m.group(1))
141 + if u not in images:
142 + images.append(u)
143 +
144 + # description = premier long paragraphe après le titre
145 + description = ""
146 + if name_pos is not None:
147 + for p, t in segments:
148 + if p > name_pos and len(t) > 120 and not t.startswith("*") \
149 + and "veuillez svp" not in t:
150 + description = t
151 + break
152 +
153 + address, unit_types = info_blocks.get(name, ("", []))
154 +
155 + # services disponibles (liste après le libellé, commune à la page)
156 + amenities: list[str] = []
157 + for i, (_, t) in enumerate(segments):
158 + if t.lower().startswith("services disponibles"):
159 + for _, t2 in segments[i + 1:i + 25]:
160 + if re.search(r"À moins de|Pourquoi|proximité", t2, re.I):
161 + break
162 + if 3 <= len(t2) <= 60 and t2 not in amenities:
163 + amenities.append(t2)
164 + break
165 +
166 + if unit_types:
167 + comp = "Composition de l'immeuble : " + ", ".join(unit_types) + "."
168 + description = (description + " " + comp).strip() if description else comp
169 +
170 + results.append(Listing(
171 + source=self.source_id,
172 + external_id=_slug(name),
173 + url=url,
174 + title=name,
175 + address=address,
176 + sector=sector,
177 + city=city,
178 + unit_type=unit_types[0] if len(unit_types) == 1 else "",
179 + availability="Sur demande (contacter l'agent de location)",
180 + description=description[:800],
181 + amenities=amenities,
182 + images=images[:20],
183 + ))
184 + return results
added louka/connectors/huma.py +125 −0
@@ -0,0 +1,125 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/huma.py : connecteur HUMĀ Condos locatifs (humalevis.com)
5 +# Deux phases de 130/132 unités à Lévis (Saint-Romuald). Plans d'étages
6 +# interactifs (<area data-color-scheme="disponible">) -> fiches d'unités
7 +# (type, superficies, plan). Aucun prix affiché sur le site.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type
16 +from .base import BaseConnector
17 +
18 +BASE = "https://humalevis.com"
19 +SECTOR = "Saint-Romuald"
20 +
21 +# fichiers d'images à ignorer (logos, partenaires)
22 +_SKIP_IMG = re.compile(r"logo|rvb[_-]?huma|ftq|edifia|favicon|icon", re.I)
23 +
24 +
25 +class HumaConnector(BaseConnector):
26 + source_id = "huma"
27 + request_delay = 0.5
28 + max_floor_pages = 24 # garde-fou (2 phases x 10 étages)
29 +
30 + def fetch(self) -> list[Listing]:
31 + # 1) Découvrir les pages d'étages depuis les pages de phases
32 + floor_urls: list[str] = []
33 + for phase in (1, 2):
34 + try:
35 + html = self.get(f"{BASE}/phase-{phase}/").text
36 + except Exception:
37 + continue
38 + found = sorted(set(re.findall(
39 + rf'href="({re.escape(BASE)}/phase-{phase}/etage-\d+/?)"',
40 + html)), key=lambda u: int(re.search(r"etage-(\d+)", u).group(1)))
41 + floor_urls.extend(found)
42 +
43 + # 2) Unités disponibles sur chaque plan d'étage
44 + unit_urls: list[str] = []
45 + for url in floor_urls[:self.max_floor_pages]:
46 + try:
47 + html = self.get(url).text
48 + except Exception:
49 + continue
50 + for tag in re.findall(r"<area\b.*?>", html, re.S):
51 + if 'data-color-scheme="disponible"' not in tag:
52 + continue
53 + m = re.search(r'href="(https?://[^"]+)"', tag)
54 + if m:
55 + u = m.group(1).rstrip("/")
56 + if u.startswith(BASE) and u not in unit_urls:
57 + unit_urls.append(u)
58 +
59 + # 3) Fiche de chaque unité disponible
60 + listings: list[Listing] = []
61 + for url in unit_urls:
62 + try:
63 + html = self.get(url).text
64 + except Exception:
65 + continue
66 + try:
67 + soup = BeautifulSoup(html, "html.parser")
68 + text = soup.get_text("\n", strip=True)
69 +
70 + num_m = re.search(r"\s*(\w+)", text)
71 + unit_no = (num_m.group(1) if num_m
72 + else url.rstrip("/").split("-")[-1])
73 + phase_m = re.search(r"unite-phase-(\d)", url)
74 + phase = phase_m.group(1) if phase_m else "?"
75 +
76 + type_m = re.search(r"TYPE\s+([\w.]+)\s*\|\s*([^\n]+)", text)
77 + model = type_m.group(1) if type_m else ""
78 + unit_type = (normalize_unit_type(type_m.group(2))
79 + if type_m else "")
80 +
81 + etat_m = re.search(r"ÉTAT\s*\n\s*([^\n]+)", text)
82 + availability = etat_m.group(1).strip() if etat_m else "Disponible"
83 +
84 + floor_m = re.search(r"ÉTAGE\s*\n\s*(\d+)", text)
85 + desc_parts = []
86 + for label in ("SUPERFICIE DU LOGEMENT", "SUPERFICIE DU BALCON",
87 + "SUPERFICIE TOTALE"):
88 + dm = re.search(rf"{label}\s*\n\s*([^\n]+)", text)
89 + if dm:
90 + desc_parts.append(
91 + f"{label.capitalize().lower().capitalize()} : "
92 + f"{dm.group(1).strip()}")
93 + if model:
94 + desc_parts.insert(0, f"Modèle {model}")
95 + if floor_m:
96 + desc_parts.insert(0, f"Étage {floor_m.group(1)}")
97 +
98 + imgs = re.findall(
99 + rf'(?:src|href|data-src)="({re.escape(BASE)}'
100 + rf'/wp-content/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"',
101 + html, re.I)
102 + images = [u for u in dict.fromkeys(imgs)
103 + if not _SKIP_IMG.search(u)][:15]
104 +
105 + listings.append(Listing(
106 + source=self.source_id,
107 + external_id=f"phase-{phase}-condo-{unit_no}",
108 + url=url,
109 + title=f"HUMĀ phase {phase} — Condo locatif N°{unit_no}"
110 + f" ({unit_type})",
111 + address="",
112 + sector=SECTOR,
113 + city=infer_city(SECTOR),
114 + unit_type=unit_type,
115 + price=None, # aucun prix affiché sur le site
116 + price_label="",
117 + availability=availability,
118 + description=" | ".join(desc_parts),
119 + amenities=["Eau chaude incluse", "Climatisation incluse"],
120 + images=images,
121 + ))
122 + except Exception:
123 + continue
124 +
125 + return listings
added louka/connectors/immomarketing.py +213 −0
@@ -0,0 +1,213 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/immomarketing.py : connecteur IMMOMARKETING / Immoappart
5 +# (immoappart.ca). Le portail couvre la région de Québec ET le Grand
6 +# Montréal (île de Montréal, Laval, Longueuil/Rive-Sud) : on crawle les
7 +# pages de secteurs (les plus précises d'abord pour un meilleur secteur).
8 +# Une annonce par unité (fiche /appartement/<slug>/).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import (Listing, infer_city, normalize_unit_type, parse_price,
17 + strip_accents)
18 +from .base import BaseConnector
19 +
20 +BASE = "https://immoappart.ca"
21 +# page de secteur -> (secteur par défaut, ville par défaut). Ordre important :
22 +# pages précises d'abord (le premier secteur rencontré est conservé).
23 +SECTOR_PAGES = {
24 + # Région de Québec
25 + f"{BASE}/appartements-a-louer/quebec/lebourgneuf/": ("Lebourgneuf", "Québec"),
26 + f"{BASE}/appartements-a-louer/quebec/saint-roch-2/": ("Saint-Roch", "Québec"),
27 + # Île de Montréal
28 + f"{BASE}/appartements-a-louer/montreal/ahuntsic-cartierville/":
29 + ("Ahuntsic-Cartierville", "Montréal"),
30 + f"{BASE}/appartements-a-louer/montreal/cote-des-neiges/":
31 + ("Côte-des-Neiges", "Montréal"),
32 + f"{BASE}/appartements-a-louer/montreal/saint-leonard/":
33 + ("Saint-Léonard", "Montréal"),
34 + f"{BASE}/appartements-a-louer/saint-laurent/": ("Saint-Laurent", "Montréal"),
35 + f"{BASE}/appartements-a-louer/montreal/": ("", "Montréal"),
36 + # Laval
37 + f"{BASE}/appartements-a-louer/laval/": ("", "Laval"),
38 + # Rive-Sud (page agrégée en dernier : villes déduites fiche par fiche)
39 + f"{BASE}/appartements-a-louer/saint-lambert/": ("", "Saint-Lambert"),
40 + f"{BASE}/appartements-a-louer/longueuil/": ("", "Longueuil"),
41 + f"{BASE}/appartements-a-louer/rive-sud/": ("", ""),
42 +}
43 +# secteur affiché sur la fiche (bandeau .city) -> vraie ville, hors région Qc
44 +_SECTOR_CITY = {
45 + "montreal": "Montréal", "ahuntsic": "Montréal",
46 + "ahuntsic-cartierville": "Montréal", "cote-des-neiges": "Montréal",
47 + "saint-leonard": "Montréal", "saint-laurent": "Montréal",
48 + "ville saint-laurent": "Montréal",
49 + "laval": "Laval",
50 + "longueuil": "Longueuil", "saint-hubert": "Longueuil",
51 + "greenfield park": "Longueuil", "vieux-longueuil": "Longueuil",
52 + "saint-lambert": "Saint-Lambert", "brossard": "Brossard",
53 + "boucherville": "Boucherville", "chambly": "Chambly",
54 + "candiac": "Candiac", "la prairie": "La Prairie",
55 + "richelieu": "Richelieu", "mcmasterville": "McMasterville",
56 + "saint-bruno": "Saint-Bruno-de-Montarville",
57 + "saint-bruno-de-montarville": "Saint-Bruno-de-Montarville",
58 + "saint-hilaire": "Mont-Saint-Hilaire",
59 + "mont-saint-hilaire": "Mont-Saint-Hilaire",
60 + "sainte-julie": "Sainte-Julie", "varennes": "Varennes",
61 + "beloeil": "Belœil",
62 +}
63 +IMG_RE = re.compile(r"https://immoappart\.ca/wp-content/uploads/"
64 + r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)
65 +
66 +
67 +class ImmomarketingConnector(BaseConnector):
68 + source_id = "immomarketing"
69 + request_delay = 0.6
70 + max_details = 120 # garde-fou de fetch des fiches (Qc + Grand Mtl)
71 +
72 + def fetch(self) -> list[Listing]:
73 + # 1) Fiches référencées par les pages de secteurs (Qc + Grand Mtl)
74 + pages: dict[str, tuple[str, str]] = {} # url fiche -> (secteur, ville)
75 + for sector_url, (sector, city) in SECTOR_PAGES.items():
76 + try:
77 + html = self.get(sector_url).text
78 + except Exception:
79 + continue
80 + soup = BeautifulSoup(html, "html.parser")
81 + for a in soup.select('a[href*="/appartement/"]'):
82 + href = (a.get("href") or "").split("?")[0]
83 + if re.search(r"/appartement/[a-z0-9\-]+/?$", href):
84 + pages.setdefault(href.rstrip("/") + "/", (sector, city))
85 +
86 + # 2) Fiches détaillées
87 + listings: list[Listing] = []
88 + for i, (url, (sector, city)) in enumerate(pages.items()):
89 + if i >= self.max_details:
90 + break
91 + try:
92 + html = self.get(url).text
93 + except Exception:
94 + continue
95 + try:
96 + lst = self._parse_detail(url, sector, city, html)
97 + if lst:
98 + listings.append(lst)
99 + except Exception:
100 + continue
101 + return listings
102 +
103 + def _parse_detail(self, url: str, sector: str, page_city: str,
104 + html: str) -> Listing | None:
105 + soup = BeautifulSoup(html, "html.parser")
106 + slug = url.rstrip("/").split("/")[-1]
107 +
108 + h1 = soup.find("h1")
109 + title = h1.get_text(" ", strip=True) if h1 else slug
110 + if re.search(r"Commercial|Stationnement|Rangement", title, re.I):
111 + return None
112 +
113 + # Bandeau : secteur / type / prix / disponibilité
114 + band = soup.select_one("p.selected-appart-title")
115 + unit_type = price_label = ""
116 + if band:
117 + size = band.select_one(".size")
118 + unit_type = size.get_text(strip=True) if size else ""
119 + price = band.select_one(".price")
120 + price_label = price.get_text(strip=True) if price else ""
121 + city_el = band.select_one(".city")
122 + if city_el and city_el.get_text(strip=True):
123 + sector = city_el.get_text(strip=True)
124 +
125 + availability = ""
126 + for s in soup.find_all(string=re.compile("Date de disponibilité")):
127 + parent = s.find_parent("p")
128 + if parent:
129 + cand = re.sub(r".*Date de disponibilité\s*:?\s*", "",
130 + parent.get_text(" ", strip=True)).strip()
131 + if cand:
132 + availability = cand
133 + break
134 +
135 + # Adresse : h2 sous le h1
136 + address = ""
137 + for h2 in soup.find_all("h2"):
138 + t = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))
139 + if re.match(r"^\d{1,5},?\s+\S[^,]{1,60},", t):
140 + address = t
141 + break
142 + if not address:
143 + m = re.search(r"(?<![\d½])\b\d{2,5}\s+(?:rue|boulevard|avenue|"
144 + r"chemin|côte)[^,<>]{2,50},\s*[^,<>]{2,40},\s*QC",
145 + re.sub(r"\s+", " ", soup.get_text(" ", strip=True)),
146 + re.I)
147 + if m:
148 + address = m.group(0)
149 + if not address:
150 + # dernier recours : le titre commence parfois par l'adresse
151 + m = re.match(r"^(\d{1,5},?\s+.{3,60}?)\s+[-–]\s+\d\s*½", title)
152 + if m:
153 + address = m.group(1).strip()
154 +
155 + # Photos : galerie de la fiche (éviter les fiches « même secteur »)
156 + images: list[str] = []
157 + gallery = soup.select_one(".appart-gallery") or soup
158 + for el in gallery.select("img[src], a[href]"):
159 + u = (el.get("src") or el.get("href") or "").split("?")[0]
160 + if IMG_RE.match(u) and not re.search(r"logo|icon|favicon", u, re.I):
161 + images.append(u)
162 + banner = soup.select_one(".banner-appart img[src]")
163 + if banner:
164 + images.insert(0, banner["src"].split("?")[0])
165 + images = list(dict.fromkeys(images))[:25]
166 +
167 + og = soup.find("meta", attrs={"property": "og:description"})
168 + desc = og["content"].strip()[:600] if og and og.get("content") else ""
169 +
170 + # Commodités : listes à puces de la section descriptive
171 + amenities = []
172 + for li in soup.select("main li"):
173 + t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))
174 + if t and 3 < len(t) < 80 and "http" not in t:
175 + amenities.append(t)
176 + amenities = list(dict.fromkeys(amenities))[:15]
177 +
178 + # ville : secteur du bandeau s'il correspond à une ville du Grand
179 + # Montréal, sinon ville de la page de secteur, sinon adresse/Québec
180 + key = strip_accents((sector or "").strip().lower())
181 + if key in _SECTOR_CITY:
182 + city = _SECTOR_CITY[key]
183 + elif page_city:
184 + city = infer_city(sector, default=page_city)
185 + else:
186 + # page agrégée (ex. Rive-Sud) : dernier segment de l'adresse
187 + city = ""
188 + if address:
189 + parts = [p.strip() for p in address.split(",")
190 + if p.strip() and not re.fullmatch(
191 + r"QC|Qu[ée]bec", p.strip(), re.I)]
192 + if len(parts) >= 2 and not parts[-1][:1].isdigit():
193 + city = parts[-1]
194 + city = city or infer_city(sector, default="Québec")
195 + if sector and sector.lower() == city.lower():
196 + sector = "" # éviter secteur == ville (redondant)
197 +
198 + return Listing(
199 + source=self.source_id,
200 + external_id=slug,
201 + url=url,
202 + title=title,
203 + address=address,
204 + sector=sector,
205 + city=city,
206 + unit_type=normalize_unit_type(unit_type),
207 + price=parse_price(price_label),
208 + price_label=price_label,
209 + availability=availability,
210 + description=desc,
211 + amenities=amenities,
212 + images=images,
213 + )
added louka/connectors/immostar.py +280 −0
@@ -0,0 +1,280 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/immostar.py : connecteur Immostar (immostar.ca / immostaralouer.ca)
5 +# Le résidentiel d'Immostar vit sur un micro-site par projet :
6 +# - MU (habitermu.ca) et Le Huppé (lehuppe.ca) -> JSON `var fiches`
7 +# - Kwayaweh (kwayaweh.ca) -> unités inline (data-*)
8 +# - Le 1070 Cap-Rouge (le1070.ca) -> SVG + REST do-selecteur
9 +# - Le Maguire (lemaguire.ca) -> polygones data-*
10 +# - Loges Saint-Nicolas (loges.ca, Lévis) -> cartes .fiche_unite
11 +# Exclus : Alo Ste-Foy (livraison 2028, rien à louer), Le Florent
12 +# (Trois-Rivières, hors région Québec/Lévis), immostaralouer.ca (commercial).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import json
17 +import re
18 +
19 +from bs4 import BeautifulSoup
20 +
21 +from ..schema import Listing, infer_city, parse_price
22 +from .base import BaseConnector
23 +
24 +_IMG_RE = re.compile(
25 + r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp)',
26 + re.I)
27 +_SKIP_IMG = re.compile(
28 + r"logo|icon|favicon|plan|selecteur|niveau-|-\d{2,3}x\d{2,3}\.", re.I)
29 +
30 +
31 +def _type_to_demi(raw: str) -> str:
32 + """'2 chambres (+ bureau)' -> '4½', 'Studio' -> 'Studio'."""
33 + s = (raw or "").strip().lower()
34 + if not s:
35 + return ""
36 + if "studio" in s:
37 + return "Studio"
38 + if "maison" in s or s.startswith("mdv"):
39 + return "Maison"
40 + m = re.search(r"(\d)\s*ch", s)
41 + if m:
42 + return f"{int(m.group(1)) + 2}½"
43 + m = re.search(r"(\d)\s*(?:½|1/2)", s)
44 + if m:
45 + return f"{m.group(1)}½"
46 + return raw.strip()
47 +
48 +
49 +class ImmostarConnector(BaseConnector):
50 + source_id = "immostar"
51 + request_delay = 0.6
52 + max_images = 15
53 + max_rest_units = 60 # garde-fou REST (le1070)
54 +
55 + # -- helpers ---------------------------------------------------------------
56 + def _site_images(self, url: str) -> list[str]:
57 + try:
58 + html = self.get(url).text
59 + except Exception:
60 + return []
61 + imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))
62 + if not _SKIP_IMG.search(u)]
63 + return imgs[: self.max_images]
64 +
65 + @staticmethod
66 + def _fiches(html: str) -> list[dict]:
67 + """Extrait `var fiches = {"data": {...}}` (plateforme MU / Le Huppé)."""
68 + i = html.find("var fiches = ")
69 + if i < 0:
70 + return []
71 + try:
72 + data, _ = json.JSONDecoder().raw_decode(html[i + len("var fiches = "):])
73 + except Exception:
74 + return []
75 + return list((data.get("data") or {}).values())
76 +
77 + def _mk(self, proj: dict, num: str, unit_type_raw: str, price_label: str,
78 + availability: str, desc: str, images: list[str]) -> Listing:
79 + sector = proj["sector"]
80 + return Listing(
81 + source=self.source_id,
82 + external_id=f"{proj['id']}-{num}",
83 + url=f"{proj['plans'][0]}#unite-{num}",
84 + title=f"{proj['name']} — Unité {num}",
85 + address=proj.get("address", ""),
86 + sector=sector,
87 + city=infer_city(sector),
88 + unit_type=_type_to_demi(unit_type_raw),
89 + price=parse_price(price_label),
90 + price_label=price_label,
91 + availability=availability,
92 + description=desc[:600],
93 + amenities=list(proj.get("amenities", [])),
94 + images=images,
95 + )
96 +
97 + # -- parseurs par plateforme ------------------------------------------------
98 + def _parse_fiches(self, proj: dict, html: str,
99 + images: list[str]) -> list[Listing]:
100 + out = []
101 + for f in self._fiches(html):
102 + dispo = f.get("disponibilite")
103 + if isinstance(dispo, dict): # variante MU
104 + dispo = dispo.get("value")
105 + if dispo != "available":
106 + continue
107 + num = str(f.get("numero") or "").strip()
108 + if not num:
109 + continue
110 + t = f.get("type") or ""
111 + if isinstance(t, dict): # variante MU
112 + t = t.get("label", "")
113 + if str(f.get("bureau")) == "1":
114 + t += " + bureau"
115 + prix = str(f.get("prix") or "").strip()
116 + sup = str(f.get("superficie") or "").strip()
117 + desc = " — ".join(x for x in [
118 + t, f"{sup} pi²" if sup else "",
119 + f"étage {f.get('etage')}" if f.get("etage") else ""] if x)
120 + out.append(self._mk(
121 + proj, num, t,
122 + f"{prix}$ / mois" if prix else "",
123 + f"Libre {f['date']}" if f.get("date") else "Disponible",
124 + desc, images))
125 + return out
126 +
127 + def _parse_inline_svg(self, proj: dict, html: str,
128 + images: list[str]) -> list[Listing]:
129 + """Kwayaweh / Le Maguire : éléments avec data-numero|data-unite et
130 + data-prix, disponibles si class contient disponible/available."""
131 + soup = BeautifulSoup(html, "html.parser")
132 + out, seen = [], set()
133 + for el in soup.select("[data-numero], [data-unite]"):
134 + cls = " ".join(el.get("class") or [])
135 + if not re.search(r"\b(disponible|available)\b", cls):
136 + continue
137 + if "indisponible" in cls or "rented" in cls:
138 + continue
139 + num = (el.get("data-numero") or el.get("data-unite") or "").strip()
140 + prix = (el.get("data-prix") or "").strip()
141 + if not num or num in seen:
142 + continue
143 + seen.add(num)
144 + t = (el.get("data-type") or "").strip()
145 + etage = (el.get("data-etage") or "").strip()
146 + # Le Maguire : data-unite = numéro d'unité, data-numero absent
147 + label_num = el.get_text(strip=True) or num
148 + if el.get("data-unite") and not el.get("data-numero"):
149 + label_num = num
150 + desc = " — ".join(x for x in [t, f"étage {etage}" if etage else ""]
151 + if x)
152 + out.append(self._mk(
153 + proj, label_num, t,
154 + f"À partir de {prix}$" if prix else "",
155 + "Disponible", desc, images))
156 + return out
157 +
158 + def _parse_do_rest(self, proj: dict, html: str,
159 + images: list[str]) -> list[Listing]:
160 + """Le 1070 : polygones `class="disponible" data-numero=` + REST
161 + /wp-json/do-selecteur-plans/v1/unite?id=N pour le détail (prix)."""
162 + base = re.match(r"https?://[^/]+", proj["plans"][0]).group(0)
163 + soup = BeautifulSoup(html, "html.parser")
164 + ids: list[str] = []
165 + for el in soup.select('[data-numero]'):
166 + cls = " ".join(el.get("class") or [])
167 + if "disponible" not in cls or "indisponible" in cls:
168 + continue
169 + num = (el.get("data-numero") or "").strip()
170 + if num and num not in ids:
171 + ids.append(num)
172 + out = []
173 + for num in ids[: self.max_rest_units]:
174 + try:
175 + data = self.get(
176 + f"{base}/wp-json/do-selecteur-plans/v1/unite",
177 + params={"id": num}).json()
178 + except Exception:
179 + continue
180 + if data.get("etat") != "disponible":
181 + continue
182 + unite = str(data.get("unite") or num)
183 + t = str(data.get("type") or "")
184 + sup = str(data.get("superficie_habitable") or "").strip()
185 + desc = " — ".join(x for x in [
186 + t, f"{sup} pi²" if sup else "",
187 + str(data.get("style") or "")] if x)
188 + avail = str(data.get("date_libre") or "").strip()
189 + out.append(self._mk(
190 + proj, unite, t,
191 + f"À partir de {data.get('montant', '')}",
192 + f"Libre {avail}" if avail else "Disponible", desc, images))
193 + return out
194 +
195 + def _parse_loges(self, proj: dict, html: str,
196 + images: list[str]) -> list[Listing]:
197 + """Loges : cartes `.affichage.liste .fiche_unite` rendues serveur."""
198 + soup = BeautifulSoup(html, "html.parser")
199 + out = []
200 + for card in soup.select(".affichage.liste .fiche_unite"):
201 + num = (card.get("data-unite") or "").strip()
202 + h4 = card.select_one("h4")
203 + head = h4.get_text(" ", strip=True) if h4 else ""
204 + if not num or "$" not in head:
205 + continue # unité louée ou sans prix
206 + info = card.select_one("header p")
207 + info_txt = info.get_text(" ", strip=True) if info else ""
208 + m = re.search(r"(\d)\s*chambre", info_txt)
209 + t = f"{m.group(1)} chambres" if m else ""
210 + dispo = card.select_one(".disponibilite")
211 + price_m = re.search(r"À partir de\s*([\d\s,.]+\$)", head)
212 + price_label = (f"À partir de {price_m.group(1)}/mois"
213 + if price_m else head)
214 + out.append(self._mk(
215 + proj, num, t, price_label,
216 + dispo.get_text(strip=True) if dispo else "Disponible",
217 + info_txt, images))
218 + return out
219 +
220 + # -- fetch -----------------------------------------------------------------
221 + def fetch(self) -> list[Listing]:
222 + projects = [
223 + {"id": "mu", "name": "MU", "sector": "Sainte-Foy",
224 + "address": "2605, boulevard Laurier, Québec",
225 + "site": "https://habitermu.ca/",
226 + "plans": ["https://habitermu.ca/plans/"], "mode": "fiches"},
227 + {"id": "huppe", "name": "Le Huppé", "sector": "Lebourgneuf",
228 + "address": "5055, boulevard des Gradins, Québec",
229 + "site": "https://lehuppe.ca/",
230 + "plans": ["https://lehuppe.ca/plans/"], "mode": "fiches"},
231 + {"id": "1070", "name": "Le 1070 Cap-Rouge", "sector": "Cap-Rouge",
232 + "address": "1070, boulevard de la Chaudière, Québec",
233 + "site": "https://le1070.ca/",
234 + "plans": ["https://le1070.ca/plans/"], "mode": "do_rest"},
235 + {"id": "kwayaweh", "name": "Kwayaweh", "sector": "Wendake",
236 + "address": "Wendake",
237 + "site": "https://kwayaweh.ca/",
238 + "plans": ["https://kwayaweh.ca/selecteur/"], "mode": "inline"},
239 + {"id": "maguire", "name": "Le Maguire sur l'avenue",
240 + "sector": "Sillery", "address": "Avenue Maguire, Québec",
241 + "site": "https://lemaguire.ca/",
242 + "plans": ["https://lemaguire.ca/les-plans/"], "mode": "inline"},
243 + {"id": "loges-555", "name": "Loges Saint-Nicolas (Le 555)",
244 + "sector": "Saint-Nicolas", "address": "555, rue Marie-Victorin, Lévis",
245 + "site": "https://loges.ca/",
246 + "plans": ["https://loges.ca/plans/le-555/"], "mode": "loges"},
247 + {"id": "loges-550", "name": "Loges Saint-Nicolas (Le 550)",
248 + "sector": "Saint-Nicolas", "address": "550, rue Jérôme-Demers, Lévis",
249 + "site": "https://loges.ca/",
250 + "plans": ["https://loges.ca/plans/le-550/"], "mode": "loges"},
251 + {"id": "loges-600", "name": "Loges Saint-Nicolas (Le 600)",
252 + "sector": "Saint-Nicolas", "address": "600, rue Pierre-Perrault, Lévis",
253 + "site": "https://loges.ca/",
254 + "plans": ["https://loges.ca/plans/le-600/"], "mode": "loges"},
255 + ]
256 + parsers = {
257 + "fiches": self._parse_fiches,
258 + "inline": self._parse_inline_svg,
259 + "do_rest": self._parse_do_rest,
260 + "loges": self._parse_loges,
261 + }
262 +
263 + listings: list[Listing] = []
264 + site_imgs: dict[str, list[str]] = {}
265 + for proj in projects:
266 + try:
267 + if proj["site"] not in site_imgs:
268 + site_imgs[proj["site"]] = self._site_images(proj["site"])
269 + images = site_imgs[proj["site"]]
270 + for plans_url in proj["plans"]:
271 + html = self.get(plans_url).text
272 + listings.extend(parsers[proj["mode"]](proj, html, images))
273 + except Exception:
274 + continue
275 +
276 + # dédup par external_id (sécurité)
277 + uniq: dict[str, Listing] = {}
278 + for lst in listings:
279 + uniq.setdefault(lst.external_id, lst)
280 + return list(uniq.values())
added louka/connectors/interrent.py +231 −0
@@ -0,0 +1,231 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/interrent.py : connecteur InterRent REIT (irent.com)
5 +# Site Next.js (app router) : la page « communities/city/montreal » embarque
6 +# dans son flux RSC (self.__next_f.push) le JSON complet des communautés
7 +# (adresse, quartier, photos, commodités) avec leurs suites disponibles
8 +# (type, chambres, sdb, pi², loyer, date). Une seule requête suffit.
9 +# REIT pancanadien : filtre strict sur les villes du Grand Montréal.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import html as htmllib
14 +import json
15 +import re
16 +
17 +from ..schema import Listing, strip_accents
18 +from .base import BaseConnector
19 +
20 +BASE = "https://www.irent.com"
21 +CITY_URL = f"{BASE}/communities/city/montreal"
22 +
23 +CHUNK_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)')
24 +OBJ_START_RE = re.compile(r'\{"Id":\d+,"ImportId"')
25 +
26 +# Villes admissibles (Grand Montréal), clés sans accents/minuscules
27 +_GM_CITIES = {
28 + "montreal": "Montréal",
29 + "cote-saint-luc": "Côte-Saint-Luc",
30 + "cote saint-luc": "Côte-Saint-Luc",
31 + "brossard": "Brossard",
32 + "laval": "Laval",
33 + "longueuil": "Longueuil",
34 + "verdun": "Montréal",
35 + "lasalle": "Montréal",
36 + "pointe-claire": "Pointe-Claire",
37 + "dollard-des-ormeaux": "Dollard-des-Ormeaux",
38 +}
39 +
40 +_BED_TYPES = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}
41 +
42 +
43 +def _unescape_js(s: str) -> str:
44 + """Déséchappe une chaîne JS du flux RSC (\\" \\n \\uXXXX...)."""
45 + try:
46 + return json.loads(f'"{s}"')
47 + except ValueError:
48 + try:
49 + return (s.encode("latin-1", "backslashreplace")
50 + .decode("unicode_escape"))
51 + except Exception:
52 + return s
53 +
54 +
55 +def _clean(s: str) -> str:
56 + """Déséchappe les entités HTML répétées (&amp;amp;amp;...)."""
57 + s = s or ""
58 + for _ in range(4):
59 + t = htmllib.unescape(s)
60 + if t == s:
61 + break
62 + s = t
63 + return s.strip()
64 +
65 +
66 +def _as_list(node) -> list:
67 + """Les nœuds XML->JSON du flux : dict simple ou liste."""
68 + if isinstance(node, list):
69 + return node
70 + if isinstance(node, dict):
71 + return [node]
72 + return []
73 +
74 +
75 +class InterrentConnector(BaseConnector):
76 + source_id = "interrent"
77 + request_delay = 0.6
78 + max_images = 20
79 +
80 + def fetch(self) -> list[Listing]:
81 + page = self.get(CITY_URL).text
82 + blob = "".join(_unescape_js(c) for c in CHUNK_RE.findall(page))
83 +
84 + # Objets communauté : {"Id":N,"ImportId":...,"PermaLink":...}
85 + communities: dict[int, dict] = {}
86 + pos = 0
87 + while True:
88 + m = OBJ_START_RE.search(blob, pos)
89 + if not m:
90 + break
91 + obj, end = self._read_object(blob, m.start())
92 + pos = end if end > m.start() else m.start() + 1
93 + if not obj or "PermaLink" not in obj or "Location" not in obj:
94 + continue
95 + cid = obj.get("Id")
96 + if isinstance(cid, int) and cid not in communities:
97 + communities[cid] = obj
98 +
99 + listings: list[Listing] = []
100 + for c in communities.values():
101 + try:
102 + listings.extend(self._community_listings(c))
103 + except Exception:
104 + continue
105 + return listings
106 +
107 + @staticmethod
108 + def _read_object(blob: str, start: int) -> tuple[dict | None, int]:
109 + """Extrait un objet JSON par appariement d'accolades."""
110 + depth = 0
111 + in_str = False
112 + esc = False
113 + for i in range(start, min(len(blob), start + 400_000)):
114 + ch = blob[i]
115 + if in_str:
116 + if esc:
117 + esc = False
118 + elif ch == "\\":
119 + esc = True
120 + elif ch == '"':
121 + in_str = False
122 + continue
123 + if ch == '"':
124 + in_str = True
125 + elif ch == "{":
126 + depth += 1
127 + elif ch == "}":
128 + depth -= 1
129 + if depth == 0:
130 + try:
131 + return json.loads(blob[start:i + 1]), i + 1
132 + except ValueError:
133 + return None, i + 1
134 + return None, start + 1
135 +
136 + def _community_listings(self, c: dict) -> list[Listing]:
137 + loc = c.get("Location") or {}
138 + if (loc.get("ProvinceCode") or "").upper() != "QC":
139 + return []
140 + raw_city = _clean(loc.get("City") or "")
141 + key = strip_accents(raw_city.lower()).strip()
142 + city = _GM_CITIES.get(key)
143 + if not city:
144 + return [] # hors Grand Montréal
145 + sector = _clean(loc.get("Neighbourhood") or c.get("TagLine") or "")
146 + if strip_accents(sector.lower()) == strip_accents(city.lower()):
147 + sector = "" if city != "Montréal" else sector
148 + if key in ("cote-saint-luc", "verdun", "lasalle") and not sector:
149 + sector = raw_city if city == "Montréal" else ""
150 +
151 + name = _clean(c.get("Name") or "")
152 + url = c.get("Url") or f"{BASE}/communities/{c.get('PermaLink', '')}"
153 + address = _clean(loc.get("Address") or "")
154 + postal = _clean(loc.get("PostalCode") or "")
155 +
156 + # Photos de la communauté
157 + images: list[str] = []
158 + photos = (c.get("Photos") or {})
159 + for p in _as_list(photos.get("Photo") if isinstance(photos, dict)
160 + else photos):
161 + u = (p or {}).get("Url") or ""
162 + if u.startswith("http") and u not in images:
163 + images.append(u)
164 + images = images[: self.max_images]
165 +
166 + amenities = [a.strip() for a in
167 + (c.get("amenities_TextField") or "").split(",")
168 + if a.strip()][:25]
169 +
170 + suites = (c.get("Suites") or {})
171 + out: list[Listing] = []
172 + for s in _as_list(suites.get("Suite") if isinstance(suites, dict)
173 + else suites):
174 + try:
175 + if (s.get("Available") or "").lower() != "yes":
176 + continue
177 + type_name = _clean(s.get("TypeName") or "")
178 + # pseudo-unités « Promotional Price » sans numéro : ignorées
179 + if "promotional" in type_name.lower() and not s.get("Number"):
180 + continue
181 + rate = s.get("Rate")
182 + price = float(rate) if isinstance(rate, (int, float)) \
183 + and rate else None
184 + beds = s.get("Bedrooms")
185 + unit_type = _BED_TYPES.get(beds, "") \
186 + if isinstance(beds, int) else ""
187 + sqft = s.get("SquareFeet")
188 + baths = s.get("Bathrooms")
189 + bits = []
190 + if sqft:
191 + bits.append(f"{sqft} pi²")
192 + if baths:
193 + bits.append(f"{baths} sdb")
194 + if type_name:
195 + bits.append(f"plan {type_name}")
196 +
197 + # plan d'étage en tête de galerie s'il existe
198 + imgs = list(images)
199 + fps = (s.get("Floorplans") or {})
200 + for fp in _as_list(fps.get("Floorplan")
201 + if isinstance(fps, dict) else fps):
202 + fu = (fp or {}).get("Image") or ""
203 + if fu.startswith("http") and fu not in imgs:
204 + imgs.insert(0, fu)
205 +
206 + num = s.get("Number") or ""
207 + label = f"{name}{type_name}" if type_name else name
208 + if num:
209 + label += f" (app. {num})"
210 + out.append(Listing(
211 + source=self.source_id,
212 + external_id=str(s.get("Id")),
213 + url=url,
214 + title=label,
215 + address=", ".join(x for x in [address, city, postal]
216 + if x),
217 + sector=sector,
218 + city=city,
219 + unit_type=unit_type,
220 + price=price,
221 + price_label=f"{int(rate)} $/mois" if price else "",
222 + availability=_clean(s.get("AvailabilityDate") or ""),
223 + description=" — ".join(bits)[:600],
224 + amenities=amenities,
225 + images=imgs[: self.max_images + 1],
226 + lat=loc.get("Latitude"),
227 + lng=loc.get("Longitude"),
228 + ))
229 + except Exception:
230 + continue
231 + return out
added louka/connectors/laberge.py +200 −0
@@ -0,0 +1,200 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/laberge.py : connecteur Gestion immobilière Laberge (laberge.qc.ca)
5 +# Site rendu serveur. La page /recherche liste tous les complexes avec, pour
6 +# chacun, un tableau des types d'unités ; les lignes cliquables
7 +# (data-href="/complexe/{slug}/appartement/{code}") sont les unités
8 +# disponibles. Une annonce par unité disponible ; la fiche unité fournit
9 +# les photos, le secteur, la superficie, la description et les inclusions.
10 +# Région retenue : Québec / Lévis / L'Ancienne-Lorette (les complexes de la
11 +# région de Montréal — Côte St-Luc, LaSalle, Pierrefonds, etc. — sont exclus).
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +from urllib.parse import urljoin
17 +
18 +from bs4 import BeautifulSoup
19 +
20 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price, strip_accents
21 +from .base import BaseConnector
22 +
23 +BASE = "https://www.laberge.qc.ca"
24 +SEARCH_URL = f"{BASE}/recherche"
25 +
26 +# villes de la région Québec/Lévis retenues (normalisées sans accents)
27 +ALLOWED_CITIES = {
28 + "quebec", "levis", "l'ancienne-lorette", "ancienne-lorette",
29 + "saint-augustin-de-desmaures", "st-augustin-de-desmaures",
30 +}
31 +
32 +IMG_RE = re.compile(r"^/image/\d+/\d+/")
33 +EXCLUDE_UNIT_RE = re.compile(r"stationnement|parking|rangement|commercial|garage", re.I)
34 +
35 +# pictogrammes dont le libellé texte est générique ("Inclus")
36 +_PICTO_NAMES = {
37 + "poele--frigo": "Poêle et réfrigérateur",
38 + "entree_laveuse_secheuse": "Entrée laveuse-sécheuse",
39 + "laveuse_secheuse": "Laveuse-sécheuse",
40 + "lave-vaisselle": "Lave-vaisselle",
41 + "lv": "Lave-vaisselle",
42 + "micro-onde": "Micro-ondes",
43 + "air_climatise": "Air climatisé",
44 +}
45 +
46 +
47 +class LabergeConnector(BaseConnector):
48 + source_id = "laberge"
49 + request_delay = 0.6
50 + max_details = 150 # garde-fou : nb max de fiches unités visitées
51 + max_images = 25
52 +
53 + # -- helpers ---------------------------------------------------------------
54 + @staticmethod
55 + def _card_address_city(card) -> tuple[str, str]:
56 + """Adresse civique + ville depuis la carte complexe de /recherche."""
57 + p = card.select_one(".bg-white p")
58 + if not p:
59 + return "", ""
60 + for a in p.find_all("a"): # retirer le lien téléphone
61 + a.decompose()
62 + addr = p.get_text(" ", strip=True)
63 + # ex. « 224, rue seigneuriale, Québec, G1E 0M8 »
64 + m = re.search(r"^(.*?),\s*([^,]+?),?\s*[A-Z]\d[A-Z]\s*\d[A-Z]\d", addr)
65 + if m:
66 + return m.group(1).strip(), m.group(2).strip()
67 + parts = [x.strip() for x in addr.split(",") if x.strip()]
68 + if len(parts) >= 2:
69 + return ", ".join(parts[:-1]), parts[-1]
70 + return addr, ""
71 +
72 + def _enrich_from_detail(self, lst: Listing) -> None:
73 + """Fiche unité : photos, secteur, superficie, description, inclusions."""
74 + html = self.get(lst.url).text
75 + soup = BeautifulSoup(html, "html.parser")
76 +
77 + # secteur depuis le <title> : « Appartement - Beauport - Le 224 #… »
78 + if soup.title:
79 + parts = soup.title.get_text(strip=True).split(" - ")
80 + if len(parts) >= 2 and parts[0].lower().startswith("appartement"):
81 + lst.sector = parts[1].strip()
82 + lst.city = infer_city(lst.sector, default=lst.city or "Québec")
83 +
84 + # photos de l'unité (URLs absolues)
85 + imgs = []
86 + for img in soup.find_all("img"):
87 + src = img.get("src") or img.get("data-src") or ""
88 + if IMG_RE.match(src):
89 + imgs.append(urljoin(BASE, src))
90 + lst.images = list(dict.fromkeys(imgs))[: self.max_images]
91 +
92 + # superficie -> description complémentaire
93 + text = soup.get_text(" ", strip=True)
94 + extra = []
95 + m = re.search(r"Superficie\s+([\d\s.,]+?)\s*pi", text)
96 + if m:
97 + extra.append(f"Superficie {m.group(1).strip()} pi²")
98 + m = re.search(r"Étage\s+(\d+)", text)
99 + if m:
100 + extra.append(f"Étage {m.group(1)}")
101 +
102 + # description (paragraphe sous le h2 « Description »)
103 + for h2 in soup.find_all("h2"):
104 + if h2.get_text(strip=True).lower().startswith("description"):
105 + sib = h2.find_next("p")
106 + if sib:
107 + desc = sib.get_text(" ", strip=True)
108 + if len(desc) > 40:
109 + lst.description = desc[:600]
110 + break
111 + if extra:
112 + lst.description = (" — ".join(extra) +
113 + (" — " + lst.description if lst.description else ""))[:600]
114 +
115 + # inclusions / caractéristiques (pictos non grisés = inclus)
116 + amenities: list[str] = []
117 + for block in soup.select(".caracteristiques-appart"):
118 + for cell in block.select("div.text-center"):
119 + p = cell.find("p")
120 + img = cell.find("img")
121 + if p is None:
122 + continue
123 + style = p.get("style") or ""
124 + src = (img.get("src") or "") if img else ""
125 + if "#ccc" in style or "_off." in src:
126 + continue # service exclu (picto grisé)
127 + label = p.get_text(" ", strip=True)
128 + if not label or label.lower() == "inclus":
129 + stem = src.rsplit("/", 1)[-1].rsplit(".", 1)[0]
130 + label = _PICTO_NAMES.get(stem, stem.replace("_", " ")
131 + .replace("--", " ").replace("-", " ")
132 + .strip().capitalize())
133 + if label and label not in amenities:
134 + amenities.append(label)
135 + if amenities:
136 + lst.amenities = amenities
137 +
138 + # -- contrat ---------------------------------------------------------------
139 + def fetch(self) -> list[Listing]:
140 + html = self.get(SEARCH_URL).text
141 + soup = BeautifulSoup(html, "html.parser")
142 +
143 + listings: dict[str, Listing] = {}
144 + for card in soup.select(".un-appart"):
145 + try:
146 + link = card.select_one("a.title-link")
147 + if not link or not link.get("href"):
148 + continue
149 + slug = link["href"].rstrip("/").rsplit("/", 1)[-1]
150 + name_el = link.find(["h2", "h3"])
151 + complex_name = (name_el.get_text(" ", strip=True)
152 + if name_el else slug.replace("-", " ").title())
153 +
154 + address, city = self._card_address_city(card)
155 + city_key = strip_accents(city.lower().strip())
156 + if city_key not in ALLOWED_CITIES:
157 + continue # hors région Québec/Lévis (ex. Montréal)
158 +
159 + # lignes cliquables du tableau = unités disponibles
160 + for row in card.select("tr.clickable-row[data-href]"):
161 + cells = [td.get_text(" ", strip=True)
162 + for td in row.find_all("td")]
163 + if len(cells) < 3:
164 + continue
165 + unit_type_raw, price_raw, avail_raw = cells[0], cells[1], cells[2]
166 + if EXCLUDE_UNIT_RE.search(unit_type_raw):
167 + continue
168 + href = row["data-href"]
169 + unit_code = href.rstrip("/").rsplit("/", 1)[-1]
170 + ext_id = f"{slug}-{unit_code}"
171 + if ext_id in listings:
172 + continue
173 + avail = re.sub(r"\s*chevron_right\s*$", "",
174 + avail_raw).strip()
175 + listings[ext_id] = Listing(
176 + source=self.source_id,
177 + external_id=ext_id,
178 + url=urljoin(BASE, href),
179 + title=f"{complex_name}{unit_type_raw} (#{unit_code})",
180 + address=address,
181 + sector="",
182 + city=city,
183 + unit_type=normalize_unit_type(unit_type_raw),
184 + price=parse_price(price_raw),
185 + price_label=price_raw,
186 + availability=avail,
187 + )
188 + except Exception:
189 + continue # carte malformée : on passe à la suivante
190 +
191 + # fiches détaillées (photos, secteur, description, inclusions)
192 + for i, lst in enumerate(listings.values()):
193 + if i >= self.max_details:
194 + break
195 + try:
196 + self._enrich_from_detail(lst)
197 + except Exception:
198 + continue
199 +
200 + return list(listings.values())
added louka/connectors/lafrance_mathieu.py +89 −0
@@ -0,0 +1,89 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/lafrance_mathieu.py : connecteur Lafrance & Mathieu
5 +# (lafrance-mathieu.com — Québec, Lévis, Val-Bélair, Beauport, etc.)
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import re
10 +
11 +from bs4 import BeautifulSoup
12 +
13 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
14 +from .base import BaseConnector
15 +
16 +BASE = "https://lafrance-mathieu.com"
17 +LIST_URL = f"{BASE}/louer-appartement-quebec"
18 +
19 +
20 +class LafranceMathieuConnector(BaseConnector):
21 + source_id = "lafrance_mathieu"
22 +
23 + def fetch(self) -> list[Listing]:
24 + # 1) Découvrir tous les filtres d'arrondissement sur la page principale
25 + html = self.get(LIST_URL).text
26 + boroughs = sorted(set(re.findall(r"\?boroughs=(\d+)", html)))
27 + pages = [html] + [
28 + self.get(f"{LIST_URL}/?boroughs={b}").text for b in boroughs
29 + ]
30 +
31 + # 2) Extraire toutes les cartes (dédupliquées par id)
32 + listings: dict[str, Listing] = {}
33 + for page in pages:
34 + soup = BeautifulSoup(page, "html.parser")
35 + for card in soup.select('a[href^="/logement/"]'):
36 + m = re.match(r"/logement/(\d+)", card.get("href", ""))
37 + if not m:
38 + continue
39 + ext_id = m.group(1)
40 + if ext_id in listings:
41 + continue
42 + borough_el = card.select_one(".apartment-borough")
43 + sector = ""
44 + if borough_el:
45 + # "Appartements · Beauport" -> "Beauport"
46 + sector = borough_el.get_text(" ", strip=True).split("·")[-1].strip()
47 + addr_el = card.select_one(".apartment-address")
48 + infos_el = card.select_one(".apartment-infos")
49 + avail_el = card.select_one(".apartment-availability")
50 + infos = infos_el.get_text(" ", strip=True) if infos_el else ""
51 + amenities = [img.get("alt", "").strip()
52 + for img in card.select("img[alt]")
53 + if img.get("alt") and img.get("alt") not in
54 + ("unit image", "")]
55 + address = addr_el.get_text(strip=True) if addr_el else ""
56 + listings[ext_id] = Listing(
57 + source=self.source_id,
58 + external_id=ext_id,
59 + url=f"{BASE}/logement/{ext_id}",
60 + title=address or f"Logement {ext_id}",
61 + address=address,
62 + sector=sector,
63 + city=infer_city(sector),
64 + unit_type=normalize_unit_type(infos),
65 + price=parse_price(infos),
66 + price_label=infos,
67 + availability=avail_el.get_text(strip=True) if avail_el else "",
68 + amenities=amenities,
69 + )
70 +
71 + # 3) Page détail : toutes les images de l'appartement
72 + for lst in listings.values():
73 + try:
74 + detail = self.get(lst.url).text
75 + except Exception:
76 + continue
77 + imgs = re.findall(
78 + r'https://gilm-site-vitrine-production\.s3\.amazonaws\.com/'
79 + r'media/real_estate/[^"\s\)]+\.(?:jpg|jpeg|png|webp)',
80 + detail)
81 + lst.images = list(dict.fromkeys(imgs))
82 + dsoup = BeautifulSoup(detail, "html.parser")
83 + about = dsoup.find(string=re.compile("À propos de l'immeuble"))
84 + if about:
85 + sec = about.find_parent()
86 + if sec:
87 + lst.description = sec.get_text(" ", strip=True)[:600]
88 +
89 + return list(listings.values())
added louka/connectors/lakle.py +123 −0
@@ -0,0 +1,123 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/lakle.py : connecteur La Klé (lakle.ca)
5 +# Tour d'appartements locatifs dans la Cité Verte (Québec, secteur
6 +# Saint-Sacrement). Le sélecteur de plans WordPress expose les unités en
7 +# <polygon class="disponible" data-numero=...> et une API REST
8 +# /wp-json/do-selecteur-plans/v1/unite?id=N retourne la fiche complète
9 +# (prix, type, superficie, date libre, plan).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import re
14 +
15 +from ..schema import Listing, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://lakle.ca"
19 +PAGE_URL = f"{BASE}/phase-1-occupation-immediate/"
20 +API_URL = f"{BASE}/wp-json/do-selecteur-plans/v1/unite"
21 +SECTOR = "Cité Verte (Saint-Sacrement)"
22 +CITY = "Québec"
23 +
24 +
25 +class LaKleConnector(BaseConnector):
26 + source_id = "lakle"
27 + request_delay = 0.5
28 + max_units = 120 # garde-fou
29 +
30 + def fetch(self) -> list[Listing]:
31 + html = self.get(PAGE_URL).text
32 +
33 + # Unités disponibles sur le plan interactif
34 + units: list[dict] = []
35 + for poly in re.findall(r"<polygon[^>]*>", html):
36 + if 'class="disponible"' not in poly:
37 + continue
38 + attrs = dict(re.findall(r'data-([a-z-]+)="([^"]*)"', poly))
39 + if attrs.get("numero"):
40 + units.append(attrs)
41 +
42 + listings: list[Listing] = []
43 + seen: set[str] = set()
44 + for attrs in units[:self.max_units]:
45 + uid = attrs["numero"]
46 + if uid in seen:
47 + continue
48 + seen.add(uid)
49 + try:
50 + data = self.get(API_URL, params={"id": uid}).json()
51 + except Exception:
52 + data = {}
53 + try:
54 + if data.get("etat") not in ("disponible", None):
55 + continue
56 + unit_no = data.get("unite") or uid
57 + raw_type = data.get("type") or attrs.get("type", "")
58 + unit_type = normalize_unit_type(raw_type)
59 +
60 + price_label = (data.get("montant") or "").strip()
61 + if data.get("montant_suffixe"):
62 + price_label = f"{price_label} {data['montant_suffixe']}".strip()
63 + price = parse_price(price_label)
64 + if price is None and attrs.get("prix"):
65 + price = parse_price(attrs["prix"] + "$")
66 + price_label = price_label or f"{attrs['prix']}$"
67 +
68 + availability = (data.get("a_partir")
69 + or data.get("date_libre")
70 + or data.get("etat_titre")
71 + or "Disponible")
72 +
73 + desc_parts = []
74 + if data.get("nb_chambres"):
75 + desc_parts.append(f"{data['nb_chambres']} chambre(s)")
76 + if data.get("superficie_habitable"):
77 + desc_parts.append(
78 + f"Superficie {data['superficie_habitable']} "
79 + f"{data.get('superficie_suffixe', 'pi²')}")
80 + if data.get("superficie_terrasse"):
81 + desc_parts.append(
82 + f"{data.get('type_balcon', 'balcon').capitalize()} "
83 + f"{data['superficie_terrasse']} "
84 + f"{data.get('superficie_suffixe', 'pi²')}")
85 + if data.get("style"):
86 + desc_parts.append(f"Style {data['style']}")
87 + if attrs.get("etage"):
88 + desc_parts.insert(0, f"Étage {attrs['etage']}")
89 + if data.get("pdf"):
90 + desc_parts.append(f"Plan PDF : {data['pdf']}")
91 +
92 + amenities = ["Électroménagers inclus", "Électricité incluse",
93 + "Chauffage et climatisation",
94 + "Piscine intérieure", "Salle d'entraînement"]
95 + promo = data.get("promotion")
96 + if promo:
97 + amenities.append(f"Promotion : {promo}")
98 +
99 + images = [u for u in (data.get("gallery") or [])
100 + if isinstance(u, str)
101 + and re.search(r"\.(?:jpg|jpeg|png|webp)$", u, re.I)]
102 +
103 + listings.append(Listing(
104 + source=self.source_id,
105 + external_id=str(uid),
106 + url=PAGE_URL,
107 + title=f"La Klé — Unité {unit_no}"
108 + f"{' (' + unit_type + ')' if unit_type else ''}",
109 + address="Cité Verte, Québec",
110 + sector=SECTOR,
111 + city=CITY,
112 + unit_type=unit_type,
113 + price=price,
114 + price_label=price_label,
115 + availability=availability,
116 + description=" | ".join(desc_parts),
117 + amenities=amenities,
118 + images=images,
119 + ))
120 + except Exception:
121 + continue
122 +
123 + return listings
added louka/connectors/leclif.py +110 −0
@@ -0,0 +1,110 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/leclif.py : connecteur Le Clif (leclif.ca)
5 +# Projet mono-immeuble de 151 condos locatifs à Charlesbourg (Québec).
6 +# Toutes les unités sont dans la page d'accueil (overlays « is-overlay-N »);
7 +# les unités louées sont marquées par un script jQuery addClass('non-dispo').
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://leclif.ca"
19 +SECTOR = "Charlesbourg"
20 +ADDRESS = "19700, boul. Henri-Bourassa, Québec"
21 +
22 +
23 +def _extract_price(label: str) -> float | None:
24 + """'À partir de 2366 (incluant électros) $ / mois' -> 2366.0"""
25 + m = re.search(r"(\d[\d\s ]{2,})", label or "")
26 + if not m:
27 + return None
28 + return parse_price(m.group(1).strip() + "$")
29 +
30 +
31 +class LeClifConnector(BaseConnector):
32 + source_id = "leclif"
33 + request_delay = 0.6
34 +
35 + def fetch(self) -> list[Listing]:
36 + html = self.get(BASE + "/").text
37 +
38 + # Unités marquées non disponibles par le script de la page
39 + non_dispo = set(re.findall(
40 + r"jQuery\('\.unite-(\d+)'\)\.addClass\('non-dispo'\)", html))
41 +
42 + soup = BeautifulSoup(html, "html.parser")
43 + listings: list[Listing] = []
44 +
45 + for overlay in soup.select("div.overlay-unite"):
46 + try:
47 + cls = " ".join(overlay.get("class", []))
48 + m = re.search(r"is-overlay-(\d+)", cls)
49 + if not m:
50 + continue
51 + num = m.group(1)
52 + if num in non_dispo:
53 + continue # unité louée
54 +
55 + price_el = overlay.select_one("h3.price")
56 + price_label = (price_el.get_text(" ", strip=True)
57 + if price_el else "")
58 + price = _extract_price(price_label)
59 +
60 + # Liste des caractéristiques : Disponibilité / Type / Modèle /
61 + # Superficie / Terrasse
62 + availability = unit_type = model = ""
63 + av = re.search(r"Disponibilité\s*:\s*([\wéûà]+)",
64 + overlay.get_text(" ", strip=True))
65 + if av:
66 + availability = f"Disponible en {av.group(1)}"
67 + desc_parts: list[str] = []
68 + for li in overlay.select("ul.flex li"):
69 + t = li.get_text(" ", strip=True)
70 + if t.startswith("Disponibilité"):
71 + availability = t.split(":", 1)[-1].strip()
72 + elif t.startswith("Type"):
73 + unit_type = normalize_unit_type(t.split(":", 1)[-1])
74 + elif t.startswith("Modèle"):
75 + model = t.split(":", 1)[-1].strip()
76 + elif t.startswith(("Superficie", "Terrasse")):
77 + desc_parts.append(re.sub(r"\s+", " ", t))
78 + if model:
79 + desc_parts.insert(0, f"Modèle {model}")
80 +
81 + # Images : aperçu du plan (jpg) dans l'overlay
82 + images: list[str] = []
83 + for img in overlay.select("img[src]"):
84 + src = img["src"]
85 + if src.startswith("http") and re.search(
86 + r"\.(?:jpg|jpeg|png|webp)$", src, re.I):
87 + images.append(src)
88 + images = list(dict.fromkeys(images))
89 +
90 + listings.append(Listing(
91 + source=self.source_id,
92 + external_id=f"unite-{num}",
93 + url=f"{BASE}/#unite-{num}",
94 + title=f"Le Clif — Unité {num}"
95 + f"{' (' + unit_type + ')' if unit_type else ''}",
96 + address=ADDRESS,
97 + sector=SECTOR,
98 + city=infer_city(SECTOR),
99 + unit_type=unit_type,
100 + price=price,
101 + price_label=price_label,
102 + availability=availability or "Disponible",
103 + description=" | ".join(desc_parts),
104 + amenities=[],
105 + images=images,
106 + ))
107 + except Exception:
108 + continue
109 +
110 + return listings
added louka/connectors/ledomaine.py +120 −0
@@ -0,0 +1,120 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/ledomaine.py : connecteur Les Habitations Le Domaine
5 +# (ledomaine.ca — grand ensemble locatif du quartier Mercier,
6 +# arrondissement Mercier–Hochelaga-Maisonneuve, Montréal).
7 +# Site WordPress rendu serveur : une page par typologie
8 +# (/appartement/appartement-3-et-demi/, etc.) avec prix « à partir de »,
9 +# description, galerie photos et plan. Une annonce par typologie.
10 +# Les alias (ex. /appartement-2/) sont dédupliqués via <link rel=canonical>.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +
16 +from bs4 import BeautifulSoup
17 +
18 +from ..schema import Listing, normalize_unit_type, parse_price
19 +from .base import BaseConnector
20 +
21 +BASE = "https://www.ledomaine.ca"
22 +
23 +ADDRESS = "2990, Avenue de Granby, Montréal"
24 +SECTOR = "Mercier"
25 +CITY = "Montréal"
26 +
27 +_APT_LINK_RE = re.compile(r"https?://www\.ledomaine\.ca/appartement/([a-z0-9-]+)/?")
28 +_IMG_HREF_RE = re.compile(
29 + r'href="(https://www\.ledomaine\.ca/wp-content/uploads/[^"]+\.(?:jpg|jpeg|png|webp))"',
30 + re.I)
31 +_PRICE_RE = re.compile(r"[àa] partir de\s*([\d\s ]+)\s*\$", re.I)
32 +
33 +
34 +class LeDomaineConnector(BaseConnector):
35 + source_id = "ledomaine"
36 + request_delay = 0.6
37 + max_pages = 12 # garde-fou de crawl
38 +
39 + def fetch(self) -> list[Listing]:
40 + listings: dict[str, Listing] = {}
41 + try:
42 + home = self.get(BASE + "/").text
43 + except Exception:
44 + return []
45 +
46 + slugs = list(dict.fromkeys(_APT_LINK_RE.findall(home)))
47 + for slug in slugs[: self.max_pages]:
48 + try:
49 + lst = self._parse_page(slug)
50 + except Exception:
51 + continue
52 + if lst and lst.external_id not in listings:
53 + listings[lst.external_id] = lst
54 + return list(listings.values())
55 +
56 + def _parse_page(self, slug: str) -> Listing | None:
57 + url = f"{BASE}/appartement/{slug}/"
58 + html = self.get(url).text
59 + soup = BeautifulSoup(html, "html.parser")
60 +
61 + # Déduplication des alias (/appartement-2/ -> /appartement-3-et-demi/)
62 + canon = soup.find("link", rel="canonical")
63 + if canon and canon.get("href"):
64 + m = _APT_LINK_RE.search(canon["href"])
65 + if m:
66 + slug = m.group(1)
67 + url = f"{BASE}/appartement/{slug}/"
68 +
69 + h1 = soup.find("h1")
70 + title = h1.get_text(" ", strip=True) if h1 else slug.replace("-", " ")
71 +
72 + # « Appartement 4 et demi sous-sol » -> 4½ (mention conservée au titre)
73 + tm = re.search(r"(\d)\s*et\s*demi", title, re.I)
74 + unit_type = f"{tm.group(1)}½" if tm else normalize_unit_type(title)
75 +
76 + # Prix « à partir de NNN $ par mois » (meta description ou corps)
77 + price = None
78 + price_label = ""
79 + meta = soup.find("meta", attrs={"name": "description"})
80 + sources = [meta.get("content", "") if meta else "",
81 + soup.get_text(" ", strip=True)]
82 + for txt in sources:
83 + m = _PRICE_RE.search(txt or "")
84 + if m:
85 + amount = re.sub(r"[\s ]", "", m.group(1))
86 + price_label = f"À partir de {amount} $/mois"
87 + price = parse_price(f"{amount}$")
88 + break
89 +
90 + # Description : bloc entre « Description de l'appartement » et la suite
91 + description = ""
92 + body_txt = soup.get_text("|", strip=True)
93 + dm = re.search(r"Description\|de l'appartement\|(.{20,900}?)\|Consulter",
94 + body_txt, re.S)
95 + if dm:
96 + description = re.sub(r"\s*\|\s*", " ", dm.group(1))
97 + description = re.sub(r"\s+", " ", description).strip()[:600]
98 +
99 + # Toutes les images (galerie + plan) de la page
100 + images = [u for u in dict.fromkeys(_IMG_HREF_RE.findall(html))
101 + if not re.search(r"logo|icon|favicon", u, re.I)]
102 + if not images:
103 + return None
104 +
105 + return Listing(
106 + source=self.source_id,
107 + external_id=slug,
108 + url=url,
109 + title=f"{title} — Les Habitations Le Domaine",
110 + address=ADDRESS,
111 + sector=SECTOR,
112 + city=CITY,
113 + unit_type=unit_type,
114 + price=price,
115 + price_label=price_label,
116 + availability="",
117 + description=description,
118 + amenities=[],
119 + images=images,
120 + )
added louka/connectors/loftsmtl.py +186 −0
@@ -0,0 +1,186 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/loftsmtl.py : connecteur Lofts MTL (loftsmtl.com)
5 +# Lofts et appartements — Mile End, Plateau, Vieux-Montréal,
6 +# Ville Mont-Royal. Plateforme Rentsync/LiftSystem : l'inventaire des
7 +# immeubles vient de l'API publique api.theliftsystem.com (client_id 773,
8 +# jeton public embarqué dans le site), puis chaque page immeuble
9 +# (rendu serveur) expose les unités disponibles (div.suite : type, prix,
10 +# pi², photos, disponibilité).
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +
16 +from bs4 import BeautifulSoup
17 +
18 +from ..schema import Listing, normalize_unit_type
19 +from .base import BaseConnector
20 +
21 +SITE = "https://www.loftsmtl.com"
22 +API_URL = ("https://api.theliftsystem.com/v2/search"
23 + "?locale=en&client_id=773&auth_token=sswpREkUtyeYjeoahA2i"
24 + "&show_all_properties=true&limit=200")
25 +
26 +# Villes de la région de Montréal telles que renvoyées par l'API
27 +_CITY_MAP = {
28 + "montréal": "Montréal", "montreal": "Montréal",
29 + "mont-royal": "Mont-Royal", "mount royal": "Mont-Royal",
30 + "westmount": "Westmount", "outremont": "Montréal",
31 +}
32 +
33 +
34 +def _suite_type(raw: str) -> str:
35 + s = (raw or "").strip().lower()
36 + if "studio" in s or "loft" in s and not re.search(r"\d", s):
37 + return "Studio" if "studio" in s else "Loft"
38 + m = re.search(r"(\d)\s*(?:1/2|½)", s)
39 + if m:
40 + return f"{m.group(1)}½"
41 + m = re.search(r"(\d)\s*bed", s)
42 + if m:
43 + return {0: "Studio", 1: "3½", 2: "4½", 3: "5½"}.get(
44 + int(m.group(1)), f"{m.group(1)} chambres")
45 + return normalize_unit_type(raw)
46 +
47 +
48 +def _parse_price_us(raw: str) -> float | None:
49 + m = re.search(r"\$\s*([\d,\s]+(?:\.\d{2})?)", raw or "")
50 + if not m:
51 + return None
52 + try:
53 + val = float(m.group(1).replace(",", "").replace(" ", "").replace(" ", ""))
54 + except ValueError:
55 + return None
56 + return val if 100 <= val <= 20000 else None
57 +
58 +
59 +def _strip_html(raw: str) -> str:
60 + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", raw or "")).strip()
61 +
62 +
63 +class LoftsMtlConnector(BaseConnector):
64 + source_id = "loftsmtl"
65 + request_delay = 0.6
66 + max_buildings = 25 # garde-fou
67 +
68 + def fetch(self) -> list[Listing]:
69 + listings: list[Listing] = []
70 + try:
71 + buildings = self.get(API_URL).json()
72 + except Exception:
73 + return listings
74 + if not isinstance(buildings, list):
75 + return listings
76 +
77 + for i, b in enumerate(buildings):
78 + if i >= self.max_buildings:
79 + break
80 + try:
81 + listings.extend(self._building_listings(b))
82 + except Exception:
83 + continue
84 + return listings
85 +
86 + # -- une page immeuble -> annonces par unité disponible ---------------------
87 + def _building_listings(self, b: dict) -> list[Listing]:
88 + addr = b.get("address") or {}
89 + raw_city = (addr.get("city") or "").strip()
90 + city = _CITY_MAP.get(raw_city.lower())
91 + if not city:
92 + return [] # hors région de Montréal
93 + sector = (addr.get("neighbourhood") or "").strip()
94 + if sector.lower() == "town of mount royal":
95 + sector = "" if city == "Mont-Royal" else sector
96 + bid = b.get("id")
97 + name = (b.get("name") or "").strip()
98 + address = (addr.get("address") or "").strip()
99 + permalink = (b.get("permalink") or "").strip() or SITE
100 + desc = _strip_html((b.get("details") or {}).get("overview", ""))[:600]
101 +
102 + # La page immeuble sert /apartments/<slug> ou /residential/<slug>
103 + html = ""
104 + for url in (permalink,
105 + permalink.replace("/apartments/", "/residential/")):
106 + try:
107 + html = self.get(url).text
108 + break
109 + except Exception:
110 + continue
111 + if not html:
112 + return []
113 + soup = BeautifulSoup(html, "html.parser")
114 +
115 + results: list[Listing] = []
116 + for suite in soup.select("div.suite"):
117 + try:
118 + lst = self._parse_suite(suite, b, city, sector, name,
119 + address, permalink, desc)
120 + except Exception:
121 + continue
122 + if lst:
123 + results.append(lst)
124 + return results
125 +
126 + def _parse_suite(self, suite, b, city, sector, name, address,
127 + permalink, desc) -> Listing | None:
128 + type_el = suite.select_one(".suite-type")
129 + if not type_el:
130 + return None
131 + raw_type = type_el.get_text(" ", strip=True)
132 + num_el = suite.select_one(".suite-number")
133 + number = num_el.get_text(" ", strip=True) if num_el else ""
134 +
135 + rate_el = suite.select_one(".suite-rate .value") or \
136 + suite.select_one(".suite-rate")
137 + price_label = rate_el.get_text(" ", strip=True) if rate_el else ""
138 + price = _parse_price_us(price_label)
139 +
140 + sqft_el = suite.select_one(".suite-sqft .value")
141 + sqft = sqft_el.get_text(strip=True) if sqft_el else ""
142 + bath_el = suite.select_one(".suite-bath .value")
143 + baths = bath_el.get_text(strip=True) if bath_el else ""
144 +
145 + avail_el = suite.select_one(".suite-availability")
146 + availability = avail_el.get_text(" ", strip=True) if avail_el else ""
147 + availability = re.sub(r"^Availab\w*\s*", "", availability).strip()
148 +
149 + photos = [a.get("href") for a in suite.select("a.suite-photo")
150 + if a.get("href")]
151 + photos = list(dict.fromkeys(photos))[:30]
152 +
153 + # id stable : rel="suite-995148-photos" sinon immeuble+numéro
154 + sid = ""
155 + first = suite.select_one("a.suite-photo[rel]")
156 + if first:
157 + rel = first.get("rel") or ""
158 + if isinstance(rel, (list, tuple)):
159 + rel = " ".join(rel)
160 + m = re.match(r"suite-(\d+)", rel)
161 + if m:
162 + sid = m.group(1)
163 + ext_id = sid or f"{b.get('id')}-{re.sub(r'[^0-9A-Za-z-]', '', number)}"
164 +
165 + amenities = []
166 + if baths:
167 + amenities.append(f"{baths} salle(s) de bain")
168 + if sqft and sqft != "0":
169 + amenities.append(f"{sqft} pi²")
170 +
171 + return Listing(
172 + source=self.source_id,
173 + external_id=str(ext_id),
174 + url=permalink,
175 + title=f"{name} — unité {number}" if number else name,
176 + address=address,
177 + sector=sector,
178 + city=city,
179 + unit_type=_suite_type(raw_type),
180 + price=price,
181 + price_label=f"{price_label}/mo" if price_label else "",
182 + availability=availability or "Disponible",
183 + description=desc,
184 + amenities=amenities,
185 + images=photos,
186 + )
added louka/connectors/logisbourg.py +166 −0
@@ -0,0 +1,166 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/logisbourg.py : connecteur Logisbourg (logisbourg.com)
5 +# Site ASP classique : tableau des disponibilités par immeuble
6 +# (Le Bourgarde, Les Terrasses du Bourg, Le Bourg du Maizerets,
7 +# Le Bourg du Fleuve) + fiche appartement.asp pour adresse, images
8 +# et commodités.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import html as htmllib
13 +import re
14 +from urllib.parse import quote
15 +
16 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
17 +from .base import BaseConnector
18 +
19 +BASE = "https://www.logisbourg.com"
20 +LIST_URL = f"{BASE}/appartements-disponibles.asp"
21 +
22 +# En-têtes d'immeuble : "Charlesbourg / Lebourgneuf : Le Bourgarde"
23 +_COMPLEX_RE = re.compile(
24 + r'class="dispos_td_complexe".*?<div class="nomComplexe">\s*(.*?)<BR>\s*'
25 + r'<div class="nomComplexeAdresse">(.*?)</div>', re.S | re.I)
26 +# Lignes de logement : <TR id="tr_23_" ...> ... </TR>
27 +_ROW_RE = re.compile(r'<TR id="tr_(\d+)_".*?</TR>', re.S | re.I)
28 +_LINK_RE = re.compile(
29 + r"appartement\.asp\?SID=(\d+)&CID=(\d+)&LID=(\d+)&Secteur=([^&']*)&Complexe=([^']*)'")
30 +_IMG_ALT_BLACKLIST = re.compile(
31 + r"logisbourg|appartements?$|condos|notifications|carri[èe]res|contact|"
32 + r"linkedin|facebook|youtube|favoris|plan appartement", re.I)
33 +
34 +
35 +def _strip_tags(s: str) -> str:
36 + return re.sub(r"\s+", " ", htmllib.unescape(re.sub(r"<[^>]+>", " ", s))).strip()
37 +
38 +
39 +class LogisbourgConnector(BaseConnector):
40 + source_id = "logisbourg"
41 + request_delay = 0.6
42 + max_details = 80 # garde-fou de fetch des fiches
43 +
44 + # le serveur n'annonce pas de charset : forcer l'UTF-8 déclaré dans le HTML
45 + def _get_text(self, url: str) -> str:
46 + resp = self.get(url)
47 + resp.encoding = "utf-8"
48 + return resp.text
49 +
50 + def fetch(self) -> list[Listing]:
51 + html = self._get_text(LIST_URL)
52 +
53 + # 1) Position des en-têtes d'immeuble (secteur : nom + adresse de rue)
54 + complexes: list[tuple[int, str, str, str]] = [] # (pos, secteur, nom, rue)
55 + for m in _COMPLEX_RE.finditer(html):
56 + header = _strip_tags(m.group(1)) # "Secteur : Nom"
57 + street = _strip_tags(m.group(2)) # "6e Avenue Ouest"
58 + sector, _, name = header.partition(":")
59 + complexes.append((m.start(), sector.strip(), name.strip(), street))
60 +
61 + # 2) Lignes de disponibilités
62 + listings: dict[str, Listing] = {}
63 + for row_m in _ROW_RE.finditer(html):
64 + row = row_m.group(0)
65 + link = _LINK_RE.search(row)
66 + if not link:
67 + continue
68 + sid, cid, lid, sector_q, complex_q = link.groups()
69 + if lid in listings:
70 + continue
71 +
72 + # immeuble courant = dernier en-tête avant cette ligne
73 + sector = name = street = ""
74 + for pos, sec, nom, rue in complexes:
75 + if pos < row_m.start():
76 + sector, name, street = sec, nom, rue
77 + else:
78 + break
79 + sector = sector or sector_q
80 + name = name or complex_q
81 +
82 + def cell(cls: str) -> str:
83 + c = re.search(r'class="%s"[^>]*>(.*?)</TD>' % cls, row, re.S | re.I)
84 + return _strip_tags(c.group(1)) if c else ""
85 +
86 + def cell_title(cls: str) -> str:
87 + c = re.search(r'class="%s"\s+TITLE="([^"]*)"' % cls, row, re.I)
88 + return htmllib.unescape(c.group(1)).replace("\xa0", " ") if c else ""
89 +
90 + unit_raw = cell("Grandeur")
91 + avail = cell_title("DateDispo") or cell("DateDispo")
92 + floor = cell_title("Etage") or cell("Etage")
93 + price_label = cell("Prix")
94 + # inclusions (icônes avec ALT dans la colonne "Inclus")
95 + included = [htmllib.unescape(a) for a in
96 + re.findall(r'ALT="([^"]+)"', row, re.I)
97 + if "favoris" not in a.lower()]
98 +
99 + url = (f"{BASE}/appartement.asp?SID={sid}&CID={cid}&LID={lid}"
100 + f"&Secteur={quote(sector_q)}&Complexe={quote(complex_q)}")
101 + amenities = list(dict.fromkeys(included))
102 + if floor:
103 + amenities.append(f"Étage : {floor}")
104 +
105 + listings[lid] = Listing(
106 + source=self.source_id,
107 + external_id=lid,
108 + url=url,
109 + title=f"{name}{unit_raw}".strip(" —"),
110 + sector=sector,
111 + city=infer_city(sector, default="Québec"),
112 + unit_type=normalize_unit_type(unit_raw),
113 + price=parse_price(price_label),
114 + price_label=price_label,
115 + availability=avail,
116 + amenities=amenities,
117 + )
118 +
119 + # 3) Fiches : adresse civique, images, commodités, superficie
120 + for i, lst in enumerate(listings.values()):
121 + if i >= self.max_details:
122 + break
123 + try:
124 + detail = self._get_text(lst.url)
125 + except Exception:
126 + continue
127 +
128 + # adresse civique (variables JS adr1/adr2 de la carte Google)
129 + m = re.search(r'var adr1 = "([^"]*)";\s*var adr2 = "([^"]*)"', detail)
130 + if m and m.group(1):
131 + street = _strip_tags(m.group(2))
132 + street = re.sub(r"(\d)\s+e(\s+|$)", r"\1e ", street).strip()
133 + addr = f"{m.group(1)}, {street}".strip(" ,")
134 + lst.address = addr
135 + lst.title = f"{addr}{lst.unit_type}".strip(" —")
136 +
137 + # retirer commentaires et scripts avant les autres extractions
138 + detail = re.sub(r"<!--.*?-->", " ", detail, flags=re.S)
139 + detail = re.sub(r"<script.*?</script>", " ", detail, flags=re.S | re.I)
140 + # images de l'immeuble : ./<secteur>/<immeuble>/images/xxx.jpg
141 + imgs = re.findall(
142 + r'(?:\./)?([a-z0-9_\-]+/[a-z0-9_\-]+/images/[^"\'\s]+'
143 + r'\.(?:jpg|jpeg|png|webp))', detail, re.I)
144 + lst.images = [f"{BASE}/{u}" for u in dict.fromkeys(imgs)][:25]
145 +
146 + # commodités supplémentaires (icônes ALT) + superficie
147 + alts = [htmllib.unescape(a or b) for a, b in
148 + re.findall(r'(?:ALT|alt)=(?:"([^"]{3,60})"|\'([^\']{3,60})\')', detail)
149 + if not _IMG_ALT_BLACKLIST.search(a or b)]
150 + for a in dict.fromkeys(alts):
151 + if a not in lst.amenities:
152 + lst.amenities.append(a)
153 + m = re.search(r"(\d{3,4})\s*pi", detail)
154 + if m:
155 + lst.amenities.append(f"{m.group(1)} pi²")
156 +
157 + # caractéristiques de l'immeuble -> description
158 + m = re.search(r"Caract[ée]ristiques immeuble(.*?)(?:Services optionnels|"
159 + r"Les informations)", detail, re.S | re.I)
160 + if m:
161 + block = re.sub(r"<!--.*?-->", " ", m.group(1), flags=re.S)
162 + block = re.sub(r"<script.*?</script>", " ", block, flags=re.S | re.I)
163 + lst.description = ("Caractéristiques de l'immeuble : "
164 + + _strip_tags(block))[:500]
165 +
166 + return list(listings.values())
added louka/connectors/logisco.py +209 −0
@@ -0,0 +1,209 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/logisco.py : connecteur Logisco (logisco.com)
5 +# Site rendu serveur. Crawl : page liste principale (+ pages villes) pour
6 +# découvrir les pages projets, puis extraction des unités disponibles
7 +# (cartes .unitCard) sur chaque page projet. Une annonce par unité.
8 +# Villes retenues : Québec, Lévis, Saint-Augustin-de-Desmaures
9 +# (Donnacona et autres villes hors région exclues).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import re
14 +from urllib.parse import urljoin
15 +
16 +from bs4 import BeautifulSoup
17 +
18 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
19 +from .base import BaseConnector
20 +
21 +BASE = "https://logisco.com"
22 +LIST_URL = f"{BASE}/fr/appartements-a-louer"
23 +
24 +# villes de la région Québec/Lévis retenues (segment d'URL -> nom de ville)
25 +CITY_MAP = {
26 + "quebec": "Québec",
27 + "levis": "Lévis",
28 + "saint-augustin-de-desmaures": "Saint-Augustin-de-Desmaures",
29 +}
30 +
31 +PROJECT_HREF_RE = re.compile(
32 + r'href="(https://logisco\.com/fr/appartements-a-louer/([a-z0-9\-]+(?:/[a-z0-9\-]+)*))"'
33 +)
34 +STATIC_IMG_RE = re.compile(
35 + r'(?:src|data-src|href)="(https://logisco\.com/static/[^"]+\.(?:webp|jpe?g|png))"'
36 +)
37 +EXCLUDE_UNIT_RE = re.compile(r"stationnement|parking|rangement|commercial|garage", re.I)
38 +
39 +
40 +def _text(el, sep: str = " ") -> str:
41 + """Texte d'un élément, robuste aux <template> Vue du site.
42 +
43 + bs4 classe les chaînes situées sous une balise <template> comme
44 + TemplateString et les exclut de get_text() par défaut ; or les cartes
45 + d'unités de Logisco se retrouvent imbriquées sous de telles balises.
46 + `types=None` force la prise en compte de toutes les chaînes.
47 + """
48 + if el is None:
49 + return ""
50 + return el.get_text(sep, strip=True, types=None)
51 +
52 +
53 +class LogiscoConnector(BaseConnector):
54 + source_id = "logisco"
55 + request_delay = 0.6
56 + max_projects = 40 # garde-fou : nb max de pages projets crawlées
57 + max_project_imgs = 15 # nb max de photos projet par annonce
58 +
59 + # -- découverte des pages projets ----------------------------------------
60 + def _discover_projects(self) -> dict[str, tuple[str, str]]:
61 + """Retourne {url_projet: (segment_ville, segment_secteur)}."""
62 + pages = []
63 + try:
64 + pages.append(self.get(LIST_URL).text)
65 + except Exception:
66 + pass
67 + for city in CITY_MAP:
68 + try:
69 + pages.append(self.get(f"{LIST_URL}/{city}").text)
70 + except Exception:
71 + continue
72 +
73 + projects: dict[str, tuple[str, str]] = {}
74 + for html in pages:
75 + for url, path in PROJECT_HREF_RE.findall(html):
76 + parts = path.split("/")
77 + city = parts[0]
78 + if city not in CITY_MAP:
79 + continue # ex. Donnacona et autres villes hors région
80 + # quebec/levis : ville/secteur/projet (3 segments)
81 + # saint-augustin : ville/projet (2 segments)
82 + if city in ("quebec", "levis"):
83 + if len(parts) != 3:
84 + continue
85 + sector = parts[1]
86 + elif len(parts) == 2:
87 + sector = city
88 + else:
89 + continue
90 + projects.setdefault(url, (city, sector))
91 + return projects
92 +
93 + # -- extraction d'une page projet -----------------------------------------
94 + def _project_images(self, html: str) -> list[str]:
95 + """Photos du projet (sans logos ni plans d'unités), dédupliquées."""
96 + imgs = []
97 + for u in STATIC_IMG_RE.findall(html):
98 + name = u.rsplit("/", 1)[-1].lower()
99 + if name.startswith(("logo-", "plan-")) or "logo" in name.split("-")[:1]:
100 + continue
101 + imgs.append(u)
102 + return list(dict.fromkeys(imgs))[: self.max_project_imgs]
103 +
104 + def fetch(self) -> list[Listing]:
105 + projects = self._discover_projects()
106 + listings: dict[str, Listing] = {}
107 +
108 + for i, (proj_url, (city_seg, sector_seg)) in enumerate(projects.items()):
109 + if i >= self.max_projects:
110 + break
111 + try:
112 + html = self.get(proj_url).text
113 + except Exception:
114 + continue
115 + soup = BeautifulSoup(html, "html.parser")
116 +
117 + project_name = ""
118 + h1 = soup.find("h1")
119 + if h1:
120 + # retirer le slogan SEO : « District GC • Appartements … »,
121 + # « L'AMALGAM - Grands appartements à louer … »
122 + project_name = re.split(r"\s+[•|–—]\s+|\s+-\s+", _text(h1))[0].strip()
123 + proj_imgs = self._project_images(html)
124 + cards = soup.select("li.unitCard, .unitCard")
125 + n_dispo = len(cards)
126 +
127 + for card in cards:
128 + try:
129 + unit_gtm_type = (card.get("data-gtm-unit-type") or "").upper()
130 + if unit_gtm_type and unit_gtm_type != "APARTMENT":
131 + continue
132 + link = card.select_one("a.unitCard-link")
133 + if not link or not link.get("href"):
134 + continue
135 + url = urljoin(BASE, link["href"])
136 +
137 + ext_id = card.get("data-gtm-unit-id") or ""
138 + if not ext_id:
139 + ext_id = url.rstrip("/").rsplit("/", 1)[-1]
140 +
141 + title = _text(card.select_one(".unitCard-title"))
142 + if EXCLUDE_UNIT_RE.search(title):
143 + continue
144 +
145 + address = _text(card.select_one(".unitCard-address"))
146 +
147 + # secteur : « Desjardins, Lévis » -> « Desjardins »
148 + sector = ""
149 + map_el = card.select_one(".unitCard-address--map")
150 + if map_el:
151 + sector = _text(map_el).split(",")[0].strip()
152 + if not sector:
153 + sector = sector_seg.replace("-", " ").title()
154 +
155 + sizes = [_text(s) for s in card.select(".unitCard-size")]
156 + unit_type = ""
157 + for s in sizes:
158 + if re.match(r"^\d\s*(?:1/2|½)|^(studio|loft)", s, re.I):
159 + unit_type = s
160 + break
161 +
162 + price_label = _text(card.select_one(".unitCard-price"))
163 +
164 + availability = _text(card.select_one(".unitCard-dispo"))
165 +
166 + amenities = list(dict.fromkeys(
167 + t for t in (_text(sr) for sr in
168 + card.select(".unitCard-services .sr-only"))
169 + if t
170 + ))
171 +
172 + images = []
173 + img_el = card.select_one("img.unitCard-image")
174 + if img_el and img_el.get("src"):
175 + images.append(urljoin(BASE, img_el["src"]))
176 + images += [u for u in proj_imgs if u not in images]
177 +
178 + extra = ", ".join(s for s in sizes if s != unit_type)
179 + desc_bits = []
180 + if project_name:
181 + desc_bits.append(f"Projet {project_name}")
182 + if extra:
183 + desc_bits.append(extra)
184 + desc_bits.append(f"{n_dispo} unité(s) disponible(s) dans le projet")
185 +
186 + default_city = CITY_MAP.get(city_seg, "Québec")
187 + full_title = (f"{project_name}{title}"
188 + if project_name and title else title or project_name)
189 +
190 + listings[ext_id] = Listing(
191 + source=self.source_id,
192 + external_id=str(ext_id),
193 + url=url,
194 + title=full_title or f"Unité {ext_id}",
195 + address=address,
196 + sector=sector,
197 + city=infer_city(sector, default=default_city),
198 + unit_type=normalize_unit_type(unit_type),
199 + price=parse_price(price_label),
200 + price_label=price_label,
201 + availability=availability,
202 + description=" — ".join(desc_bits)[:600],
203 + amenities=amenities,
204 + images=images,
205 + )
206 + except Exception:
207 + continue # ne jamais planter sur une carte malformée
208 +
209 + return list(listings.values())
added louka/connectors/logisma.py +145 −0
@@ -0,0 +1,145 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/logisma.py : connecteur Logisma (logisma.ca)
5 +# Page de recherche /appartements-a-louer/ (boucle Elementor) : une carte
6 +# par unité disponible. Pages détail pour disponibilité, inclusions et
7 +# toutes les images. La section commerciale/industrielle est exclue.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://logisma.ca"
19 +LIST_URL = f"{BASE}/appartements-a-louer/"
20 +
21 +DETAIL_RE = re.compile(r"/appartements-a-louer/([a-z0-9\-]+)/?$")
22 +IMG_RE = re.compile(
23 + r"https://logisma\.ca/wp-content/uploads/[^\"'\\\s\)]+"
24 + r"\.(?:jpg|jpeg|png|webp)", re.I)
25 +IMG_NOISE_RE = re.compile(r"logo|favicon|icon|-\d+x\d+\.", re.I)
26 +
27 +
28 +class LogismaConnector(BaseConnector):
29 + source_id = "logisma"
30 + request_delay = 0.6
31 + max_details = 60 # garde-fou
32 +
33 + def fetch(self) -> list[Listing]:
34 + html = self.get(LIST_URL).text
35 + soup = BeautifulSoup(html, "html.parser")
36 +
37 + listings: dict[str, Listing] = {}
38 + # Cartes : <h3 class="... containsUrl" attr-url="...">
39 + # <b>3 1/2</b> Québec, Ste-Foy<br>3 1/2 – 3003 avenue d'Entremont #5
40 + for h3 in soup.select("h3.containsUrl[attr-url]"):
41 + try:
42 + lst = self._parse_card(h3)
43 + except Exception:
44 + continue
45 + if lst and lst.external_id not in listings:
46 + listings[lst.external_id] = lst
47 +
48 + for i, lst in enumerate(listings.values()):
49 + if i >= self.max_details:
50 + break
51 + try:
52 + self._enrich(lst)
53 + except Exception:
54 + continue
55 +
56 + return list(listings.values())
57 +
58 + def _parse_card(self, h3) -> Listing | None:
59 + url = h3.get("attr-url", "").split("?")[0]
60 + m = DETAIL_RE.search(url)
61 + if not m:
62 + return None
63 + slug = m.group(1)
64 +
65 + b = h3.find("b")
66 + unit_raw = b.get_text(" ", strip=True) if b else ""
67 + lines = [t.strip() for t in h3.get_text("\n", strip=True).split("\n")
68 + if t.strip()]
69 + # lines ~ ['3 1/2', 'Québec, Ste-Foy', '3 1/2 – 3003 avenue …, Ste-Foy']
70 + sector = ""
71 + title = lines[-1] if lines else slug
72 + # Ligne ville/secteur : « Québec, Ste-Foy » (exclure hors Québec/Lévis)
73 + for ln in lines:
74 + mm = re.match(r"^([A-Za-zÀ-ÿ\-'’ ]+)\s*,\s*([A-Za-zÀ-ÿ\-'’ ]+)$", ln)
75 + if mm and "–" not in ln:
76 + city_raw = mm.group(1).strip().lower()
77 + if city_raw not in ("québec", "quebec", "lévis", "levis"):
78 + return None # hors agglomération Québec/Lévis
79 + sector = mm.group(2).strip()
80 + break
81 + # Adresse : après le tiret du titre (« 3 1/2 – 3003 avenue … #5, Ste-Foy »)
82 + address = ""
83 + if "–" in title or " - " in title:
84 + rest = re.split(r"|\s-\s", title, maxsplit=1)[-1].strip()
85 + segs = [s.strip() for s in rest.split(",")]
86 + if len(segs) > 1 and not re.search(r"\d", segs[-1]):
87 + segs = segs[:-1] # retirer le secteur final
88 + address = ", ".join(s for s in segs if s)
89 +
90 + # Prix : dans le conteneur de la carte (élément frère du titre)
91 + price_label = ""
92 + container = h3.find_parent(class_=re.compile(r"add-link-container")) or \
93 + h3.find_parent("div", attrs={"data-e-type": "container"})
94 + if container:
95 + mp = re.search(r"\d[\d\s ]*\$", container.get_text(" ", strip=True))
96 + if mp:
97 + price_label = mp.group(0).strip()
98 +
99 + return Listing(
100 + source=self.source_id,
101 + external_id=slug,
102 + url=url,
103 + title=title,
104 + address=address,
105 + sector=sector,
106 + city=infer_city(sector),
107 + unit_type=normalize_unit_type(unit_raw),
108 + price=parse_price(price_label),
109 + price_label=price_label,
110 + )
111 +
112 + def _enrich(self, lst: Listing) -> None:
113 + html = self.get(lst.url).text
114 + soup = BeautifulSoup(html, "html.parser")
115 + body = soup.get_text(" ", strip=True)
116 +
117 + # Disponibilité : « Disponible le 1er juillet 2026 »
118 + m = re.search(r"[Dd]isponible\b(?!s)\s*(?:le|dès|en|maintenant|"
119 + r"immédiatement|1er)[^.<,$]{0,50}", body)
120 + if m:
121 + avail = re.sub(r"\s+", " ", m.group(0)).strip()
122 + avail = re.split(r"\s+(?:UN MOIS|Description|Électro|Offre|Vous)",
123 + avail)[0]
124 + lst.availability = avail.strip()
125 +
126 + # Prix de secours si absent de la carte
127 + if lst.price is None:
128 + mp = re.search(r"(\d[\d\s ]*\$)\s*par mois", body)
129 + if mp:
130 + lst.price_label = mp.group(1).strip()
131 + lst.price = parse_price(lst.price_label)
132 +
133 + # Inclusions « • Eau chaude inclus »
134 + amen = [re.sub(r"\s+", " ", a).strip(" .")
135 + for a in re.findall(r"\s*([^•<\n]{3,60})", body)]
136 + if amen:
137 + lst.amenities = list(dict.fromkeys(amen))[:20]
138 +
139 + og = soup.find("meta", attrs={"property": "og:description"}) or \
140 + soup.find("meta", attrs={"name": "description"})
141 + if og and og.get("content"):
142 + lst.description = og["content"].strip()[:600]
143 +
144 + lst.images = [u for u in dict.fromkeys(IMG_RE.findall(html))
145 + if not IMG_NOISE_RE.search(u)][:25]
added louka/connectors/lokalia.py +194 −0
@@ -0,0 +1,194 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/lokalia.py : connecteur Espaces Lokalia (espaceslokalia.ca)
5 +# Le gestionnaire couvre plusieurs régions (Québec/Lévis + Grand Montréal :
6 +# Laval, Longueuil/Rive-Sud, Montérégie, Rive-Nord proche...)
7 +# -> on interroge l'endpoint AJAX WordPress `get_immeubles_map_data`
8 +# région par région (slugs découverts dans le filtre du site), ce qui donne
9 +# la ville par défaut de chaque immeuble.
10 +# Une annonce par type d'unité offert dans chaque immeuble.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import json
15 +import re
16 +import time
17 +
18 +from bs4 import BeautifulSoup
19 +
20 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
21 +from .base import BaseConnector
22 +
23 +BASE = "https://www.espaceslokalia.ca"
24 +AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"
25 +# slug de région (filtre du site) -> ville par défaut des immeubles
26 +REGIONS = {
27 + # Région de Québec / Lévis
28 + "quebec": "Québec",
29 + "levis": "Lévis",
30 + # Laval
31 + "laval": "Laval",
32 + # Longueuil / Rive-Sud / Montérégie
33 + "longueuil": "Longueuil",
34 + "saint-hubert": "Longueuil",
35 + "brossard": "Brossard",
36 + "saint-lambert": "Saint-Lambert",
37 + "boucherville": "Boucherville",
38 + "saint-basile-le-grand": "Saint-Basile-le-Grand",
39 + "sainte-julie": "Sainte-Julie",
40 + "chambly": "Chambly",
41 + "saint-constant": "Saint-Constant",
42 + "delson": "Delson",
43 + "candiac": "Candiac",
44 + "chateauguay": "Châteauguay",
45 + "beauharnois": "Beauharnois",
46 + "salaberry-de-valleyfield": "Salaberry-de-Valleyfield",
47 + "granby": "Granby",
48 + # Rive-Nord proche
49 + "lachenaie": "Terrebonne",
50 + "terrebonne": "Terrebonne",
51 + "mascouche": "Mascouche",
52 + "saint-jerome": "Saint-Jérôme",
53 +}
54 +
55 +ADDR_RE = re.compile(
56 + r"(?<![\d\)])\b\d{1,5}\s+(?:rue|route|boulevard|bd\.?|avenue|av\.|chemin|"
57 + r"place|mont[ée]e|all[ée]e)\s[^,<>]{2,50},\s*[^,<>]{2,40},\s*"
58 + r"(?:QC|Qu[ée]bec)[^,<>]{0,12}(?:,\s*Canada)?", re.I)
59 +IMG_RE = re.compile(r"https://www\.espaceslokalia\.ca/wp-content/uploads/"
60 + r'[^"\s\\)]+\.(?:jpe?g|webp)', re.I)
61 +
62 +
63 +class LokaliaConnector(BaseConnector):
64 + source_id = "lokalia"
65 + request_delay = 0.6
66 + max_buildings = 60 # garde-fou de crawl (toutes régions)
67 +
68 + def _buildings(self) -> list[tuple[str, str]]:
69 + """Endpoint AJAX interrogé région par région
70 + -> [(url immeuble, ville par défaut), ...] dédupliqués."""
71 + out: list[tuple[str, str]] = []
72 + seen: set[str] = set()
73 + for region, city in REGIONS.items():
74 + try:
75 + time.sleep(self.request_delay) # politesse (POST direct)
76 + resp = self.session.post(
77 + AJAX_URL,
78 + data={"action": "get_immeubles_map_data",
79 + "regions": json.dumps([region])},
80 + timeout=self.timeout,
81 + )
82 + resp.raise_for_status()
83 + items = resp.json()
84 + except Exception:
85 + continue
86 + for item in items:
87 + content = (item.get("list_content") or "") + \
88 + (item.get("popup_content") or "")
89 + m = re.search(r'href="(https://www\.espaceslokalia\.ca/'
90 + r'immeuble/[^"]+)"', content)
91 + if m and m.group(1) not in seen:
92 + seen.add(m.group(1))
93 + out.append((m.group(1), city))
94 + return out
95 +
96 + def fetch(self) -> list[Listing]:
97 + listings: list[Listing] = []
98 + for url, default_city in self._buildings()[: self.max_buildings]:
99 + try:
100 + html = self.get(url).text
101 + except Exception:
102 + continue
103 + try:
104 + listings.extend(self._parse_building(url, html, default_city))
105 + except Exception:
106 + continue
107 + return listings
108 +
109 + def _parse_building(self, url: str, html: str,
110 + default_city: str = "Québec") -> list[Listing]:
111 + soup = BeautifulSoup(html, "html.parser")
112 + slug = url.rstrip("/").split("/")[-1]
113 + h1 = soup.find("h1")
114 + title = h1.get_text(" ", strip=True) if h1 else slug
115 + # « Appartements à louer à Lévis - Vivaxcès Le Nicolas » -> nom court
116 + name = title.split(" - ")[-1].split("│")[-1].strip() or slug
117 +
118 + text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
119 + m = ADDR_RE.search(text)
120 + address = m.group(0).strip() if m else ""
121 + sector = ""
122 + if address:
123 + parts = [p.strip() for p in address.split(",")]
124 + if len(parts) >= 2:
125 + sector = parts[1]
126 + city = infer_city(sector, default=default_city)
127 + if not sector and "Lévis" in title:
128 + city = "Lévis"
129 + if sector and sector.lower() == city.lower():
130 + sector = "" # éviter secteur == ville (redondant)
131 +
132 + og = soup.find("meta", attrs={"property": "og:description"})
133 + desc = og["content"].strip()[:600] if og and og.get("content") else ""
134 +
135 + # Commodités : inclusions + équipements (icônes avec attribut title)
136 + amenities = list(dict.fromkeys(
137 + el.get("title").strip() for el in soup.select("div.icon-box[title]")
138 + if el.get("title")))[:20]
139 +
140 + # Photos (exclure icônes .png des inclusions)
141 + images = [u for u in dict.fromkeys(IMG_RE.findall(html))
142 + if not re.search(r"logo|icon|favicon|-150x150", u, re.I)][:25]
143 +
144 + # Types d'unités + prix : blocs .square-box (h5 = type, p = prix)
145 + results: list[Listing] = []
146 + seen: set[str] = set()
147 + for box in soup.select("div.square-box"):
148 + h5 = box.find("h5")
149 + if not h5:
150 + continue
151 + unit_raw = h5.get_text(" ", strip=True)
152 + unit_type = normalize_unit_type(unit_raw)
153 + if not unit_type or unit_type in seen:
154 + continue
155 + seen.add(unit_type)
156 + price_label = re.sub(r"\s+", " ",
157 + box.get_text(" ", strip=True)
158 + .replace(unit_raw, "", 1)).strip()
159 + pm = re.search(r"(?:À partir de\s*)?([\d\s]+)\$\s*/?\s*mois",
160 + price_label)
161 + price_label = (f"À partir de {pm.group(1).strip()}$/mois"
162 + if pm else price_label[:60])
163 + results.append(Listing(
164 + source=self.source_id,
165 + external_id=f"{slug}-{unit_type.replace('½', '.5')}",
166 + url=url,
167 + title=f"{name}{unit_type}",
168 + address=address,
169 + sector=sector,
170 + city=city,
171 + unit_type=unit_type,
172 + price=parse_price(price_label),
173 + price_label=price_label,
174 + availability="",
175 + description=desc,
176 + amenities=amenities,
177 + images=images,
178 + ))
179 +
180 + # Immeuble sans grille de prix : annonce par immeuble quand même
181 + if not results:
182 + results.append(Listing(
183 + source=self.source_id,
184 + external_id=slug,
185 + url=url,
186 + title=name,
187 + address=address,
188 + sector=sector,
189 + city=city,
190 + description=desc,
191 + amenities=amenities,
192 + images=images,
193 + ))
194 + return results
added louka/connectors/lynk_olymbec.py +133 −0
@@ -0,0 +1,133 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/lynk_olymbec.py : connecteur Lynk par Olymbec (lynk.ca)
5 +# Lynk De la Savane — 303 unités au 5200, rue De la Savane, Montréal
6 +# (secteur Namur / De la Savane, CDN-NDG). Site RentCafe derrière un
7 +# challenge Cloudflare (403 pour les robots) : accès direct tenté avec
8 +# en-têtes réalistes, sinon repli sur Firecrawl (rendu JS). La page
9 +# /lynk-dls/units publie les prix « à partir de » par type d'unité
10 +# (Studio, 3½, 4½, 5½) — 1 annonce par type. Le projet Lynk Royale
11 +# (Trois-Rivières) et Lynk Griffintown (« bientôt disponible ») sont exclus.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +
17 +from ..schema import Listing
18 +from .base import BaseConnector
19 +
20 +BASE = "https://www.lynk.ca"
21 +UNITS_URL = f"{BASE}/lynk-dls/units"
22 +DLS_URL = f"{BASE}/lynk-dls"
23 +
24 +ADDRESS = "5200, rue De la Savane, Montréal, QC H4P 0E2"
25 +SECTOR = "Namur / De la Savane (CDN-NDG)"
26 +
27 +# « Studio à partir de 1 380 $ par mois », « 3 ½ à partir de 1 700 $ … »
28 +TYPE_PRICE_RE = re.compile(
29 + r"(Studio|\d\s*½|\d\s*1/2)\s*à partir de\s*([\d\s ,]+)\s*\$",
30 + re.I)
31 +
32 +IMG_RE = re.compile(
33 + r"https://resource\.rentcafe\.com/image/upload/[^\"'\s\\]+?\.(?:jpg|jpeg)",
34 + re.I)
35 +
36 +AMENITIES = [
37 + "Chauffage, électricité et eau chaude inclus", "Climatisation",
38 + "Internet fibre haute vitesse", "6 électroménagers Whirlpool",
39 + "Salle de fitness haut de gamme", "Piscine extérieure chauffée",
40 + "Terrasse aménagée avec coin lounge", "Concierge sur place",
41 + "Surveillance vidéo 24/7", "Détecteurs de fuite d'eau",
42 + "Casiers intelligents pour colis", "Accès sans clé (app Lynk)",
43 +]
44 +
45 +BROWSER_HEADERS = {
46 + "Accept": ("text/html,application/xhtml+xml,application/xml;q=0.9,"
47 + "image/avif,image/webp,*/*;q=0.8"),
48 + "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8",
49 + "Sec-Fetch-Dest": "document",
50 + "Sec-Fetch-Mode": "navigate",
51 + "Sec-Fetch-Site": "none",
52 + "Upgrade-Insecure-Requests": "1",
53 +}
54 +
55 +
56 +def _strip_tags(html: str) -> str:
57 + txt = re.sub(r"<script.*?</script>|<style.*?</style>", " ", html,
58 + flags=re.S | re.I)
59 + txt = re.sub(r"<[^>]+>", " ", txt)
60 + return re.sub(r"\s+", " ", txt)
61 +
62 +
63 +class LynkOlymbecConnector(BaseConnector):
64 + source_id = "lynk_olymbec"
65 + request_delay = 0.8
66 +
67 + def _get_page(self, url: str) -> str:
68 + """Essaie l'accès direct (en-têtes navigateur), sinon Firecrawl."""
69 + try:
70 + resp = self.get(url, headers=BROWSER_HEADERS)
71 + if "Just a moment" not in resp.text:
72 + return resp.text
73 + except Exception:
74 + pass
75 + return self.get_rendered(url) # Cloudflare -> rendu Firecrawl
76 +
77 + def fetch(self) -> list[Listing]:
78 + listings: list[Listing] = []
79 + try:
80 + units_html = self._get_page(UNITS_URL)
81 + except Exception:
82 + return listings
83 + if not units_html:
84 + return listings
85 + text = _strip_tags(units_html)
86 +
87 + # Photos des unités/immeuble (jpg de la galerie RentCafe)
88 + images = [u for u in dict.fromkeys(IMG_RE.findall(units_html))
89 + if not re.search(r"logo|icon|chevron|download|lockup",
90 + u, re.I)][:25]
91 +
92 + # Description : page de présentation du projet (facultative)
93 + desc = ("Lynk De la Savane (Olymbec) — 303 unités locatives "
94 + "intelligentes, studio à 5½, au 5200 De la Savane à Montréal.")
95 + try:
96 + dls_html = self._get_page(DLS_URL)
97 + m = re.search(r"<p[^>]*>([^<]{80,400})</p>", dls_html)
98 + if m:
99 + desc = re.sub(r"\s+", " ", m.group(1)).strip()[:600]
100 + except Exception:
101 + pass
102 +
103 + for m in TYPE_PRICE_RE.finditer(text):
104 + raw_type, raw_price = m.group(1), m.group(2)
105 + unit_type = ("Studio" if raw_type.lower().startswith("studio")
106 + else re.sub(r"\s*(?:½|1/2)", "½",
107 + raw_type.replace(" ", "")))
108 + num = re.sub(r"[^\d]", "", raw_price)
109 + price = None
110 + if num:
111 + val = float(num)
112 + if 100 <= val <= 20000:
113 + price = val
114 + ext = f"dls-{unit_type.replace('½', '.5').lower()}"
115 + if any(l.external_id == ext for l in listings):
116 + continue
117 + listings.append(Listing(
118 + source=self.source_id,
119 + external_id=ext,
120 + url=UNITS_URL,
121 + title=f"Lynk De la Savane — {unit_type}",
122 + address=ADDRESS,
123 + sector=SECTOR,
124 + city="Montréal",
125 + unit_type=unit_type,
126 + price=price,
127 + price_label=f"à partir de {num} $ par mois" if num else "",
128 + availability="Disponible",
129 + description=desc,
130 + amenities=AMENITIES,
131 + images=images,
132 + ))
133 + return listings
added louka/connectors/metcap.py +192 −0
@@ -0,0 +1,192 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/metcap.py : connecteur MetCap Living (metcap.com)
5 +# Site WordPress rendu serveur. La page /province/quebec liste les villes QC
6 +# (province=115) ; chaque page « province-search-results » liste les
7 +# immeubles et leurs types d'unités (« Montreal 2 Bedrooms from $1,819 »).
8 +# Les fiches /apartment/... donnent le détail (prix, lits, sdb, pi², statut)
9 +# et les fiches /property/... la galerie photo. Gestionnaire pancanadien :
10 +# seules les villes du Grand Montréal sont couvertes.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +import urllib.parse
16 +
17 +from bs4 import BeautifulSoup
18 +
19 +from ..schema import Listing, parse_price, strip_accents
20 +from .base import BaseConnector
21 +
22 +BASE = "https://www.metcap.com"
23 +QC_PROVINCE_URL = f"{BASE}/province/quebec?lang=en"
24 +
25 +# Villes admissibles (Grand Montréal), clés sans accents/minuscules
26 +_GM_CITIES = {
27 + "montreal": ("Montréal", ""),
28 + "saint laurent": ("Montréal", "Saint-Laurent"),
29 + "st laurent": ("Montréal", "Saint-Laurent"),
30 + "saint lambert": ("Saint-Lambert", ""),
31 + "st lambert": ("Saint-Lambert", ""),
32 + "verdun": ("Montréal", "Verdun"),
33 + "lasalle": ("Montréal", "LaSalle"),
34 + "laval": ("Laval", ""),
35 + "longueuil": ("Longueuil", ""),
36 + "brossard": ("Brossard", ""),
37 + "pointe-claire": ("Pointe-Claire", ""),
38 + "dorval": ("Dorval", ""),
39 +}
40 +
41 +_TYPE_MAP = [
42 + (re.compile(r"bachelor|studio", re.I), "Studio"),
43 + (re.compile(r"1\s*bed", re.I), "3½"),
44 + (re.compile(r"2\s*bed", re.I), "4½"),
45 + (re.compile(r"3\s*bed", re.I), "5½"),
46 + (re.compile(r"4\s*bed", re.I), "6½"),
47 +]
48 +_SKIP_IMG = re.compile(r"logo|icon|favicon|header|/map/|walk\.sc|sharethis",
49 + re.I)
50 +
51 +
52 +class MetcapConnector(BaseConnector):
53 + source_id = "metcap"
54 + request_delay = 0.6
55 + max_units = 60 # garde-fou fiches unités
56 + max_images = 25
57 +
58 + def fetch(self) -> list[Listing]:
59 + html = self.get(QC_PROVINCE_URL).text
60 + # Liens de villes QC : /province-search-results?...province=115&city=X
61 + cities = []
62 + for href in re.findall(r'href="(/province-search-results\?[^"]+)"',
63 + html):
64 + q = urllib.parse.parse_qs(urllib.parse.urlparse(
65 + href.replace("&amp;", "&")).query)
66 + if (q.get("province") or [""])[0] != "115":
67 + continue
68 + city = (q.get("city") or [""])[0]
69 + if city and city not in cities:
70 + cities.append(city)
71 +
72 + listings: list[Listing] = []
73 + galleries: dict[str, list[str]] = {}
74 + count = 0
75 + for city_name in cities:
76 + key = strip_accents(city_name.lower()).replace(".", "").strip()
77 + if key not in _GM_CITIES:
78 + continue # hors Grand Montréal (garde REIT pancanadien)
79 + city, sector = _GM_CITIES[key]
80 + try:
81 + page = self.get(
82 + f"{BASE}/province-search-results?lang=en&province=115"
83 + f"&city={urllib.parse.quote(city_name)}").text
84 + except Exception:
85 + continue
86 + soup = BeautifulSoup(page, "html.parser")
87 + for block in soup.select(".province-results__content"):
88 + try:
89 + h2a = block.select_one("h2 a[href^='/property/']")
90 + if not h2a:
91 + continue
92 + address = h2a.get_text(" ", strip=True)
93 + prop_path = h2a.get("href", "").split("?")[0]
94 + spans = block.select("p span.d-block")
95 + prop_name = ""
96 + if spans and not spans[0].find("a"):
97 + prop_name = spans[0].get_text(" ", strip=True)
98 + for a in block.select("a[href^='/apartment/']"):
99 + if count >= self.max_units:
100 + break
101 + count += 1
102 + text = a.get_text(" ", strip=True)
103 + lst = self._unit_listing(
104 + a.get("href", ""), text, address, prop_name,
105 + prop_path, city, sector, galleries)
106 + if lst:
107 + listings.append(lst)
108 + except Exception:
109 + continue
110 + return listings
111 +
112 + def _unit_listing(self, href: str, card_text: str, address: str,
113 + prop_name: str, prop_path: str, city: str, sector: str,
114 + galleries: dict) -> Listing | None:
115 + path = href.split("?")[0]
116 + slug = path.rstrip("/").split("/")[-1]
117 + if not slug:
118 + return None
119 + url = f"{BASE}{path}?lang=en"
120 +
121 + unit_type = ""
122 + for rx, ut in _TYPE_MAP:
123 + if rx.search(card_text):
124 + unit_type = ut
125 + break
126 + price = parse_price(
127 + card_text.replace("from $", "").replace(",", "") + " $")
128 + price_label = ""
129 + pm = re.search(r'from \$[\d,.]+', card_text)
130 + if pm:
131 + price_label = pm.group(0).replace("from", "À partir de") + " /mois"
132 +
133 + # Galerie photo depuis la fiche immeuble (partagée entre unités)
134 + if prop_path not in galleries:
135 + imgs: list[str] = []
136 + try:
137 + ph = self.get(f"{BASE}{prop_path}?lang=en").text
138 + for u in re.findall(
139 + r'https://www\.metcap\.com/wp-content/uploads/'
140 + r'[^"\'\s\)]+\.(?:jpg|jpeg|png|webp)', ph):
141 + if not _SKIP_IMG.search(u) and u not in imgs:
142 + imgs.append(u)
143 + except Exception:
144 + pass
145 + galleries[prop_path] = imgs[: self.max_images]
146 + images = galleries[prop_path]
147 +
148 + # Fiche unité : statut, lits/sdb/pi², description, intersection
149 + availability = ""
150 + desc = ""
151 + bits: list[str] = []
152 + try:
153 + uh = self.get(url).text
154 + usoup = BeautifulSoup(uh, "html.parser")
155 + txt = re.sub(r"\s+", " ", usoup.get_text(" ", strip=True))
156 + im = re.search(r"Intersection:\s*([^|]{3,60}?)\s{0,2}Suite", txt)
157 + if im and not sector:
158 + bits.append(f"Intersection : {im.group(1).strip()}")
159 + sm = re.search(
160 + r"(Available|Waiting List|Rented)\s+(\d+)\s+([\d.]+)\s+"
161 + r"([\d,]+)", txt)
162 + if sm:
163 + availability = ("Disponible" if sm.group(1) == "Available"
164 + else sm.group(1))
165 + bits.append(f"{sm.group(2)} ch. — {sm.group(3)} sdb — "
166 + f"{sm.group(4)} pi²")
167 + dm = re.search(r"Description\s+(.{40,600}?)(?:The safest way|"
168 + r"Disclaimer|$)", txt)
169 + if dm:
170 + desc = dm.group(1).strip()[:500]
171 + except Exception:
172 + pass
173 +
174 + title_type = re.sub(r"\s*from \$[\d,.].*$", "", card_text).strip()
175 + title = (f"{prop_name}{title_type}" if prop_name
176 + else f"{address}{title_type}")
177 + return Listing(
178 + source=self.source_id,
179 + external_id=slug,
180 + url=url,
181 + title=title,
182 + address=address,
183 + sector=sector,
184 + city=city,
185 + unit_type=unit_type,
186 + price=price,
187 + price_label=price_label,
188 + availability=availability,
189 + description=" — ".join([desc] + bits if desc else bits)[:600],
190 + amenities=[],
191 + images=images,
192 + )
added louka/connectors/minto.py +175 −0
@@ -0,0 +1,175 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/minto.py : connecteur Minto Apartments (mintoapartments.com)
5 +# Page « projects » Montréal rendue serveur : Rockhill, Haddon Hall, Le 4300,
6 +# Le Hill-Park. Chaque fiche propriété (main.html) liste ses types de suites
7 +# disponibles (h4 + prix + dispo + pi² + sdb) avec une galerie photo par type
8 +# (tableaux JS `lightboxImages…`). Une annonce par type de suite.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Listing, normalize_unit_type, parse_price, strip_accents
17 +from .base import BaseConnector
18 +
19 +BASE = "https://www.mintoapartments.com"
20 +LIST_URL = f"{BASE}/montreal/apartment-rentals/projects.html"
21 +
22 +PROJECT_RE = re.compile(
23 + r'https?://www\.mintoapartments\.com/montreal/apartment-rentals/'
24 + r'([A-Za-z0-9\-]+)/main\.html')
25 +LIGHTBOX_RE = re.compile(r'var\s+lightboxImages(\d+)\s*=\s*\[(.*?)\];', re.S)
26 +IMG_SRC_RE = re.compile(r"src:\s*'([^']+)'")
27 +
28 +# Secteurs connus recherchés dans le <title> ; certains sont des villes
29 +# distinctes de l'île de Montréal.
30 +_KNOWN_SECTORS = [
31 + "Côte-des-Neiges", "Westmount", "Côte-Saint-Luc", "Mont-Royal",
32 + "Notre-Dame-de-Grâce", "NDG", "Griffintown", "Plateau", "Ville-Marie",
33 + "Downtown",
34 +]
35 +_CITY_SECTORS = {"westmount": "Westmount", "mont-royal": "Mont-Royal",
36 + "cote-saint-luc": "Côte-Saint-Luc"}
37 +
38 +
39 +class MintoConnector(BaseConnector):
40 + source_id = "minto"
41 + request_delay = 0.6
42 + max_projects = 12 # garde-fou
43 + max_images = 25
44 +
45 + def fetch(self) -> list[Listing]:
46 + html = self.get(LIST_URL).text
47 + slugs = list(dict.fromkeys(PROJECT_RE.findall(html)))
48 +
49 + listings: list[Listing] = []
50 + for slug in slugs[: self.max_projects]:
51 + try:
52 + listings.extend(self._project_listings(slug))
53 + except Exception:
54 + continue
55 + return listings
56 +
57 + def _project_listings(self, slug: str) -> list[Listing]:
58 + url = f"{BASE}/montreal/apartment-rentals/{slug}/main.html"
59 + html = self.get(url).text
60 + soup = BeautifulSoup(html, "html.parser")
61 +
62 + name = slug.replace("-", " ").strip()
63 + h1 = soup.find("h1")
64 + if h1 and h1.get_text(strip=True):
65 + name = h1.get_text(" ", strip=True)
66 + t = soup.find("title")
67 + title_text = t.get_text(strip=True) if t else ""
68 +
69 + # Secteur : premier quartier connu mentionné dans le <title>
70 + sector = ""
71 + for s in _KNOWN_SECTORS:
72 + if s.lower() in title_text.lower():
73 + sector = "Centre-ville" if s == "Downtown" else s
74 + break
75 + city = _CITY_SECTORS.get(strip_accents(sector.lower()), "Montréal")
76 + if sector == city:
77 + sector = ""
78 +
79 + # Adresse civique (premier motif plausible dans la page)
80 + address = ""
81 + am = re.search(
82 + r'\d{2,5},?\s+(?:chemin|avenue|rue|boulevard|c[ôo]te)'
83 + r'[^<>"{}]{3,60}', html, re.I)
84 + if am:
85 + address = re.sub(r"\s+", " ", am.group(0)).strip().rstrip(",")
86 +
87 + # Galeries par suite : var lightboxImages<ID> = [{src: '...'}, ...]
88 + galleries = [(mm.start(), IMG_SRC_RE.findall(mm.group(2)))
89 + for mm in LIGHTBOX_RE.finditer(html)]
90 +
91 + # Photos de la propriété (carrousel d'entête)
92 + hero = [u for u in re.findall(
93 + r'https://media\.minto\.com/(?:dev/)?slideshows/[^"\'\s]+'
94 + r'\.(?:jpg|jpeg|png|webp)', html)]
95 + hero = list(dict.fromkeys(hero))[:8]
96 +
97 + # Commodités de l'immeuble (icônes « characteristics »)
98 + amenities = []
99 + for span in soup.select("span.characteristics-icon"):
100 + sib = span.find_next_sibling("span")
101 + if sib:
102 + txt = sib.get_text(" ", strip=True)
103 + if txt and txt not in amenities and len(txt) < 60:
104 + amenities.append(txt)
105 + amenities = amenities[:20]
106 +
107 + og = soup.find("meta", attrs={"name": "description"})
108 + desc = (og.get("content", "").strip()[:600] if og else "")
109 +
110 + listings: list[Listing] = []
111 + for h4 in soup.select("h4.h-h3-minto"):
112 + try:
113 + suite_name = h4.get_text(" ", strip=True)
114 + if not suite_name or len(suite_name) > 70:
115 + continue
116 + # Le conteneur de rangée du type de suite
117 + row = h4.find_parent("div", class_="row")
118 + outer = row.find_parent("div", class_="row") if row else None
119 + block = outer or row
120 + if block is None:
121 + continue
122 + block_html = str(block)
123 + # Prix « $1,215 - $1,305 »
124 + pm = re.search(r'\$[\d,]+(?:\s*-\s*\$[\d,]+)?', block_html)
125 + if not pm:
126 + continue # section non tarifée (pas une carte de suite)
127 + price_label = pm.group(0)
128 + price = parse_price(
129 + price_label.split("-")[0].replace("$", "").replace(",", "")
130 + + " $")
131 + avail_el = block.select_one(".btn-available-date")
132 + availability = (avail_el.get_text(" ", strip=True)
133 + if avail_el else "")
134 + sqm = re.search(r'([\d,]+(?:\s*-\s*[\d,]+)?)\s*</span>\s*SQ FT',
135 + block_html)
136 + sqft = sqm.group(1).strip() if sqm else ""
137 + bm = re.search(r'([\d.]+)\s*Bathroom', block_html)
138 +
139 + # Galerie : le tableau lightbox défini dans ce bloc, sinon
140 + # le plus proche avant la position du h4 dans la page
141 + imgs: list[str] = []
142 + gm = LIGHTBOX_RE.search(block_html)
143 + if gm:
144 + imgs = IMG_SRC_RE.findall(gm.group(2))
145 + else:
146 + pos = html.find(suite_name)
147 + prev = [g for g in galleries if g[0] < pos]
148 + if prev:
149 + imgs = prev[-1][1]
150 + imgs = list(dict.fromkeys(imgs))[: self.max_images] or hero
151 +
152 + suite_slug = re.sub(r"[^a-z0-9]+", "-",
153 + strip_accents(suite_name.lower())).strip("-")
154 + bits = [f"{sqft} pi²" if sqft else "",
155 + f"{bm.group(1)} sdb" if bm else ""]
156 + listings.append(Listing(
157 + source=self.source_id,
158 + external_id=f"{slug}-{suite_slug}",
159 + url=url,
160 + title=f"{name}{suite_name}",
161 + address=address,
162 + sector=sector,
163 + city=city,
164 + unit_type=normalize_unit_type(suite_name),
165 + price=price,
166 + price_label=f"{price_label} /mois",
167 + availability=availability,
168 + description=" — ".join(
169 + x for x in [desc] + bits if x)[:600],
170 + amenities=amenities,
171 + images=imgs,
172 + ))
173 + except Exception:
174 + continue
175 + return listings
added louka/connectors/mondev.py +175 −0
@@ -0,0 +1,175 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/mondev.py : connecteur Mondev (mondev.ca)
5 +# Grand constructeur-locateur montréalais (Ville-Marie, Sud-Ouest,
6 +# Griffintown, Plateau, LaSalle, etc.). Site WordPress/Elementor rendu
7 +# serveur : la page /apartments-and-condos-for-rent/ liste les immeubles
8 +# (une carte par immeuble, quartier dans l'URL), et chaque fiche immeuble
9 +# contient un « PLAN SELECTOR » avec, par typologie, « Starting at $X » ou
10 +# « not available ». Une annonce par (immeuble, typologie) disponible.
11 +# Prix « à partir de », adresse, description, commodités et galerie photos.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +
17 +from bs4 import BeautifulSoup
18 +
19 +from ..schema import Listing
20 +from .base import BaseConnector
21 +
22 +BASE = "https://mondev.ca"
23 +LIST_URL = f"{BASE}/apartments-and-condos-for-rent/"
24 +
25 +_BUILDING_RE = re.compile(
26 + r'href="(https://mondev\.ca/apartments-and-condos-for-rent/'
27 + r'([a-z0-9-]+)/([a-z0-9-]+)/)"')
28 +
29 +# Typologie (site anglophone) -> type normalisé Lou-Ka
30 +_TYPE_MAP = {
31 + "studio": "Studio",
32 + "1-bedroom": "3½",
33 + "2-bedroom": "4½",
34 + "3-bedroom": "5½",
35 + "4-bedroom": "6½",
36 + "penthouse": "Penthouse",
37 + "loft": "Loft",
38 + "townhouse": "Maison",
39 +}
40 +
41 +_PLAN_RE = re.compile(
42 + r"(Studio|\d-bedroom|Penthouse|Loft|Townhouse)\s*[-–]\s*"
43 + r"(?:Starting at\s*\$\s*([\d,]+)|not\s+available)", re.I)
44 +
45 +# Quartier (slug d'URL) -> nom d'affichage
46 +_ZONES = {
47 + "ahuntsic-cartierville": "Ahuntsic-Cartierville",
48 + "cote-des-neiges": "Côte-des-Neiges",
49 + "downtown": "Centre-ville",
50 + "griffintown": "Griffintown",
51 + "lasalle": "LaSalle",
52 + "little-burgundy": "Petite-Bourgogne",
53 + "old-montreal": "Vieux-Montréal",
54 + "park-extension": "Parc-Extension",
55 + "plateau-mont-royal": "Plateau-Mont-Royal",
56 + "quartier-des-spectacles": "Quartier des spectacles",
57 + "rosemont-la-petite-patrie": "Rosemont–La Petite-Patrie",
58 + "sud-ouest": "Sud-Ouest",
59 + "ville-marie": "Ville-Marie",
60 + "ville-saint-laurent": "Saint-Laurent",
61 + "villeray-saint-michel-parc-extension": "Villeray–Saint-Michel–Parc-Extension",
62 +}
63 +
64 +_ADDR_RE = re.compile(
65 + r"\d[\w\s.'’&,-]{3,70},\s*(?:Montr[ée]al|Ville Saint-Laurent|LaSalle|"
66 + r"Verdun)\b[^<>\"|]{0,60}", re.I)
67 +_GALLERY_RE = re.compile(
68 + r'href="(https://mondev\.ca/wp-content/uploads/[^"]+?\.(?:jpg|jpeg|png|webp))"'
69 + r'[^>]*data-elementor-lightbox-slideshow="wl_property_gallery_photos[^"]*"')
70 +
71 +
72 +class MondevConnector(BaseConnector):
73 + source_id = "mondev"
74 + request_delay = 0.6
75 + max_buildings = 45 # garde-fou de crawl
76 +
77 + def fetch(self) -> list[Listing]:
78 + listings: list[Listing] = []
79 + try:
80 + index = self.get(LIST_URL).text
81 + except Exception:
82 + return listings
83 +
84 + buildings: dict[str, tuple[str, str]] = {}
85 + for url, zone, slug in _BUILDING_RE.findall(index):
86 + buildings.setdefault(url, (zone, slug))
87 +
88 + for i, (url, (zone, slug)) in enumerate(buildings.items()):
89 + if i >= self.max_buildings:
90 + break
91 + try:
92 + listings.extend(self._parse_building(url, zone, slug))
93 + except Exception:
94 + continue
95 + return listings
96 +
97 + def _parse_building(self, url: str, zone: str, slug: str) -> list[Listing]:
98 + html = self.get(url).text
99 + soup = BeautifulSoup(html, "html.parser")
100 +
101 + h1 = soup.find("h1")
102 + name = h1.get_text(" ", strip=True) if h1 else slug.replace("-", " ")
103 + name = re.sub(r"\s*[-–]\s*(Condo|Apartment)\s+Rentals?\s*$", "", name,
104 + flags=re.I).strip()
105 +
106 + sector = _ZONES.get(zone, zone.replace("-", " ").title())
107 +
108 + # Adresse civique : premier segment de texte ressemblant à une adresse
109 + address = ""
110 + body_txt = soup.get_text("|", strip=True)
111 + for seg in body_txt.split("|"):
112 + seg = seg.strip()
113 + if len(seg) > 120:
114 + continue
115 + am = _ADDR_RE.search(seg)
116 + if am:
117 + address = re.sub(r"\s+", " ", am.group(0)).strip().rstrip(",")
118 + break
119 +
120 + # Description (meta Yoast)
121 + description = ""
122 + meta = soup.find("meta", attrs={"name": "description"})
123 + if meta and meta.get("content"):
124 + description = meta["content"].strip()[:600]
125 +
126 + # Commodités (bloc AMENITIES)
127 + amenities = [el.get_text(" ", strip=True)
128 + for el in soup.select(".wl_property_amenities li .name")]
129 + amenities = [a for a in dict.fromkeys(amenities) if a]
130 +
131 + # Galerie photos (liens lightbox)
132 + images = list(dict.fromkeys(_GALLERY_RE.findall(html)))[:40]
133 +
134 + # PLAN SELECTOR : « Studio - Starting at $1,635 » / « not available »
135 + plan_txt = ""
136 + marker = soup.find(string=re.compile(r"^\s*PLAN SELECTOR\s*$", re.I))
137 + if marker:
138 + widget = marker.find_parent(class_="elementor-widget")
139 + if widget:
140 + sib = widget.find_next_sibling(class_="elementor-widget")
141 + if sib:
142 + plan_txt = sib.get_text("\n", strip=True)
143 + if not plan_txt:
144 + plan_txt = body_txt.replace("|", "\n")
145 +
146 + results: list[Listing] = []
147 + for m in _PLAN_RE.finditer(plan_txt):
148 + raw_type, raw_price = m.group(1), m.group(2)
149 + if not raw_price:
150 + continue # typologie non disponible
151 + type_key = raw_type.lower()
152 + unit_type = _TYPE_MAP.get(type_key, raw_type)
153 + try:
154 + price = float(re.sub(r"[^\d]", "", raw_price))
155 + except ValueError:
156 + continue
157 + if not (100 <= price <= 20000):
158 + continue
159 + results.append(Listing(
160 + source=self.source_id,
161 + external_id=f"{zone}-{slug}-{type_key}",
162 + url=url,
163 + title=f"{name}{unit_type}",
164 + address=address,
165 + sector=sector,
166 + city="Montréal",
167 + unit_type=unit_type,
168 + price=price,
169 + price_label=f"À partir de {int(price)} $/mois",
170 + availability="Disponible",
171 + description=description,
172 + amenities=amenities,
173 + images=images,
174 + ))
175 + return results
added louka/connectors/msi.py +127 −0
@@ -0,0 +1,127 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/msi.py : connecteur MSI Gestion immobilière (msimmobiliers.com)
5 +# Crawl des pages de secteurs (rendu serveur) : Québec + Lévis + Montréal.
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import re
10 +
11 +from bs4 import BeautifulSoup
12 +
13 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
14 +from .base import BaseConnector
15 +
16 +BASE = "https://www.msimmobiliers.com"
17 +ROOTS = [
18 + f"{BASE}/appartements-a-louer/quebec",
19 + f"{BASE}/appartements-a-louer/levis",
20 + f"{BASE}/appartements-a-louer/montreal",
21 +]
22 +UNIT_RE = re.compile(r"/appartements-a-louer/[^\"]*appartement-(\d+)[^\"]*")
23 +LIST_RE = re.compile(r"^/appartements-a-louer/[a-z0-9\-/]+$")
24 +# ville par défaut selon la racine de l'URL (/appartements-a-louer/<ville>/...)
25 +_ROOT_CITY = {"quebec": "Québec", "levis": "Lévis", "montreal": "Montréal"}
26 +
27 +
28 +def _default_city(path_or_url: str) -> str:
29 + m = re.search(r"/appartements-a-louer/([a-z0-9\-]+)", path_or_url)
30 + return _ROOT_CITY.get(m.group(1) if m else "", "Québec")
31 +
32 +
33 +class MSIConnector(BaseConnector):
34 + source_id = "msi"
35 + request_delay = 0.5
36 + max_list_pages = 90 # garde-fou de crawl (Qc + Lévis + Mtl)
37 + max_details = 200 # garde-fou de fetch des fiches
38 +
39 + def fetch(self) -> list[Listing]:
40 + # 1) BFS sur les pages de listes (arrondissements / quartiers)
41 + to_visit = list(ROOTS)
42 + visited: set[str] = set()
43 + listings: dict[str, Listing] = {}
44 +
45 + while to_visit and len(visited) < self.max_list_pages:
46 + url = to_visit.pop(0)
47 + if url in visited:
48 + continue
49 + visited.add(url)
50 + try:
51 + html = self.get(url).text
52 + except Exception:
53 + continue
54 + soup = BeautifulSoup(html, "html.parser")
55 +
56 + # cartes d'unités
57 + for card in soup.select('a[href*="appartement-"]'):
58 + href = card.get("href", "")
59 + m = UNIT_RE.search(href)
60 + if not m:
61 + continue
62 + ext_id = m.group(1)
63 + if ext_id in listings:
64 + continue
65 + full_url = href if href.startswith("http") else BASE + href
66 + text = card.get_text("|", strip=True)
67 + parts = [p for p in text.split("|") if p and p != "Voir cette fiche"]
68 + # Format observé : "4 1/2 | 1195$ / mois | Appartement / Condo |
69 + # 177 Avenue Ruel | Chutes-Montmorency | Libre ..."
70 + unit_type = price_label = category = address = sector = avail = ""
71 + for p in parts:
72 + if not unit_type and re.match(r"^\d\s*1/2$|^Studio|^Loft", p, re.I):
73 + unit_type = p
74 + elif not price_label and "$" in p:
75 + price_label = p
76 + elif not category and re.search(r"Appartement|Condo|Maison|Commercial|Stationnement", p, re.I):
77 + category = p
78 + elif not address and re.match(r"^\d+[\s,]", p):
79 + address = p
80 + elif not avail and re.search(r"Libre|Disponib", p, re.I):
81 + avail = p
82 + elif not sector and address:
83 + sector = p
84 + # ignorer stationnements/espaces commerciaux
85 + if re.search(r"Stationnement|Commercial|Rangement|Parking", category or "", re.I):
86 + continue
87 + city = _default_city(href if "/appartements-a-louer/" in href
88 + else url)
89 + listings[ext_id] = Listing(
90 + source=self.source_id,
91 + external_id=ext_id,
92 + url=full_url,
93 + title=address or parts[0] if parts else f"Unité {ext_id}",
94 + address=address,
95 + sector=sector,
96 + city=infer_city(sector, default=city),
97 + unit_type=normalize_unit_type(unit_type),
98 + price=parse_price(price_label),
99 + price_label=price_label,
100 + availability=avail,
101 + )
102 +
103 + # sous-pages de secteurs
104 + for a in soup.select('a[href^="/appartements-a-louer/"]'):
105 + href = a.get("href", "").split("?")[0]
106 + if LIST_RE.match(href) and "appartement-" not in href:
107 + nxt = BASE + href
108 + if nxt not in visited:
109 + to_visit.append(nxt)
110 +
111 + # 2) Fiches détaillées : toutes les images
112 + for i, lst in enumerate(listings.values()):
113 + if i >= self.max_details:
114 + break
115 + try:
116 + detail = self.get(lst.url).text
117 + except Exception:
118 + continue
119 + imgs = re.findall(
120 + r'https://api\.msimmobiliers\.com/wp-content/uploads/[^"\\\s\)]+'
121 + r'\.(?:jpg|jpeg|png|webp)', detail)
122 + # retirer icônes/logos, dédupliquer en gardant l'ordre
123 + imgs = [u for u in dict.fromkeys(imgs)
124 + if not re.search(r"logo|icon|favicon", u, re.I)]
125 + lst.images = imgs[:25]
126 +
127 + return list(listings.values())
added louka/connectors/niddamour.py +198 −0
@@ -0,0 +1,198 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/niddamour.py : connecteur Nid d'Amour / Karen Cadet inc.
5 +# (niddamour.ca — Plateau, Verdun, Outremont, Rosemont, Ville-Marie,
6 +# Brossard...). Front WordPress + Angular sur la plateforme source.immo :
7 +# on lit la config publique (_configs.json) puis on interroge directement
8 +# l'API JSON api-v1.source.immo (liste + fiches avec toutes les photos).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import json
13 +import re
14 +
15 +from ..schema import Listing
16 +from .base import BaseConnector
17 +
18 +SITE = "https://niddamour.ca"
19 +# la config source.immo (config_path) est présente sur la page d'accueil ;
20 +# les pages /proprietes/ sont des routes Angular servies en HTTP 404
21 +HOME_URL = f"{SITE}/"
22 +API_ROOT = "https://api-v1.source.immo/api"
23 +
24 +# Régions administratives admissibles (Grand Montréal / CMM)
25 +ALLOWED_REGIONS = {"montreal", "laval", "monteregie", "lanaudiere", "laurentides"}
26 +
27 +_BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}
28 +
29 +
30 +def _strip_accents_lower(s: str) -> str:
31 + import unicodedata
32 + return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower())
33 + if unicodedata.category(c) != "Mn")
34 +
35 +
36 +class NiddamourConnector(BaseConnector):
37 + source_id = "niddamour"
38 + request_delay = 0.6
39 + max_details = 40 # garde-fou : fiches détaillées
40 +
41 + # -- helpers ---------------------------------------------------------------
42 + def _api(self, path: str, cfg: dict) -> dict:
43 + headers = {
44 + "x-si-account": cfg["account_id"],
45 + "x-si-api": cfg["api_key"],
46 + "x-si-appId": cfg["app_id"],
47 + "x-si-appVersion": cfg.get("app_version", ""),
48 + "Origin": SITE,
49 + "Referer": SITE + "/",
50 + }
51 + return self.get(f"{API_ROOT}/{path}", headers=headers).json()
52 +
53 + def _load_config(self) -> dict | None:
54 + """Extrait l'URL du _configs.json de source.immo depuis le site."""
55 + try:
56 + page = self.get(HOME_URL).text
57 + except Exception:
58 + return None
59 + m = re.search(r'config_path\s*:\s*"([^"]+)"', page)
60 + if not m:
61 + return None
62 + cfg_url = m.group(1).replace("\\/", "/")
63 + if cfg_url.startswith("//"):
64 + cfg_url = "https:" + cfg_url
65 + try:
66 + return self.get(cfg_url).json()
67 + except Exception:
68 + return None
69 +
70 + # -- contrat ---------------------------------------------------------------
71 + def fetch(self) -> list[Listing]:
72 + listings: list[Listing] = []
73 + cfg = self._load_config()
74 + if not cfg or not cfg.get("api_key"):
75 + return listings
76 +
77 + view = cfg.get("default_view") or ""
78 + if view.startswith("{"):
79 + try:
80 + view = json.loads(view).get("id", "")
81 + except ValueError:
82 + return listings
83 + if not view:
84 + return listings
85 +
86 + # Dictionnaires (codes ville / sous-catégorie / région -> libellés)
87 + try:
88 + meta = self._api(f"view/{view}/fr", cfg)
89 + except Exception:
90 + return listings
91 + dico = meta.get("dictionary") or {}
92 + cities = dico.get("city") or {}
93 + subcats = dico.get("listing_subcategory") or {}
94 + regions = dico.get("region") or {}
95 +
96 + try:
97 + items = (self._api(f"listing/view/{view}/fr/items", cfg)
98 + .get("items") or [])
99 + except Exception:
100 + return listings
101 +
102 + count = 0
103 + for it in items:
104 + try:
105 + ref = it.get("ref_number") or ""
106 + if not ref:
107 + continue
108 + # disponibles à louer, résidentiel seulement
109 + if it.get("status_code") != "AVAILABLE":
110 + continue
111 + if not it.get("for_rent_flag"):
112 + continue
113 + if (it.get("category_code") or "") != "RESIDENTIAL":
114 + continue
115 + subcap = ((subcats.get(it.get("subcategory_code") or "") or {})
116 + .get("caption") or "")
117 + if re.search(r"stationnement|commercial|bureau|local|terrain|"
118 + r"industriel|garage|entrep[oô]t", subcap, re.I):
119 + continue
120 +
121 + loc = it.get("location") or {}
122 + region_cap = ((regions.get(loc.get("region_code") or "") or {})
123 + .get("caption") or "")
124 + if region_cap and \
125 + _strip_accents_lower(region_cap) not in ALLOWED_REGIONS:
126 + continue # hors Grand Montréal
127 +
128 + # 'Montréal (Le Plateau-Mont-Royal)' -> ville + quartier
129 + city_cap = ((cities.get(loc.get("city_code") or "") or {})
130 + .get("caption") or "")
131 + mcity = re.match(r"^([^(]+?)\s*(?:\(([^)]+)\))?$", city_cap)
132 + city = (mcity.group(1).strip() if mcity else city_cap) or "Montréal"
133 + sector = (mcity.group(2) or "").strip() if mcity else ""
134 +
135 + price = ((it.get("price") or {}).get("rent") or {}).get("amount")
136 + price = float(price) if isinstance(price, (int, float)) else None
137 + price_label = (f"{price:,.0f}".replace(",", " ") + " $ / mois"
138 + if price else "")
139 +
140 + bedrooms = (it.get("main_unit") or {}).get("bedroom_count")
141 + if re.search(r"maison", subcap, re.I):
142 + unit_type = "Maison"
143 + elif re.search(r"studio|loft", subcap, re.I) and not bedrooms:
144 + unit_type = "Studio"
145 + else:
146 + unit_type = _BEDROOMS_TO_TYPE.get(
147 + bedrooms, f"{bedrooms} chambres" if bedrooms else "")
148 +
149 + # Fiche détaillée : photos, adresse civique, description,
150 + # inclusions (avec parcimonie — garde-fou max_details)
151 + address = description = ""
152 + images: list[str] = []
153 + amenities: list[str] = []
154 + if count < self.max_details:
155 + try:
156 + det = self._api(
157 + f"listing/view/{view}/fr/items/ref_number/{ref}", cfg)
158 + images = [ph.get("url") for ph in (det.get("photos") or [])
159 + if isinstance(ph, dict) and ph.get("url")]
160 + description = re.sub(
161 + r"\s+", " ", det.get("description") or "").strip()[:600]
162 + adr = (det.get("location") or {}).get("address") or {}
163 + parts = [adr.get("street_number"), adr.get("street_name")]
164 + address = " ".join(x for x in parts if x)
165 + if adr.get("door") and address:
166 + address += f", app. {adr['door']}"
167 + amenities = [re.sub(r"^[-–•\s]+", "", ln).strip(" ;.")
168 + for ln in (det.get("inclusions") or "")
169 + .splitlines() if ln.strip(" -–•;.")]
170 + except Exception:
171 + pass
172 + if not images and it.get("photo_url"):
173 + images = [it["photo_url"]]
174 +
175 + title = address or f"{unit_type or 'Logement'}{sector or city}"
176 + listings.append(Listing(
177 + source=self.source_id,
178 + external_id=ref,
179 + url=f"{SITE}/propriete/{ref.lower()}/",
180 + title=title,
181 + address=address,
182 + sector=sector,
183 + city=city,
184 + unit_type=unit_type,
185 + price=price,
186 + price_label=price_label,
187 + availability="Disponible",
188 + description=description,
189 + amenities=[a for a in amenities if a][:15],
190 + images=list(dict.fromkeys(images))[:25],
191 + lat=loc.get("latitude"),
192 + lng=loc.get("longitude"),
193 + ))
194 + count += 1
195 + except Exception:
196 + continue
197 +
198 + return listings
added louka/connectors/oklouer.py +136 −0
@@ -0,0 +1,136 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/oklouer.py : connecteur OK Louer (oklouer.com)
5 +# Appartements meublés en location temporaire à Québec (Haute-Ville,
6 +# Centre-Ville, Limoilou, Charlesbourg/Beauport). Pages rendues serveur.
7 +# Le secteur Lac-Beauport est exclu (hors Québec/Lévis).
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://www.oklouer.com"
19 +
20 +# secteurs conservés (lac-beauport exclu : hors Québec/Lévis)
21 +SECTORS = {
22 + "haute-ville": "Haute-Ville (Montcalm, St-Jean-Baptiste)",
23 + "centre-ville": "Centre-Ville (St-Roch)",
24 + "limoilou": "Limoilou",
25 + "charlesbourg-beauport": "Charlesbourg / Beauport",
26 +}
27 +
28 +# une fiche d'unité se termine par un code postal, ex.
29 +# /2-1-2-limoilou-1905a-de-la-bastille-quebec-qc-g1l-4b9
30 +DETAIL_RE = re.compile(r"-qc-g\d[a-z]-?\d[a-z]\d$", re.I)
31 +
32 +
33 +class OkLouerConnector(BaseConnector):
34 + source_id = "oklouer"
35 + request_delay = 0.6
36 + max_details = 80 # garde-fou
37 +
38 + def fetch(self) -> list[Listing]:
39 + # 1) Découvrir les fiches d'unité sur les pages de secteurs
40 + unit_pages: dict[str, str] = {} # path -> secteur
41 + for slug, sector in SECTORS.items():
42 + try:
43 + html = self.get(f"{BASE}/logements/{slug}").text
44 + except Exception:
45 + continue
46 + for href in set(re.findall(r'href="(/[^"]+)"', html)):
47 + if DETAIL_RE.search(href) and href not in unit_pages:
48 + unit_pages[href] = sector
49 +
50 + # 2) Fiche détaillée de chaque unité
51 + listings: list[Listing] = []
52 + for i, (path, sector) in enumerate(sorted(unit_pages.items())):
53 + if i >= self.max_details:
54 + break
55 + try:
56 + html = self.get(BASE + path).text
57 + except Exception:
58 + continue
59 + try:
60 + soup = BeautifulSoup(html, "html.parser")
61 + text = soup.get_text("\n", strip=True)
62 +
63 + # Adresse : ligne du style "1905 A de la Bastille, Québec, QC G1L 4B9"
64 + address = ""
65 + m = re.search(r"^\s*(\d[^\n]{5,70}QC\s*G\d[A-Z]\s*\d[A-Z]\d)",
66 + text, re.M | re.I)
67 + if m:
68 + address = re.sub(r"\s+", " ", m.group(1)).strip()
69 + # retirer un éventuel préfixe "2 1/2 dans Limoilou "
70 + address = re.sub(r"^\d\s*1/2\s+dans\s+[A-Za-zÀ-ÿ /-]+?"
71 + r"\s+(?=\d)", "", address)
72 +
73 + # Secteur plus précis si présent dans le slug de la fiche
74 + for key, name in (("centre-ville", SECTORS["centre-ville"]),
75 + ("haute-ville", SECTORS["haute-ville"]),
76 + ("limoilou", SECTORS["limoilou"]),
77 + ("charlesbourg", SECTORS["charlesbourg-beauport"])):
78 + if key in path:
79 + sector = name
80 + break
81 +
82 + # Prix mensuel : "À partir de 75$/jour ou 1100$/mois"
83 + price = None
84 + price_label = ""
85 + pm = re.search(r"([\d\s ]{2,})\$\s*/\s*mois", text)
86 + if pm:
87 + price = parse_price(pm.group(1).strip() + "$")
88 + lm = re.search(r"À partir de[^\n]*", text)
89 + if lm:
90 + price_label = re.sub(r"\s+", " ", lm.group(0)).strip()
91 + if "mois" not in price_label and pm:
92 + price_label += f" ou {pm.group(1).strip()}$/mois"
93 +
94 + # Type d'unité : entête de la fiche, sinon slug
95 + unit_type = ""
96 + hm = re.search(r"(\d)\s*1/2\s+dans\s", text)
97 + tm = re.match(r"^/(\d)-1-2-", path)
98 + if hm:
99 + unit_type = normalize_unit_type(f"{hm.group(1)} 1/2")
100 + elif tm:
101 + unit_type = normalize_unit_type(f"{tm.group(1)} 1/2")
102 + elif path.startswith("/studios-lofts"):
103 + unit_type = "Loft"
104 + elif path.startswith("/studio"):
105 + unit_type = "Studio"
106 + elif path.startswith("/condo"):
107 + unit_type = "Condo"
108 +
109 + # Images (galerie /uploads/m/)
110 + imgs = re.findall(r'(?:src|href)="(/uploads/m/[^"]+'
111 + r'\.(?:jpg|jpeg|png|webp))"', html, re.I)
112 + images = [BASE + u for u in dict.fromkeys(imgs)][:25]
113 +
114 + title = address or path.strip("/").replace("-", " ").title()
115 + listings.append(Listing(
116 + source=self.source_id,
117 + external_id=path.strip("/"),
118 + url=BASE + path,
119 + title=f"{unit_type + ' — ' if unit_type else ''}{title}"
120 + " (meublé, location temporaire)",
121 + address=address,
122 + sector=sector,
123 + city=infer_city(sector, default="Québec"),
124 + unit_type=unit_type,
125 + price=price,
126 + price_label=price_label,
127 + availability="",
128 + description="Appartement meublé en location temporaire "
129 + "(court, moyen ou long terme).",
130 + amenities=["Meublé"],
131 + images=images,
132 + ))
133 + except Exception:
134 + continue
135 +
136 + return listings
added louka/connectors/per.py +139 −0
@@ -0,0 +1,139 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/per.py : connecteur Les Immeubles Paul-E. Richard
5 +# (immeublesper.com — 18 immeubles à Limoilou, Charlesbourg et Beauport).
6 +# Les unités en vedette sont listées sur /logements/ ; chaque fiche
7 +# /logement/<slug>/ fournit secteur, adresse, format, prix, disponibilité,
8 +# caractéristiques et galerie de photos.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
17 +from .base import BaseConnector
18 +
19 +BASE = "https://immeublesper.com"
20 +LIST_URL = f"{BASE}/logements/"
21 +
22 +_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)
23 +_IMG_BLACKLIST = re.compile(r"ico-|logo|slide-\d|favicon", re.I)
24 +
25 +
26 +class PERConnector(BaseConnector):
27 + source_id = "per"
28 + request_delay = 0.6
29 + max_details = 60 # garde-fou de fetch des fiches
30 +
31 + def fetch(self) -> list[Listing]:
32 + html = self.get(LIST_URL).text
33 + slugs: list[str] = []
34 + for m in re.finditer(r'href="https://immeublesper\.com/logement/([^/"]+)/?"', html):
35 + if m.group(1) not in slugs:
36 + slugs.append(m.group(1))
37 +
38 + listings: list[Listing] = []
39 + for slug in slugs[:self.max_details]:
40 + try:
41 + lst = self._parse_detail(slug)
42 + if lst:
43 + listings.append(lst)
44 + except Exception:
45 + continue
46 + return listings
47 +
48 + # -- fiche /logement/<slug>/ -------------------------------------------------
49 + def _parse_detail(self, slug: str) -> Listing | None:
50 + url = f"{BASE}/logement/{slug}/"
51 + html = self.get(url).text
52 + soup = BeautifulSoup(html, "html.parser")
53 +
54 + # Informations générales : paires "ls-label"/"ls-data"
55 + info: dict[str, str] = {}
56 + for div in soup.select("div.ls-info"):
57 + label = div.select_one("span.ls-label")
58 + data = div.select_one("span.ls-data")
59 + if label and data:
60 + key = label.get_text(strip=True).rstrip(":").lower()
61 + info[key] = data.get_text(" ", strip=True)
62 +
63 + unit_raw = info.get("format", "")
64 + # exclusions : stationnement / commercial / rangement
65 + blob = " ".join([slug, unit_raw] + list(info.values()))
66 + if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t",
67 + unit_raw + " " + slug, re.I):
68 + return None
69 +
70 + sector = info.get("secteur", "")
71 + address = info.get("adresse", "")
72 + price_label = info.get("prix", "")
73 + availability = info.get("disponibilité", info.get("disponibilite", ""))
74 +
75 + # numéro d'unité : texte "#3" entre les ls-info
76 + unit_no = ""
77 + details_div = soup.select_one("div.ls-single-details")
78 + if details_div:
79 + m = re.search(r"#\s*([\w\-]+)", details_div.get_text(" ", strip=True))
80 + if m:
81 + unit_no = f"#{m.group(1)}"
82 +
83 + # caractéristiques
84 + amenities: list[str] = []
85 + for h2 in soup.find_all("h2"):
86 + if "caract" in h2.get_text(strip=True).lower():
87 + ul = h2.find_next("ul")
88 + if ul:
89 + for li in ul.find_all("li"):
90 + t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))
91 + if t and not t.startswith("N.B.") and t not in amenities:
92 + amenities.append(t)
93 + break
94 + for key, lbl in (("nombre de chambres", "chambre(s)"),
95 + ("étage", "étage"), ("etage", "étage")):
96 + if info.get(key):
97 + amenities.append(f"{info[key]} {lbl}")
98 + if info.get("animaux permis"):
99 + amenities.append(f"Animaux permis : {info['animaux permis']}")
100 +
101 + # images : galerie fancybox + image principale (pleine taille, dédupliquées)
102 + images: list[str] = []
103 + for a in soup.select("div.gallerie a[href], a.fancybox-thumb[href]"):
104 + u = a.get("href", "")
105 + if re.search(r"\.(?:jpg|jpeg|png|webp)$", u, re.I):
106 + u = _SIZE_SUFFIX.sub("", u)
107 + if u.startswith("/"):
108 + u = BASE + u
109 + if u.startswith("http") and not _IMG_BLACKLIST.search(u) and u not in images:
110 + images.append(u)
111 + main = soup.select_one("div.ls-single-image img[src]")
112 + if main:
113 + u = _SIZE_SUFFIX.sub("", main["src"])
114 + if u.startswith("http") and not _IMG_BLACKLIST.search(u) and u not in images:
115 + images.insert(0, u)
116 + if not images: # repli : toutes les images d'uploads de la page
117 + for u in re.findall(r'https://immeublesper\.com/wp-content/uploads/'
118 + r'[^"\'\s]+\.(?:jpg|jpeg|png|webp)', html, re.I):
119 + u = _SIZE_SUFFIX.sub("", u)
120 + if not _IMG_BLACKLIST.search(u) and u not in images:
121 + images.append(u)
122 +
123 + title = ", ".join(x for x in (address or slug.replace("-", " "),
124 + unit_no, unit_raw) if x)
125 + return Listing(
126 + source=self.source_id,
127 + external_id=slug,
128 + url=url,
129 + title=title,
130 + address=address,
131 + sector=sector,
132 + city=infer_city(sector, default="Québec"),
133 + unit_type=normalize_unit_type(unit_raw),
134 + price=parse_price(price_label),
135 + price_label=price_label,
136 + availability=availability,
137 + amenities=amenities,
138 + images=images[:25],
139 + )
added louka/connectors/picard.py +140 −0
@@ -0,0 +1,140 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/picard.py : connecteur Picard Immobilier (picardimmobilier.net)
5 +# La page /recherche/ liste tous les logements disponibles (rendu serveur),
6 +# cartes .property-box-2 avec lien /logement/<secteur>/ref=<id>/.
7 +# Fiches détaillées : photos S3, disponibilité, description, commodités.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://picardimmobilier.net"
19 +SEARCH_URL = f"{BASE}/recherche/"
20 +
21 +SECTOR_LABELS = {
22 + "charny": "Charny",
23 + "les-saules": "Les Saules",
24 + "sainte-foy": "Sainte-Foy",
25 + "beauport": "Beauport",
26 + "limoilou": "Limoilou",
27 + "loretteville": "Loretteville",
28 + "vieux-quebec": "Vieux-Québec",
29 + "montcalm": "Montcalm",
30 +}
31 +IMG_RE = re.compile(r"https://picardimmobilier\.s3\.amazonaws\.com/media/"
32 + r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)
33 +
34 +
35 +class PicardConnector(BaseConnector):
36 + source_id = "picard"
37 + request_delay = 0.6
38 + max_details = 80 # garde-fou de fetch des fiches
39 +
40 + def fetch(self) -> list[Listing]:
41 + listings: dict[str, Listing] = {}
42 + try:
43 + html = self.get(SEARCH_URL).text
44 + except Exception:
45 + return []
46 + soup = BeautifulSoup(html, "html.parser")
47 +
48 + for card in soup.select("div.property-box-2"):
49 + try:
50 + a = card.select_one('a[href*="ref="]')
51 + if not a:
52 + continue
53 + m = re.search(r"/logement/([a-z\-]+)/ref=(\d+)/", a.get("href", ""))
54 + if not m:
55 + continue
56 + sector_slug, ext_id = m.group(1), m.group(2)
57 + if ext_id in listings:
58 + continue
59 + sector = SECTOR_LABELS.get(sector_slug,
60 + sector_slug.replace("-", " ").title())
61 + title_el = card.select_one("h3.title a")
62 + title = title_el.get_text(" ", strip=True) if title_el else ""
63 + # Exclure commercial / stationnement / rangement
64 + if re.search(r"Commercial|Stationnement|Rangement|Parking",
65 + title, re.I):
66 + continue
67 + loc_el = card.select_one("h5.location a")
68 + address = loc_el.get_text(" ", strip=True) if loc_el else ""
69 + price_el = card.select_one(".price-box")
70 + price_label = (re.sub(r"\s+", " ", price_el.get_text(" ", strip=True))
71 + if price_el else "")
72 + # ex. "$1055.00 Par mois" -> normaliser pour parse_price
73 + pm = re.search(r"\$\s*([\d\s,\.]+)", price_label)
74 + price = None
75 + if pm:
76 + try:
77 + price = float(pm.group(1).replace(",", "").replace(" ", ""))
78 + if not (100 <= price <= 20000):
79 + price = None
80 + except ValueError:
81 + price = None
82 + unit_type = ""
83 + for li in card.select("ul.facilities-list li"):
84 + t = li.get_text(" ", strip=True)
85 + if "Chambre" in t:
86 + unit_type = t.replace("Chambres", "").strip()
87 + break
88 + img = card.select_one(".property-photo img")
89 + images = [img["src"]] if img and img.get("src") else []
90 + listings[ext_id] = Listing(
91 + source=self.source_id,
92 + external_id=ext_id,
93 + url=f"{BASE}/logement/{sector_slug}/ref={ext_id}/",
94 + title=title or (address or f"Logement {ext_id}"),
95 + address=address,
96 + sector=sector,
97 + city=infer_city(sector),
98 + unit_type=normalize_unit_type(unit_type),
99 + price=price,
100 + price_label=price_label,
101 + images=images,
102 + )
103 + except Exception:
104 + continue
105 +
106 + # Fiches détaillées : toutes les photos + disponibilité + commodités
107 + for i, lst in enumerate(listings.values()):
108 + if i >= self.max_details:
109 + break
110 + try:
111 + detail = self.get(lst.url).text
112 + except Exception:
113 + continue
114 + try:
115 + imgs = [u for u in dict.fromkeys(IMG_RE.findall(detail))
116 + if not re.search(r"logo|icon|favicon|static", u, re.I)]
117 + if imgs:
118 + lst.images = imgs[:25]
119 + dsoup = BeautifulSoup(detail, "html.parser")
120 + text = re.sub(r"\s+", " ", dsoup.get_text(" ", strip=True))
121 + # Adresse complète + disponibilité en tête de fiche
122 + am = re.search(r"(Disponible[^#]{0,40}?)\s*#?\s*Référence", text)
123 + if am:
124 + lst.availability = am.group(1).strip()
125 + adm = re.search(
126 + r"par mois\s+(.{5,90}?)\s+(?:Disponible|#\s*Référence)", text)
127 + if adm:
128 + lst.address = adm.group(1).strip()
129 + dm = re.search(r"Description\s+(.{10,600}?)\s+Caractéristiques",
130 + text)
131 + if dm:
132 + lst.description = dm.group(1).strip()
133 + lst.amenities = [
134 + re.sub(r"\s+", " ", d.get_text(" ", strip=True))
135 + for d in dsoup.select("div.col-6.text-dark")
136 + if d.get_text(strip=True)][:15]
137 + except Exception:
138 + continue
139 +
140 + return list(listings.values())
added louka/connectors/plan_a.py +171 −0
@@ -0,0 +1,171 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/plan_a.py : connecteur Plan A Immobilier (plan-a.ca)
5 +# ~3500 unités dans des complexes locatifs (Pierrefonds-Roxboro,
6 +# Pointe-aux-Trembles, Ahuntsic, Laval, Vaudreuil-Dorion — les projets
7 +# de Saguenay et Sherbrooke sont exclus). WordPress rendu serveur :
8 +# la page « appartements et condos à louer » expose des cartes-projets
9 +# a.c-card[data-listing=project] avec un JSON data-filters-item (ville,
10 +# budget min, types), le carrousel d'images et le prix « à partir de ».
11 +# Granularité = 1 annonce par complexe (prix plancher), fiches /project/
12 +# pour la description et la disponibilité.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import json
17 +import re
18 +
19 +from bs4 import BeautifulSoup
20 +
21 +from ..schema import Listing, parse_price
22 +from .base import BaseConnector
23 +
24 +BASE = "https://plan-a.ca"
25 +LIST_URL = f"{BASE}/plan-a-appartements-et-condos-a-louer/"
26 +
27 +# Grand Montréal seulement (valeurs du champ "city" des cartes)
28 +ALLOWED_CITIES = {
29 + "montreal": "Montréal",
30 + "laval": "Laval",
31 + "vaudreuil-dorion": "Vaudreuil-Dorion", # Montérégie ouest / CMM
32 + "longueuil": "Longueuil",
33 +}
34 +
35 +# Secteur (quartier) déduit du slug pour les projets montréalais
36 +_SECTOR_HINTS = [
37 + ("pierrefonds", "Pierrefonds-Roxboro"),
38 + ("roxboro", "Pierrefonds-Roxboro"),
39 + ("gouin", "Pierrefonds-Roxboro"),
40 + ("pointe-aux-trembles", "Pointe-aux-Trembles"),
41 + ("ahuntsic", "Ahuntsic"),
42 + ("cremazie", "Ahuntsic"),
43 + ("community-high-school", "Pierrefonds-Roxboro"),
44 +]
45 +
46 +
47 +class PlanAConnector(BaseConnector):
48 + source_id = "plan_a"
49 + request_delay = 0.6
50 + max_details = 20 # garde-fou de fetch des fiches projets
51 +
52 + def fetch(self) -> list[Listing]:
53 + listings: list[Listing] = []
54 + try:
55 + html = self.get(LIST_URL).text
56 + except Exception:
57 + return listings
58 + soup = BeautifulSoup(html, "html.parser")
59 +
60 + for card in soup.select('a.c-card[data-listing="project"]'):
61 + try:
62 + lst = self._parse_card(card)
63 + except Exception:
64 + continue
65 + if lst:
66 + listings.append(lst)
67 +
68 + # Fiches projets : description + disponibilité + quartier
69 + for i, lst in enumerate(listings):
70 + if i >= self.max_details:
71 + break
72 + try:
73 + detail = self.get(lst.url).text
74 + except Exception:
75 + continue
76 + dsoup = BeautifulSoup(detail, "html.parser")
77 + og = dsoup.find("meta", attrs={"property": "og:description"}) or \
78 + dsoup.find("meta", attrs={"name": "description"})
79 + if og and og.get("content"):
80 + lst.description = og["content"].strip()[:600]
81 + text = re.sub(r"\s+", " ", dsoup.get_text(" ", strip=True))
82 + m = re.search(r"Prochaine disponibilité\s+(.{3,40}?)\s+Unités", text)
83 + if m:
84 + lst.availability = m.group(1).strip()
85 + m = re.search(r"Unités? disponibles?\s+(\d+\s*/\s*\d+)", text)
86 + if m:
87 + avail = f"{lst.availability} ({m.group(1)} unités)".strip()
88 + lst.availability = avail[:120]
89 + if not lst.sector and lst.city == "Montréal":
90 + m = re.search(r"QUARTIER\s+([A-ZÀ-Ü][\w\-]+(?:[ \-][A-ZÀ-Ü][\w\-]+)*)",
91 + text)
92 + if m:
93 + lst.sector = m.group(1).strip()
94 +
95 + return listings
96 +
97 + # -- parsing d'une carte-projet ---------------------------------------------
98 + def _parse_card(self, card) -> Listing | None:
99 + href = (card.get("href") or "").split("?")[0]
100 + m = re.search(r"/project/([a-z0-9\-]+)/?$", href)
101 + if not m:
102 + return None
103 + slug = m.group(1)
104 +
105 + try:
106 + filters = json.loads(card.get("data-filters-item") or "{}")
107 + except (ValueError, TypeError):
108 + filters = {}
109 + city_key = (filters.get("city") or "").strip().lower()
110 + if city_key not in ALLOWED_CITIES:
111 + return None # Saguenay, Sherbrooke... exclus
112 + city = ALLOWED_CITIES[city_key]
113 +
114 + name_el = card.select_one("h6")
115 + name = name_el.get_text(" ", strip=True) if name_el else slug
116 + overline = card.select_one(".c-card_overline")
117 + locality = overline.get_text(" ", strip=True) if overline else ""
118 +
119 + subtitles = [s.get_text(" ", strip=True)
120 + for s in card.select(".c-card_subtitle")]
121 + address = subtitles[0] if subtitles else ""
122 + if "Complet" in subtitles or any("complet" in s.lower() for s in subtitles):
123 + return None # aucun logement disponible
124 + price_label = next((s for s in subtitles if "$" in s), "")
125 + unit_types = [s for s in subtitles
126 + if re.fullmatch(r"\d\s*½|\d\s*1/2|Studio|Loft", s)]
127 +
128 + notice = card.select_one(".c-card_notice")
129 + availability = notice.get_text(" ", strip=True) if notice else ""
130 + if availability.lower() == "maintenant":
131 + availability = "Disponible maintenant"
132 +
133 + # prix plancher : JSON budget-min sinon texte « À partir de … $ »
134 + price = None
135 + bmin = str(filters.get("budget-min") or "").strip()
136 + if bmin.isdigit():
137 + val = float(bmin)
138 + if 100 <= val <= 20000:
139 + price = val
140 + if price is None:
141 + price = parse_price(price_label)
142 +
143 + images = [img.get("src") for img in card.select("img.c-card_image")
144 + if img.get("src")]
145 + images = list(dict.fromkeys(images))[:20]
146 +
147 + sector = ""
148 + if city == "Montréal":
149 + for key, sec in _SECTOR_HINTS:
150 + if key in slug:
151 + sector = sec
152 + break
153 + elif locality and locality != city:
154 + sector = locality
155 +
156 + return Listing(
157 + source=self.source_id,
158 + external_id=slug,
159 + url=f"{BASE}/project/{slug}/",
160 + title=name,
161 + address=f"{address}, {locality}" if address else locality,
162 + sector=sector,
163 + city=city,
164 + unit_type=", ".join(dict.fromkeys(
165 + t.replace(" ", "").replace("1/2", "½") for t in unit_types)),
166 + price=price,
167 + price_label=price_label,
168 + availability=availability,
169 + amenities=[],
170 + images=images,
171 + )
added louka/connectors/progim.py +198 −0
@@ -0,0 +1,198 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/progim.py : connecteur Gestion Immobilière Progim
5 +# (progimannonces.bstk.io — plateforme Building Stack, Grand Montréal :
6 +# Montréal, Dorval, Longueuil, Sainte-Julie, Châteauguay, Charlemagne...)
7 +# La page /Listing/Listings embarque `var units = [...]` (JSON complet des
8 +# unités). Le détail d'une unité (photos, équipements, description) vient
9 +# de POST /Listing/ApartmentView avec `id=<ApartmentId>`.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import json
14 +import re
15 +
16 +from bs4 import BeautifulSoup
17 +
18 +from ..schema import Listing, normalize_unit_type, strip_accents
19 +from .base import BaseConnector
20 +
21 +BASE = "https://progimannonces.bstk.io"
22 +LIST_URL = f"{BASE}/Listing/Listings"
23 +VIEW_URL = f"{BASE}/Listing/ApartmentView"
24 +
25 +# Villes de la Communauté métropolitaine de Montréal (Grand Montréal)
26 +# desservies par Progim. Tout le reste (ex. Bromont, Saint-Césaire) est exclu.
27 +GRAND_MTL = {
28 + "montreal", "laval", "longueuil", "dorval", "sainte-julie", "charlemagne",
29 + "chateauguay", "brossard", "boucherville", "repentigny", "terrebonne",
30 + "saint-lambert", "pointe-claire", "kirkland", "beaconsfield", "lachine",
31 + "verdun", "lasalle", "mont-royal", "westmount", "cote-saint-luc",
32 + "dollard-des-ormeaux", "pierrefonds", "anjou", "saint-leonard",
33 + "montreal-nord", "montreal-est", "saint-laurent", "candiac", "la prairie",
34 + "chambly", "varennes", "sainte-catherine", "delson", "saint-constant",
35 + "mascouche", "blainville", "mirabel", "saint-eustache", "deux-montagnes",
36 + "rosemere", "boisbriand", "sainte-therese", "vaudreuil-dorion",
37 + "l'ile-perrot", "pincourt", "beauharnois", "mercier", "saint-bruno",
38 + "saint-basile-le-grand", "mcmasterville", "beloeil", "otterburn park",
39 + "mont-saint-hilaire", "carignan", "richelieu", "l'assomption",
40 + "saint-sulpice",
41 +}
42 +
43 +
44 +def _in_grand_mtl(city: str) -> bool:
45 + key = strip_accents((city or "").strip().lower())
46 + return any(key == c or key.startswith(c + "-") for c in GRAND_MTL)
47 +
48 +
49 +_CITY_CANON = {"montreal": "Montréal", "chateauguay": "Châteauguay",
50 + "levis": "Lévis", "quebec": "Québec"}
51 +
52 +
53 +def _canon_city(city: str) -> str:
54 + """Uniformise la graphie ('Montreal' -> 'Montréal')."""
55 + return _CITY_CANON.get(strip_accents(city.strip().lower()), city.strip())
56 +
57 +
58 +def _unit_type_from_bedrooms(bedrooms, unit_name: str = "") -> str:
59 + """Building Stack donne le nb de chambres ; 0 ch -> Studio, n ch -> (n+2)½."""
60 + try:
61 + n = int(bedrooms)
62 + except (TypeError, ValueError):
63 + return normalize_unit_type(unit_name)
64 + if n <= 0:
65 + return "Studio"
66 + return f"{n + 2}½"
67 +
68 +
69 +class ProgimConnector(BaseConnector):
70 + source_id = "progim"
71 + request_delay = 0.5
72 + max_details = 120 # garde-fou (une requête ApartmentView par unité)
73 +
74 + def fetch(self) -> list[Listing]:
75 + listings: list[Listing] = []
76 + try:
77 + html = self.get(LIST_URL).text
78 + except Exception:
79 + return listings
80 +
81 + m = re.search(r"var units = (\[.*?\]);", html, re.S)
82 + if not m:
83 + return listings
84 + try:
85 + units = json.loads(m.group(1))
86 + except ValueError:
87 + return listings
88 +
89 + for u in units:
90 + try:
91 + apt = u.get("Apartment") or {}
92 + addr = u.get("Address") or {}
93 + city = (addr.get("City") or u.get("City") or "").strip()
94 + if not _in_grand_mtl(city):
95 + continue # hors Grand Montréal (ex. Bromont)
96 + if not apt.get("IsResidential", True):
97 + continue # commercial / stationnement
98 + ext_id = str(apt.get("ApartmentId") or u.get("ApartmentId") or "")
99 + if not ext_id:
100 + continue
101 + price = apt.get("Price")
102 + price_label = apt.get("PriceFormatted") or ""
103 + address = addr.get("AddressLine1") or ""
104 + building = u.get("BuildingName") or address
105 + unit_name = apt.get("UnitName") or ""
106 + images = [img for img in
107 + (u.get("PreviewUrl"), u.get("BuildingPreviewUrl"))
108 + if img]
109 + building_url = u.get("BuildingUrl") or ""
110 + url = BASE + building_url if building_url.startswith("/") \
111 + else (building_url or LIST_URL)
112 + lat = lng = None
113 + try:
114 + lat = float(addr.get("Latitude"))
115 + lng = float(addr.get("Longitude"))
116 + except (TypeError, ValueError):
117 + pass
118 + listings.append(Listing(
119 + source=self.source_id,
120 + external_id=ext_id,
121 + url=url,
122 + title=f"{building} — unité {unit_name}" if unit_name
123 + else building,
124 + address=address,
125 + sector="",
126 + city=_canon_city(city),
127 + unit_type=_unit_type_from_bedrooms(
128 + apt.get("NumberOfBedrooms"), unit_name),
129 + price=float(price) if isinstance(price, (int, float))
130 + and 100 <= price <= 20000 else None,
131 + price_label=price_label,
132 + availability="",
133 + images=images,
134 + lat=lat,
135 + lng=lng,
136 + ))
137 + except Exception:
138 + continue
139 +
140 + # Détail de chaque unité : photos, équipements, description, dispo
141 + for i, lst in enumerate(listings):
142 + if i >= self.max_details:
143 + break
144 + try:
145 + self._enrich(lst)
146 + except Exception:
147 + continue
148 +
149 + return listings
150 +
151 + # -- détail (fragment HTML ApartmentView) ---------------------------------
152 + def _enrich(self, lst: Listing) -> None:
153 + import time
154 + wait = self.request_delay - (time.time() - self._last_request)
155 + if wait > 0:
156 + time.sleep(wait)
157 + resp = self.session.post(VIEW_URL, data={"id": lst.external_id},
158 + timeout=self.timeout)
159 + self._last_request = time.time()
160 + resp.raise_for_status()
161 + frag = resp.text
162 + soup = BeautifulSoup(frag, "html.parser")
163 +
164 + # Photos de l'unité
165 + imgs = re.findall(
166 + r'https://wfiles\.buildingstack\.com/resources/image/[A-Za-z0-9]+'
167 + r'(?:/[a-z]+)?', frag)
168 + lst.images = list(dict.fromkeys(lst.images + imgs))
169 +
170 + # Disponibilité : la plateforme n'affiche que des unités disponibles ;
171 + # le fragment précise parfois une date ("Disponible dès maintenant!").
172 + avail = soup.find(string=re.compile(
173 + r"^\s*Disponible (dès|le|à partir|maintenant|immédiatement)", re.I))
174 + lst.availability = avail.strip() if avail else "Disponible"
175 +
176 + # Équipements : paires libellé/Oui + liste d'électros
177 + amenities: list[str] = []
178 + for li in soup.select("li"):
179 + txt = li.get_text(" ", strip=True)
180 + m = re.match(r"^(.{3,40}?)\s+Oui$", txt)
181 + if m:
182 + amenities.append(m.group(1))
183 + for kw in ("Réfrigérateur", "Cuisinière", "Lave-vaisselle",
184 + "Laveuse", "Sécheuse", "Micro-ondes", "Climatiseur"):
185 + if re.search(re.escape(kw), frag):
186 + amenities.append(kw)
187 + lst.amenities = list(dict.fromkeys(amenities))
188 +
189 + # Description (section Commentaires)
190 + com = soup.find(string=re.compile(r"Commentaires"))
191 + if com:
192 + parent = com.find_parent()
193 + if parent:
194 + sib = parent.find_next("p") or parent.parent
195 + if sib:
196 + desc = sib.get_text(" ", strip=True)
197 + if desc and desc != "Commentaires":
198 + lst.description = desc[:600]
added louka/connectors/realstar.py +201 −0
@@ -0,0 +1,201 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/realstar.py : connecteur Realstar (realstar.ca)
5 +# Site protégé par Cloudflare (403 pour les robots) et rendu côté client
6 +# (moteur RentCafe/Yardi) : tout passe par Firecrawl avec attente de rendu.
7 +# 1) /searchlisting?province=Quebec -> cartes propriétés (nom, adresse,
8 +# lits/sdb/pi², fourchette de prix, vignette) ; 2) fiche de chaque
9 +# propriété du Grand Montréal -> galerie photos, description, points forts.
10 +# Une annonce par propriété (les prix unitaires sont derrière un portail
11 +# RentCafe). Propriétés QC hors Grand Montréal (Gatineau, Sherbrooke)
12 +# exclues.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import os
17 +import re
18 +
19 +import requests
20 +from bs4 import BeautifulSoup
21 +
22 +from ..schema import Listing, parse_price
23 +from .base import FIRECRAWL_API, BaseConnector
24 +
25 +SEARCH_URL = "https://www.realstar.ca/searchlisting?province=Quebec"
26 +
27 +# Ville du chemin /apartments/qc/<ville>/<slug> -> (ville affichée, secteur)
28 +_GM_CITIES = {
29 + "montreal": ("Montréal", ""),
30 + "cote-saint-luc": ("Côte-Saint-Luc", ""),
31 + "brossard": ("Brossard", ""),
32 + "pointe-claire": ("Pointe-Claire", ""),
33 + "boisbriand": ("Boisbriand", ""), # Rive-Nord proche
34 + "sainte-therese": ("Sainte-Thérèse", ""), # Rive-Nord proche
35 + "laval": ("Laval", ""),
36 + "longueuil": ("Longueuil", ""),
37 +}
38 +
39 +_BED_TYPES = {"0": "Studio", "1": "3½", "2": "4½", "3": "5½", "4": "6½"}
40 +_SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder", re.I)
41 +
42 +
43 +class RealstarConnector(BaseConnector):
44 + source_id = "realstar"
45 + request_delay = 1.0
46 + max_properties = 8 # garde-fou (1 appel Firecrawl par propriété)
47 + max_images = 25
48 +
49 + # -- Firecrawl avec attente de rendu (SPA + Cloudflare) -------------------
50 + def _rendered(self, url: str, wait_ms: int = 9000) -> str:
51 + key = os.environ.get("FIRECRAWL_API_KEY")
52 + if not key:
53 + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
54 + resp = requests.post(
55 + FIRECRAWL_API,
56 + json={"url": url, "formats": ["html"], "waitFor": wait_ms},
57 + headers={"Authorization": f"Bearer {key}"},
58 + timeout=150,
59 + )
60 + resp.raise_for_status()
61 + return (resp.json().get("data") or {}).get("html", "")
62 +
63 + def fetch(self) -> list[Listing]:
64 + html = self._rendered(SEARCH_URL, 10000)
65 + soup = BeautifulSoup(html, "html.parser")
66 + cards = soup.select("li.property-box")
67 + if not cards: # rendu incomplet : une seconde chance
68 + html = self._rendered(SEARCH_URL, 15000)
69 + soup = BeautifulSoup(html, "html.parser")
70 + cards = soup.select("li.property-box")
71 +
72 + listings: list[Listing] = []
73 + seen: set[str] = set()
74 + count = 0
75 + for card in cards:
76 + try:
77 + a = card.select_one("a[href*='/apartments/qc/']")
78 + if not a:
79 + continue # autre province
80 + url = (a.get("href") or "").split("?")[0]
81 + url = url.replace("http://", "https://")
82 + m = re.search(r"/apartments/qc/([a-z0-9\-.]+)/([a-z0-9\-]+)",
83 + url)
84 + if not m or url in seen:
85 + continue
86 + seen.add(url)
87 + city_slug, slug = m.group(1), m.group(2)
88 + if city_slug not in _GM_CITIES:
89 + continue # QC hors Grand Montréal (Gatineau, Sherbrooke)
90 + if count >= self.max_properties:
91 + break
92 + count += 1
93 + listings.append(
94 + self._property_listing(card, url, city_slug, slug))
95 + except Exception:
96 + continue
97 + return listings
98 +
99 + def _property_listing(self, card, url: str, city_slug: str,
100 + slug: str) -> Listing:
101 + city, sector = _GM_CITIES[city_slug]
102 + name = ""
103 + fav = card.select_one("[data-property]")
104 + if fav:
105 + name = (fav.get("data-property") or "").strip()
106 + if not name:
107 + h = card.select_one(".property-name a")
108 + if h:
109 + name = h.get_text(" ", strip=True)
110 + name = re.sub(r"\s*opens in a new tab\s*", "", name).strip()
111 + name = name or slug.replace("-", " ").title()
112 +
113 + addr_el = card.select_one(".card-prop-address")
114 + address = addr_el.get_text(" ", strip=True) if addr_el else ""
115 +
116 + meta = card.select_one(".card-bed-bath-rent")
117 + beds = baths = sqft = ""
118 + if meta:
119 + items = [li.get_text(" ", strip=True)
120 + for li in meta.select("li")]
121 + for it in items:
122 + if "Bed" in it:
123 + beds = it
124 + elif "Bath" in it:
125 + baths = it
126 + elif "Sq" in it:
127 + sqft = it
128 + unit_type = ""
129 + bm = re.match(r"^(\d)(?:\s|-)?.*Bed", beds or "")
130 + if bm and "-" not in beds.split("Bed")[0]:
131 + unit_type = _BED_TYPES.get(bm.group(1), "")
132 +
133 + # Fourchette de prix « $1,645.00 - $2,630.00 »
134 + card_text = card.get_text(" ", strip=True)
135 + price = None
136 + price_label = ""
137 + pm = re.search(r"\$[\d,]+(?:\.\d{2})?(?:(?:\s*(?:-|to))+\s*"
138 + r"\$[\d,]+(?:\.\d{2})?)?", card_text)
139 + if pm:
140 + price_label = re.sub(r"\s*to\s*-\s*", " - ", pm.group(0))
141 + first = price_label.split("-")[0].replace("$", "").replace(
142 + ",", "").replace("to", "").strip()
143 + try:
144 + price = float(first)
145 + except ValueError:
146 + price = parse_price(price_label)
147 + if "-" in price_label:
148 + price_label = "À partir de " + price_label
149 +
150 + # Vignette de la carte
151 + images: list[str] = []
152 + img = card.select_one("img[src*='rentcafe']")
153 + if img and img.get("src"):
154 + images.append(img["src"])
155 +
156 + # Fiche propriété : galerie, description, points forts
157 + desc = ""
158 + amenities: list[str] = []
159 + try:
160 + ph = self._rendered(url, 8000)
161 + psoup = BeautifulSoup(ph, "html.parser")
162 + for im in psoup.select("img[src*='resource.rentcafe.com']"):
163 + src = im.get("src", "")
164 + if src and not _SKIP_IMG.search(src) and src not in images:
165 + images.append(src)
166 + # description : premiers paragraphes substantiels
167 + paras = [p.get_text(" ", strip=True)
168 + for p in psoup.find_all("p")]
169 + paras = [p for p in paras if len(p) > 80]
170 + if paras:
171 + desc = " ".join(paras[:2])[:600]
172 + # points forts de la propriété (courtes mentions après le titre)
173 + text = psoup.get_text("\n", strip=True)
174 + hm = re.search(r"Points forts de la propri[ée]t[ée]\n(.*?)\n"
175 + r"(?:Photos|Emplacement|Votre)", text, re.S)
176 + if hm:
177 + for t in hm.group(1).split("\n"):
178 + t = t.strip()
179 + if 2 < len(t) < 50 and t not in amenities:
180 + amenities.append(t)
181 + amenities = amenities[:15]
182 + except Exception:
183 + pass
184 +
185 + bits = [b for b in [beds, baths, sqft] if b]
186 + return Listing(
187 + source=self.source_id,
188 + external_id=slug,
189 + url=url,
190 + title=name,
191 + address=address,
192 + sector=sector,
193 + city=city,
194 + unit_type=unit_type,
195 + price=price,
196 + price_label=price_label,
197 + availability="",
198 + description=" — ".join(([desc] if desc else []) + bits)[:600],
199 + amenities=amenities,
200 + images=images[: self.max_images],
201 + )
added louka/connectors/rentalys.py +234 −0
@@ -0,0 +1,234 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/rentalys.py : connecteur Rentalys (location.rentalys.ca)
5 +# Agence de location du Grand Montréal (Verdun, Villeray, Rosemont,
6 +# Saint-Michel, Longueuil...). Site WordPress (thème Houzez) rendu
7 +# serveur : archives /property/page/N/ = cartes complètes (prix, adresse,
8 +# étiquettes de disponibilité), pages /property/<slug>/ = photos + détails.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Listing, normalize_unit_type, parse_price, strip_accents
17 +from .base import BaseConnector
18 +
19 +BASE = "https://location.rentalys.ca"
20 +
21 +# Arrondissements/quartiers de l'île de Montréal (partie avant ", QC" de
22 +# l'adresse Houzez) -> ville Montréal. Les autres villes du Grand Montréal
23 +# (Longueuil, Laval, Brossard...) restent des villes à part entière.
24 +MTL_BOROUGHS = {
25 + "montreal", "verdun", "villeray", "rosemont", "la petite-patrie",
26 + "petite-patrie", "rosemont/la petite patrie", "saint-michel", "st-michel",
27 + "ville-marie", "le plateau-mont-royal", "plateau-mont-royal", "plateau",
28 + "hochelaga-maisonneuve", "mercier", "ahuntsic", "ahuntsic-cartierville",
29 + "cote-des-neiges", "notre-dame-de-grace", "ndg", "outremont", "lachine",
30 + "lasalle", "saint-laurent", "st-laurent", "anjou", "saint-leonard",
31 + "st-leonard", "montreal-nord", "riviere-des-prairies", "pointe-aux-trembles",
32 + "griffintown", "le sud-ouest", "sud-ouest", "pointe-saint-charles",
33 + "saint-henri", "st-henri", "viau", "westmount", "mont-royal",
34 +}
35 +
36 +GRAND_MTL_CITIES = {
37 + "longueuil", "laval", "brossard", "saint-lambert", "boucherville",
38 + "candiac", "chateauguay", "repentigny", "terrebonne", "dorval",
39 + "pointe-claire", "dollard-des-ormeaux", "kirkland", "beaconsfield",
40 + "sainte-julie", "varennes", "chambly", "la prairie", "saint-constant",
41 + "sainte-catherine", "delson", "blainville", "mirabel", "saint-eustache",
42 + "deux-montagnes", "rosemere", "boisbriand", "sainte-therese",
43 + "vaudreuil-dorion", "charlemagne", "mascouche", "l'assomption",
44 +}
45 +
46 +
47 +def _city_sector(address: str, title: str) -> tuple[str, str]:
48 + """Déduit (ville, secteur) de l'adresse Houzez '..., Secteur, QC, Canada'."""
49 + sector = ""
50 + parts = [p.strip() for p in (address or "").split(",")]
51 + # partie juste avant "QC"
52 + for i, p in enumerate(parts):
53 + if p.upper().startswith("QC") and i > 0:
54 + sector = parts[i - 1]
55 + break
56 + if not sector and len(parts) >= 2:
57 + sector = parts[1]
58 + key = strip_accents(sector.lower())
59 + if key in GRAND_MTL_CITIES:
60 + return sector, "" # ville de banlieue, pas de secteur
61 + if key in MTL_BOROUGHS or key.replace("st-", "saint-") in MTL_BOROUGHS:
62 + return "Montréal", "" if key == "montreal" else sector
63 + # repli : préfixe du titre ("VERDUN – ...", "LONGUEUIL maison ...")
64 + t = strip_accents((title or "").lower())
65 + for c in GRAND_MTL_CITIES:
66 + if t.startswith(c):
67 + return sector or c.title(), ""
68 + return "Montréal", sector
69 +
70 +
71 +def _unit_type(title: str, beds: str) -> str:
72 + t = normalize_unit_type(title)
73 + if re.match(r"^\d½$", t) or t in ("Studio", "Loft", "Maison", "Chambre"):
74 + return t
75 + m = re.search(r"(\d)\s*(?:1/2|½|%c2%bd)", (title or "").lower())
76 + if m:
77 + return f"{m.group(1)}½"
78 + if "maison" in (title or "").lower():
79 + return "Maison"
80 + if "studio" in (title or "").lower():
81 + return "Studio"
82 + try:
83 + n = int(str(beds).strip())
84 + return "Studio" if n == 0 else f"{n + 2}½"
85 + except (TypeError, ValueError):
86 + return ""
87 +
88 +
89 +def _parse_houzez_price(label: str) -> float | None:
90 + """'$2,177/mois' -> 2177.0 (format nord-américain, $ devant)."""
91 + m = re.search(r"\$\s*([\d][\d,\.\s]*)", label or "")
92 + if not m:
93 + return parse_price(label)
94 + try:
95 + val = float(m.group(1).replace(",", "").replace(" ", ""))
96 + except ValueError:
97 + return None
98 + return val if 100 <= val <= 20000 else None
99 +
100 +
101 +class RentalysConnector(BaseConnector):
102 + source_id = "rentalys"
103 + request_delay = 0.6
104 + max_pages = 10 # garde-fou d'archives
105 + max_details = 60 # garde-fou de fiches
106 +
107 + def fetch(self) -> list[Listing]:
108 + listings: dict[str, Listing] = {}
109 +
110 + for page in range(1, self.max_pages + 1):
111 + url = f"{BASE}/property/" if page == 1 \
112 + else f"{BASE}/property/page/{page}/"
113 + try:
114 + html = self.get(url).text
115 + except Exception:
116 + break
117 + found = self._parse_archive(html, listings)
118 + if not found:
119 + break
120 +
121 + # Fiches détaillées : toutes les photos + description
122 + for i, lst in enumerate(listings.values()):
123 + if i >= self.max_details:
124 + break
125 + try:
126 + self._enrich(lst)
127 + except Exception:
128 + continue
129 +
130 + return list(listings.values())
131 +
132 + # -- archive ------------------------------------------------------------------
133 + def _parse_archive(self, html: str, listings: dict[str, Listing]) -> int:
134 + soup = BeautifulSoup(html, "html.parser")
135 + found = 0
136 + for card in soup.select(".item-listing-wrap"):
137 + try:
138 + a = card.select_one('a[href*="/property/"]')
139 + if not a:
140 + continue
141 + href = (a.get("href") or "").split("?")[0]
142 + m = re.search(r"/property/([^/]+)/?$", href)
143 + if not m:
144 + continue
145 + slug = m.group(1)
146 + found += 1
147 + if slug in listings:
148 + continue
149 +
150 + title_el = card.select_one(".item-title")
151 + title = title_el.get_text(" ", strip=True) if title_el else slug
152 + # exclure stationnements / commercial
153 + if re.search(r"parking|stationnement|commercial|garage",
154 + title, re.I):
155 + continue
156 +
157 + addr_el = card.select_one(".item-address")
158 + address = addr_el.get_text(" ", strip=True) if addr_el else ""
159 + price_el = card.select_one(".item-price")
160 + price_label = price_el.get_text(" ", strip=True) \
161 + if price_el else ""
162 +
163 + labels = [el.get_text(" ", strip=True)
164 + for el in card.select(".hz-label, .label-status,"
165 + " .labels-wrap a")]
166 + labels = [l for l in dict.fromkeys(labels) if l]
167 + availability = next(
168 + (l for l in labels if re.search(r"disponible", l, re.I)), "")
169 + amenities = [l for l in labels
170 + if not re.search(r"disponible", l, re.I)]
171 +
172 + beds = baths = area = ""
173 + for amen in card.select(".item-amenities li"):
174 + txt = amen.get_text(" ", strip=True)
175 + if re.search(r"Lits?|Bed", txt, re.I):
176 + beds = re.sub(r"\D", "", txt)
177 + elif re.search(r"Bains?|Bath", txt, re.I):
178 + baths = re.sub(r"\D", "", txt)
179 + elif re.search(r"\d{3,}", txt):
180 + area = re.search(r"(\d[\d,\s]*)", txt).group(1)
181 + if baths:
182 + amenities.append(f"{baths} salle(s) de bain")
183 + if area:
184 + amenities.append(f"{area.strip()} pi²")
185 +
186 + city, sector = _city_sector(address, title)
187 + # repli secteur : préfixe du titre ("SAINT-MICHEL – ...")
188 + if not sector and city == "Montréal":
189 + mt = re.match(r"^([A-ZÉÈÀÂÎÔÛÇ/\-\s\.]{3,30})[–\-—]",
190 + title)
191 + if mt:
192 + sector = mt.group(1).strip().title()
193 + img = card.select_one("img")
194 + cover = (img.get("src") or img.get("data-src") or "") \
195 + if img else ""
196 +
197 + listings[slug] = Listing(
198 + source=self.source_id,
199 + external_id=slug,
200 + url=href,
201 + title=title,
202 + address=address.replace(", Canada", ""),
203 + sector=sector,
204 + city=city,
205 + unit_type=_unit_type(title, beds),
206 + price=_parse_houzez_price(price_label),
207 + price_label=price_label,
208 + availability=availability,
209 + amenities=amenities,
210 + images=[cover] if cover.startswith("http") else [],
211 + )
212 + except Exception:
213 + continue
214 + return found
215 +
216 + # -- fiche --------------------------------------------------------------------
217 + def _enrich(self, lst: Listing) -> None:
218 + html = self.get(lst.url).text
219 + imgs = re.findall(
220 + r'https://location\.rentalys\.ca/wp-content/uploads/'
221 + r'[^"\s\\\)]+\.(?:jpg|jpeg|png|webp)', html)
222 + imgs = [u for u in dict.fromkeys(imgs)
223 + if not re.search(r"logo|icon|favicon|avatar|-\d{2,3}x\d{2,3}\.",
224 + u, re.I)]
225 + if imgs:
226 + lst.images = imgs[:40]
227 + soup = BeautifulSoup(html, "html.parser")
228 + desc = soup.select_one("#property-description-wrap .block-content-wrap")
229 + if desc:
230 + lst.description = desc.get_text(" ", strip=True)[:600]
231 + else:
232 + og = soup.find("meta", attrs={"property": "og:description"})
233 + if og and og.get("content"):
234 + lst.description = og["content"].strip()[:600]
added louka/connectors/rivero.py +150 −0
@@ -0,0 +1,150 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/rivero.py : connecteur Le Rivero (lerivero.ca — Québec,
5 +# rue Bourdages, aux abords de la rivière Saint-Charles).
6 +# Le sélecteur de plans est un embed RealVuu (app.realvuu.com) dont la page
7 +# contient toutes les unités en JSON (numéro, pièces, prix, disponibilité,
8 +# plans/images). Phase 1 louée; phase 2 (livraison été 2028) en prélocation.
9 +# Garde-fou : si toutes les unités « disponibles » affichent un prix
10 +# identique (placeholder), le prix n'est pas retenu.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import json
15 +import re
16 +
17 +from ..schema import Listing, infer_city
18 +from .base import BaseConnector
19 +
20 +RV_URL = "https://app.realvuu.com/fr/external/immeubles-simard/rivero/plans"
21 +SITE_URL = "https://www.lerivero.ca/plans/"
22 +SECTOR = "Vanier (rivière Saint-Charles)"
23 +
24 +
25 +def _extract_json_array(html: str, key: str) -> list:
26 + """Extrait le plus grand tableau JSON `"key":[...]` de la page RealVuu.
27 +
28 + La clé peut apparaître plusieurs fois (souvent vide ailleurs) : on garde
29 + l'occurrence contenant le plus d'éléments.
30 + """
31 + dec = json.JSONDecoder()
32 + needle = f'"{key}":['
33 + best: list = []
34 + start = 0
35 + while True:
36 + i = html.find(needle, start)
37 + if i == -1:
38 + break
39 + try:
40 + arr, _ = dec.raw_decode(html[i + len(needle) - 1:])
41 + if isinstance(arr, list) and len(arr) > len(best):
42 + best = arr
43 + except Exception:
44 + pass
45 + start = i + len(needle)
46 + return best
47 +
48 +
49 +def _unit_type_from_rooms(rooms: float) -> str:
50 + if not rooms:
51 + return ""
52 + if rooms <= 1:
53 + return "Studio"
54 + return f"{int(rooms)}½"
55 +
56 +
57 +class RiveroConnector(BaseConnector):
58 + source_id = "rivero"
59 + request_delay = 0.6
60 +
61 + def fetch(self) -> list[Listing]:
62 + html = self.get(RV_URL).text
63 +
64 + units = _extract_json_array(html, "units")
65 + buildings = {b.get("buildingId"): b
66 + for b in _extract_json_array(html, "buildings")}
67 +
68 + avail = [u for u in units
69 + if u.get("availability") == "AVAILABLE"
70 + and u.get("rental")
71 + and u.get("typeType") == "APARTMENT"
72 + and u.get("segment") in ("RESIDENTIAL", "", None)
73 + and u.get("isMarketable", True)]
74 + if not avail:
75 + return []
76 +
77 + # Garde-fou prix placeholder : prix unique pour des types différents
78 + prices = {u.get("rentalPrice") for u in avail}
79 + rooms_set = {u.get("rooms") for u in avail}
80 + placeholder = len(prices) == 1 and len(rooms_set) > 1
81 +
82 + listings: list[Listing] = []
83 + for u in avail:
84 + try:
85 + b = buildings.get(u.get("buildingId"), {})
86 + bname = (b.get("name") or "").strip()
87 + address = (u.get("address") or b.get("address") or "").strip()
88 + number = str(u.get("number") or "").strip()
89 + unit_type = _unit_type_from_rooms(u.get("rooms") or 0)
90 +
91 + price = None
92 + price_label = ""
93 + rp = u.get("rentalPrice") or 0
94 + if rp and not placeholder and 100 <= rp <= 20000:
95 + price = float(rp)
96 + price_label = f"{rp} $/mois"
97 +
98 + availability = "Disponible"
99 + if u.get("futureAvailability"):
100 + availability = f"Disponible : {u['futureAvailability']}"
101 + if "phase 2" in bname.lower():
102 + availability = "Phase 2 — prélocation (livraison été 2028)"
103 +
104 + images: list[str] = []
105 + for ti in (u.get("typeImages") or []):
106 + if ti.get("fullUrl"):
107 + images.append(ti["fullUrl"])
108 + for im in (u.get("images") or []):
109 + if isinstance(im, dict) and im.get("fullUrl"):
110 + images.append(im["fullUrl"])
111 + if u.get("floorPlanImageUrl"):
112 + images.append(u["floorPlanImageUrl"])
113 + images = list(dict.fromkeys(images))
114 +
115 + desc = []
116 + if u.get("unitSize"):
117 + desc.append(f"{u['unitSize']} pi²")
118 + if u.get("roomsBed"):
119 + desc.append(f"{u['roomsBed']} chambre(s)")
120 + if u.get("roomsBath"):
121 + desc.append(f"{u['roomsBath']} salle(s) de bain")
122 + if u.get("balconySize"):
123 + desc.append(f"Balcon/loggia {u['balconySize']} pi²")
124 + if u.get("orientation"):
125 + desc.append(f"Orientation {u['orientation']}")
126 + if bname:
127 + desc.append(bname)
128 +
129 + listings.append(Listing(
130 + source=self.source_id,
131 + external_id=u.get("unitId") or f"{bname}-{number}",
132 + url=SITE_URL,
133 + title=f"Le Rivero — Unité {number}"
134 + f"{' (' + unit_type + ')' if unit_type else ''}",
135 + address=address,
136 + sector=SECTOR,
137 + city=infer_city(SECTOR, default="Québec"),
138 + unit_type=unit_type,
139 + price=price,
140 + price_label=price_label,
141 + availability=availability,
142 + description=" | ".join(desc),
143 + amenities=["Eau chaude incluse", "Électricité et chauffage",
144 + "Air climatisé", "Internet inclus"],
145 + images=images,
146 + ))
147 + except Exception:
148 + continue
149 +
150 + return listings
added louka/connectors/roussin.py +143 −0
@@ -0,0 +1,143 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/roussin.py : connecteur Immeubles Roussin (immeublesroussin.com)
5 +# Immeubles/projets résidentiels (L'Aromate, L'Allié, La Vigie, etc.) —
6 +# une annonce par type d'unité et par immeuble, prix « à partir de ».
7 +# Les pages commerciales (locaux, bureaux, entrepôts) sont exclues.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://immeublesroussin.com"
19 +INDEX = f"{BASE}/location-condo-appartement-quebec/"
20 +
21 +# Lignes de prix : « Loft à partir de 1200$ », « 3 ½ à partir de 1595$ »...
22 +PRICE_LINE_RE = re.compile(
23 + r"((?:\d\s*(?:½|1/2))|Loft|Studio|Maison)[^<>$]{0,30}?"
24 + r"à\s+partir\s+de\s*([\d][\d\s ,]*)\s*\$",
25 + re.I,
26 +)
27 +IMG_RE = re.compile(
28 + r"https://immeublesroussin\.com/wp-content/uploads/[^\"'\\\s\)]+"
29 + r"\.(?:jpg|jpeg|png|webp)", re.I)
30 +IMG_NOISE_RE = re.compile(r"favicon|logo|icon|cropped|header", re.I)
31 +ADDR_RE = re.compile(
32 + r"\d{2,5}[,]?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|place)"
33 + r"\s[^<>{}\"]{3,60}", re.I)
34 +BUILDING_RE = re.compile(r"/location-condo-appartement-quebec/([a-z0-9\-]+)/?$")
35 +
36 +
37 +class RoussinConnector(BaseConnector):
38 + source_id = "roussin"
39 + request_delay = 0.6
40 + max_buildings = 30 # garde-fou
41 +
42 + def fetch(self) -> list[Listing]:
43 + # 1) Découvrir les pages d'immeubles résidentiels
44 + html = self.get(INDEX).text
45 + slugs: list[str] = []
46 + for href in re.findall(r'href="([^"]+)"', html):
47 + m = BUILDING_RE.search(href.split("#")[0].split("?")[0])
48 + if m and m.group(1) not in slugs:
49 + slugs.append(m.group(1))
50 +
51 + listings: list[Listing] = []
52 + for slug in slugs[: self.max_buildings]:
53 + url = f"{INDEX}{slug}/"
54 + try:
55 + page = self.get(url).text
56 + except Exception:
57 + continue
58 + try:
59 + listings.extend(self._parse_building(slug, url, page))
60 + except Exception:
61 + continue
62 + return listings
63 +
64 + def _parse_building(self, slug: str, url: str, page: str) -> list[Listing]:
65 + soup = BeautifulSoup(page, "html.parser")
66 +
67 + # Nom + secteur depuis le <title> :
68 + # « L'Aromate - Location de condos à Ste-Foy | Immeubles Roussin »
69 + title_tag = soup.find("title")
70 + raw_title = title_tag.get_text(strip=True) if title_tag else slug
71 + name = re.split(r"\s+[-–|]\s+", raw_title)[0].strip() or slug
72 + if re.search(r"louer|condos? neufs?", name, re.I):
73 + # Titre générique (« Condos à louer à Lévis ») : essayer la partie
74 + # après « : », sinon un nom dérivé du slug.
75 + m = re.search(r":\s*([^|]+)", raw_title)
76 + name = (m.group(1).strip() if m
77 + else slug.replace("-", " ").strip().title())
78 +
79 + # Description (og:description)
80 + desc = ""
81 + og = soup.find("meta", attrs={"property": "og:description"})
82 + if og and og.get("content"):
83 + desc = og["content"].strip()[:600]
84 +
85 + # Adresse civique (en excluant le bureau administratif de Roussin)
86 + address = ""
87 + for cand in ADDR_RE.findall(page):
88 + if re.search(r"bureau", cand, re.I):
89 + continue
90 + address = re.sub(r"\s+", " ", cand).strip().rstrip(",")
91 + break
92 +
93 + # Secteur : mots-clés dans le titre puis dans l'adresse
94 + sector = ""
95 + m = re.search(r"(Sainte-Foy|Ste-Foy|L[ée]vis|Beauport|Sillery|"
96 + r"Cap-Rouge|St-Augustin)", f"{raw_title} {address}", re.I)
97 + if m:
98 + sector = m.group(1)
99 +
100 + # Images (galerie de l'immeuble)
101 + images = [u for u in dict.fromkeys(IMG_RE.findall(page))
102 + if not IMG_NOISE_RE.search(u)][:25]
103 +
104 + # Types d'unités avec prix « à partir de »
105 + text = soup.get_text(" ", strip=True)
106 + seen: set[str] = set()
107 + out: list[Listing] = []
108 + for type_raw, amount in PRICE_LINE_RE.findall(text):
109 + unit_type = normalize_unit_type(type_raw)
110 + if not unit_type or unit_type in seen:
111 + continue
112 + seen.add(unit_type)
113 + price_label = f"{type_raw.strip()} à partir de {amount.strip()}$"
114 + key = re.sub(r"[^a-z0-9]+", "-", unit_type.lower().replace("½", "5"))
115 + out.append(Listing(
116 + source=self.source_id,
117 + external_id=f"{slug}--{key}",
118 + url=url,
119 + title=f"{name}{unit_type}",
120 + address=address,
121 + sector=sector,
122 + city=infer_city(sector),
123 + unit_type=unit_type,
124 + price=parse_price(f"{amount}$"),
125 + price_label=price_label,
126 + description=desc,
127 + images=images,
128 + ))
129 +
130 + # Aucun prix affiché : une annonce par immeuble quand même
131 + if not out:
132 + out.append(Listing(
133 + source=self.source_id,
134 + external_id=slug,
135 + url=url,
136 + title=name,
137 + address=address,
138 + sector=sector,
139 + city=infer_city(sector),
140 + description=desc,
141 + images=images,
142 + ))
143 + return out
added louka/connectors/sdg.py +143 −0
@@ -0,0 +1,143 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sdg.py : connecteur SDG Immobilier (sdgimmobilier.ca)
5 +# Site WordPress (thème Houzez) : /appartements-a-louer/ liste des
6 +# immeubles (fiches « immeuble »). Une annonce par immeuble (types
7 +# d'unités affichés, pas de prix publiés). Galerie via data-images.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import json
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Listing, infer_city, normalize_unit_type
17 +from .base import BaseConnector
18 +
19 +BASE = "https://www.sdgimmobilier.ca"
20 +LIST_URL = f"{BASE}/appartements-a-louer/"
21 +
22 +IMG_RE = re.compile(
23 + r"https://www\.sdgimmobilier\.ca/wp-content/uploads/[^\"'\\\s\)]+"
24 + r"\.(?:jpg|jpeg|png|webp)", re.I)
25 +IMG_NOISE_RE = re.compile(r"logo|favicon|icon|-\d+x\d+\.", re.I)
26 +EXCLUDE_RE = re.compile(r"stationnement|commercial|rangement|entrepos|bureau",
27 + re.I)
28 +
29 +
30 +class SDGConnector(BaseConnector):
31 + source_id = "sdg"
32 + request_delay = 0.6
33 + max_details = 40 # garde-fou
34 +
35 + def fetch(self) -> list[Listing]:
36 + html = self.get(LIST_URL).text
37 + soup = BeautifulSoup(html, "html.parser")
38 +
39 + listings: dict[str, Listing] = {}
40 + for card in soup.select("div.item-listing-wrap[data-hz-id]"):
41 + try:
42 + lst = self._parse_card(card)
43 + except Exception:
44 + continue
45 + if lst and lst.external_id not in listings:
46 + listings[lst.external_id] = lst
47 +
48 + # Fiches immeuble : description, types d'unités, commodités, adresse
49 + for i, lst in enumerate(listings.values()):
50 + if i >= self.max_details:
51 + break
52 + try:
53 + self._enrich(lst)
54 + except Exception:
55 + continue
56 +
57 + return list(listings.values())
58 +
59 + def _parse_card(self, card) -> Listing | None:
60 + ext_id = card.get("data-hz-id", "").strip()
61 + title_a = card.select_one(".item-title a")
62 + if not ext_id or not title_a:
63 + return None
64 + url = title_a.get("href", "")
65 + if "/immeuble/" not in url:
66 + return None
67 + title = title_a.get_text(" ", strip=True)
68 + if EXCLUDE_RE.search(f"{title} {url}"):
69 + return None
70 +
71 + addr_el = card.select_one(".item-address span") or \
72 + card.select_one(".item-address")
73 + sector = addr_el.get_text(" ", strip=True) if addr_el else ""
74 +
75 + # Galerie complète fournie dans l'attribut data-images (JSON)
76 + images: list[str] = []
77 + raw = card.get("data-images", "")
78 + if raw:
79 + try:
80 + images = [d.get("image", "") for d in json.loads(raw)
81 + if d.get("image")]
82 + except (ValueError, TypeError):
83 + images = []
84 + if not images:
85 + img_el = card.select_one(".listing-thumb img")
86 + if img_el and (img_el.get("src") or "").startswith("http"):
87 + images = [img_el["src"]]
88 +
89 + return Listing(
90 + source=self.source_id,
91 + external_id=ext_id,
92 + url=url,
93 + title=title,
94 + address=title if re.match(r"^\d", title) else "",
95 + sector=sector,
96 + city=infer_city(sector),
97 + images=list(dict.fromkeys(images))[:25],
98 + )
99 +
100 + def _enrich(self, lst: Listing) -> None:
101 + html = self.get(lst.url).text
102 + soup = BeautifulSoup(html, "html.parser")
103 +
104 + # Description
105 + desc_el = soup.select_one(".property-description-content") or \
106 + soup.select_one("#property-description-wrap")
107 + if desc_el:
108 + lst.description = desc_el.get_text(" ", strip=True)[:600]
109 +
110 + # Caractéristiques : « Type d'unités: 3 ½ » + commodités
111 + feats = [a.get_text(" ", strip=True)
112 + for a in soup.select("#property-features-wrap li a")]
113 + amenities = []
114 + for f in feats:
115 + m = re.search(r"types? d.unités?\s*:?\s*(.+)", f, re.I)
116 + if m and not lst.unit_type:
117 + # « 2 ½, 3 ½, 4 ½ et 5 ½ » -> plus petit type (standard Lou-Ka)
118 + lst.unit_type = normalize_unit_type(m.group(1))
119 + if f and not m:
120 + amenities.append(f)
121 + elif m and "," in m.group(1):
122 + amenities.append(f) # garder le détail multi-types
123 + if amenities:
124 + lst.amenities = amenities[:20]
125 +
126 + # Adresse / arrondissement (bloc « Adresse » Houzez)
127 + for row in soup.select("#property-address-wrap .list-lined-item"):
128 + label = row.find("strong")
129 + value = row.find("span")
130 + if not label or not value:
131 + continue
132 + key = label.get_text(strip=True).lower()
133 + val = value.get_text(" ", strip=True)
134 + if key.startswith("adresse") and val:
135 + lst.address = val
136 + elif key.startswith("arrondissement") and val and not lst.sector:
137 + lst.sector = val
138 + lst.city = infer_city(val)
139 +
140 + # Compléter la galerie avec les photos de la fiche
141 + imgs = [u for u in dict.fromkeys(IMG_RE.findall(html))
142 + if not IMG_NOISE_RE.search(u)]
143 + lst.images = list(dict.fromkeys(lst.images + imgs))[:25]
added louka/connectors/sentinelle.py +115 −0
@@ -0,0 +1,115 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sentinelle.py : connecteur La Sentinelle (lasentinellelevis.com)
5 +# Immeuble de 144 condos locatifs au 7002, boul. Guillaume-Couture
6 +# (Vieux-Lévis). La page /projet/ liste les unités disponibles avec liens
7 +# vers les fiches /projet/etage-N/unite-NNN/ (prix, superficie, plan).
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://lasentinellelevis.com"
19 +LIST_URL = f"{BASE}/projet/"
20 +SECTOR = "Vieux-Lévis"
21 +ADDRESS = "7002, boul. Guillaume-Couture, Lévis"
22 +
23 +_SKIP_IMG = re.compile(r"logo|favicon|icon|brochu|promenade", re.I)
24 +
25 +
26 +class SentinelleConnector(BaseConnector):
27 + source_id = "sentinelle"
28 + request_delay = 0.6
29 + max_details = 60 # garde-fou
30 +
31 + def fetch(self) -> list[Listing]:
32 + html = self.get(LIST_URL).text
33 +
34 + unit_urls = sorted(set(re.findall(
35 + rf'href="({re.escape(BASE)}/projet/etage-\d+/unite-\d+/)"', html)))
36 +
37 + listings: list[Listing] = []
38 + for url in unit_urls[:self.max_details]:
39 + try:
40 + dhtml = self.get(url).text
41 + except Exception:
42 + continue
43 + try:
44 + soup = BeautifulSoup(dhtml, "html.parser")
45 + text = soup.get_text("\n", strip=True)
46 +
47 + m = re.search(r"Unité\s+(\d+)\s*-\s*(\d\s*(?:½|1/2)\s*\+?)",
48 + text)
49 + unit_no = m.group(1) if m else url.rstrip("/").split("-")[-1]
50 + raw_type = m.group(2).strip() if m else ""
51 + unit_type = normalize_unit_type(raw_type)
52 +
53 + floor_m = re.search(r"Étage\s+(\d+)", text)
54 + status_m = re.search(r"\n(Disponible|Loué|Réservé)\n", text)
55 + status = status_m.group(1) if status_m else "Disponible"
56 + if status != "Disponible":
57 + continue
58 +
59 + avail = "Disponible"
60 + am = re.search(r"Disponible à partir de\s*:\s*([^\n]+)", text)
61 + if am:
62 + avail = f"Disponible à partir de : {am.group(1).strip()}"
63 +
64 + price = None
65 + price_label = ""
66 + pm = re.search(r"^([\d\s ]{3,})\$\s*$", text, re.M)
67 + if pm:
68 + price_label = pm.group(0).strip()
69 + price = parse_price(price_label)
70 +
71 + sqft_m = re.search(r"Superficie\s+([\d\s]+)pi", text)
72 + desc_parts = []
73 + if floor_m:
74 + desc_parts.append(f"Étage {floor_m.group(1)}")
75 + if sqft_m:
76 + desc_parts.append(f"Superficie {sqft_m.group(1).strip()} pi²")
77 + if "+" in raw_type or "espace bureau" in text.lower():
78 + desc_parts.append("Avec espace bureau")
79 +
80 + # commodités listées sur la fiche
81 + amenities = []
82 + for cand in ("Stationnement intérieur inclus", "Ascenceur",
83 + "Ascenseur", "Eau chaude fournie", "Air climatisé",
84 + "Îlot central dans la cuisine",
85 + "Espace de rangement supplémentaire inclus"):
86 + if cand.lower() in text.lower():
87 + amenities.append(cand)
88 +
89 + imgs = re.findall(
90 + rf'(?:src|href)="({re.escape(BASE)}/wp-content/uploads/'
91 + rf'[^"]+\.(?:jpg|jpeg|png|webp))"', dhtml, re.I)
92 + images = [u for u in dict.fromkeys(imgs)
93 + if not _SKIP_IMG.search(u)][:10]
94 +
95 + listings.append(Listing(
96 + source=self.source_id,
97 + external_id=f"unite-{unit_no}",
98 + url=url,
99 + title=f"La Sentinelle — Unité {unit_no}"
100 + f"{' (' + unit_type + ')' if unit_type else ''}",
101 + address=ADDRESS,
102 + sector=SECTOR,
103 + city=infer_city(SECTOR, default="Lévis"),
104 + unit_type=unit_type,
105 + price=price,
106 + price_label=price_label,
107 + availability=avail,
108 + description=" | ".join(desc_parts),
109 + amenities=amenities,
110 + images=images,
111 + ))
112 + except Exception:
113 + continue
114 +
115 + return listings
added louka/connectors/sgiq.py +181 −0
@@ -0,0 +1,181 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sgiq.py : connecteur SGIQ — Société de Gestion Immobilière du
5 +# Québec (gestionimmobilierequebec.com). Liste paginée /immeubles?page=N
6 +# (rendu serveur) + fiches /fiche/<id> pour images, description, commodités.
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import re
11 +
12 +from bs4 import BeautifulSoup
13 +
14 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price, strip_accents
15 +from .base import BaseConnector
16 +
17 +BASE = "https://gestionimmobilierequebec.com"
18 +LIST_URL = f"{BASE}/immeubles"
19 +
20 +# Secteurs connus de l'agglomération de Québec (localité affichée après la
21 +# virgule dans le titre) — tout le reste (hors Québec/Lévis) est exclu.
22 +_QC_SECTORS = {
23 + "quebec", "ville de quebec", "sainte-foy", "ste-foy", "sillery", "limoilou",
24 + "beauport", "charlesbourg", "vanier", "loretteville", "val-belair",
25 + "l'ancienne-lorette", "ancienne-lorette", "saint-augustin",
26 + "saint-augustin-de-desmaures", "cap-rouge", "saint-roch", "st-roch",
27 + "saint-sauveur", "st-sauveur", "montcalm", "duberger", "les saules",
28 + "neufchatel", "lebourgneuf", "lac-saint-charles", "saint-emile", "st-emile",
29 + "wendake", "cite-limoilou", "la cite-limoilou",
30 +}
31 +
32 +_IMG_RE = re.compile(
33 + r"//gestionimmobilierequebec\.com/mod/file/ImmeubleSliderFile/"
34 + r"[0-9a-f]+\.(?:jpg|jpeg|png|webp)", re.I)
35 +
36 +
37 +class SGIQConnector(BaseConnector):
38 + source_id = "sgiq"
39 + request_delay = 0.5
40 + max_pages = 15 # garde-fou de pagination
41 + max_details = 250 # garde-fou de fetch des fiches
42 +
43 + # -- helpers ---------------------------------------------------------------
44 + @staticmethod
45 + def _city_from_locality(locality: str) -> str | None:
46 + """Ville normalisée, ou None si hors agglomération Québec/Lévis."""
47 + key = strip_accents(locality.strip().lower())
48 + if not key:
49 + return "Québec"
50 + if infer_city(locality, default="") == "Lévis":
51 + return "Lévis"
52 + if key in _QC_SECTORS:
53 + return "Québec"
54 + return None
55 +
56 + # -- fetch -----------------------------------------------------------------
57 + def fetch(self) -> list[Listing]:
58 + listings: dict[str, Listing] = {}
59 +
60 + # 1) Pagination de la liste (?page=N ; total dans input#total_page)
61 + total_pages = 1
62 + page = 1
63 + while page <= total_pages and page <= self.max_pages:
64 + try:
65 + html = self.get(LIST_URL if page == 1 else f"{LIST_URL}?page={page}").text
66 + except Exception:
67 + break
68 + soup = BeautifulSoup(html, "html.parser")
69 + tp = soup.select_one("input#total_page")
70 + if tp and (tp.get("value") or "").isdigit():
71 + total_pages = int(tp["value"])
72 +
73 + for card in soup.select("div.preview-immeuble a[href*='/fiche/']"):
74 + m = re.search(r"/fiche/(\d+)", card.get("href", ""))
75 + if not m:
76 + continue
77 + ext_id = m.group(1)
78 + if ext_id in listings:
79 + continue
80 + title = (card.get("title") or "").strip()
81 + if not title:
82 + img = card.select_one("img[alt]")
83 + title = (img.get("alt") or "").strip() if img else ""
84 + # Formats observés : "925-1A rue Liénard, Québec",
85 + # "920-1F Av. Myrand, Québec, QC G1V 2V9", "830 avenue Turnbull"
86 + tokens = [t.strip() for t in title.split(",") if t.strip()]
87 + locality = ""
88 + for tok in tokens[1:]:
89 + tok_clean = re.sub(r"\bQC\b|\bG\d[A-Z]\s?\d[A-Z]\d\b", "", tok).strip()
90 + if tok_clean:
91 + locality = tok_clean
92 + break
93 + address = tokens[0] if tokens else title
94 + city = self._city_from_locality(locality)
95 + if city is None: # hors Québec / Lévis
96 + continue
97 + cat_el = card.select_one("div.text p")
98 + category = cat_el.get_text(strip=True) if cat_el else ""
99 + if re.search(r"stationnement|commercial|rangement|garage",
100 + category, re.I):
101 + continue
102 + price_el = card.select_one(".background-price strong")
103 + price_label = f"{price_el.get_text(strip=True)} $ /Mois" if price_el else ""
104 + sector = locality if strip_accents(locality.lower()) not in ("quebec",) else ""
105 + listings[ext_id] = Listing(
106 + source=self.source_id,
107 + external_id=ext_id,
108 + url=f"{BASE}/fiche/{ext_id}",
109 + title=title or f"Logement {ext_id}",
110 + address=address,
111 + sector=sector,
112 + city=city,
113 + price=parse_price(price_label),
114 + price_label=price_label,
115 + description=category,
116 + )
117 + page += 1
118 +
119 + # 2) Fiches détaillées : images, description, type, commodités
120 + for i, lst in enumerate(listings.values()):
121 + if i >= self.max_details:
122 + break
123 + try:
124 + detail = self.get(lst.url).text
125 + except Exception:
126 + continue
127 + lst.images = ["https:" + u for u in dict.fromkeys(_IMG_RE.findall(detail))][:30]
128 +
129 + dsoup = BeautifulSoup(detail, "html.parser")
130 + # description (bloc "Description et remarques")
131 + desc_el = dsoup.select_one("div.text.description-content, div.description")
132 + if not desc_el:
133 + anchor = dsoup.find(string=re.compile("Description et remarques"))
134 + if anchor:
135 + desc_el = anchor.find_parent("div")
136 + if desc_el:
137 + desc_el = desc_el.find_next_sibling("div") or desc_el.parent
138 + desc = ""
139 + if desc_el:
140 + desc = re.sub(r"\s+", " ", desc_el.get_text(" ", strip=True))
141 + if not desc: # repli : tout le corps de la fiche
142 + body = dsoup.get_text(" ", strip=True)
143 + m = re.search(r"Description et remarques\s*(.+?)(?:Vous pourriez aussi aimer|Siège social)",
144 + body)
145 + desc = re.sub(r"\s+", " ", m.group(1)) if m else ""
146 + lst.description = desc[:800]
147 +
148 + # type d'unité depuis la description ("3 1/2 LUMINEUX ...")
149 + unit = normalize_unit_type(desc)
150 + if not unit or unit == desc.strip():
151 + unit = ""
152 + lst.unit_type = unit
153 +
154 + # commodités : "Chauffé : Non", "1 chambre", "Chiens permis", ...
155 + amenities: list[str] = []
156 + for el in dsoup.select("div.description-item div, div.block-right div.icon div"):
157 + t = el.get_text(" ", strip=True)
158 + if t and len(t) < 60 and t not in amenities:
159 + amenities.append(t)
160 + lst.amenities = amenities
161 +
162 + # secteur depuis la description ("QUARTIER SAINTE-FOY", "SECTEUR LIMOILOU")
163 + if not lst.sector:
164 + m = re.search(r"(?:QUARTIER|SECTEUR)\s+(?:DE\s+|DU\s+)?"
165 + r"([A-ZÀ-Ü][A-ZÀ-Üa-zà-ü']+(?:-[A-ZÀ-Üa-zà-ü']+)*)",
166 + desc)
167 + if m:
168 + lst.sector = m.group(1).strip(" -").title()
169 + lst.city = infer_city(lst.sector, default=lst.city)
170 +
171 + # disponibilité si mentionnée ("DISPONIBLE MAINTENANT", "LIBRE 1ER JUILLET"...)
172 + m = re.search(r"(?:LIBRE|DISPONIBLE|DISPONIBILIT[ÉE])\s*:?\s*(?:D[ÈE]S\s+|LE\s+)?"
173 + r"(MAINTENANT|IMM[ÉE]DIATEMENT|\d+\s*(?:ER|E)?\s*[A-ZÀ-Ü]{3,10}(?:\s+20\d\d)?|"
174 + r"[A-ZÀ-Ü]{3,10}\s+20\d\d)", desc, re.I)
175 + if not m:
176 + m = re.search(r"PRISE DE POSSESSION\s*:?\s*(FLEXIBLE\s*)?"
177 + r"(\([^)]{0,50}\)|[A-ZÀ-Ü0-9][^.<–—-]{0,40})?", desc, re.I)
178 + if m:
179 + lst.availability = re.sub(r"\s+", " ", m.group(0)).strip().capitalize()
180 +
181 + return list(listings.values())
added louka/connectors/shdm.py +166 −0
@@ -0,0 +1,166 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/shdm.py : connecteur SHDM — Société d'habitation et de
5 +# développement de Montréal (shdm.org, logements abordables à Montréal).
6 +# Front Nuxt 3 + WordPress GraphQL : la page /fr/logements-disponibles
7 +# embarque les logements disponibles dans le payload __NUXT_DATA__
8 +# (format « devalue » : tableau plat où les valeurs sont des index).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import json
13 +import re
14 +
15 +from ..schema import Listing, normalize_unit_type
16 +from .base import BaseConnector
17 +
18 +BASE = "https://www.shdm.org"
19 +LIST_URL = f"{BASE}/fr/logements-disponibles"
20 +
21 +# Champs booléens du CPT « logement » -> libellé de commodité
22 +_AMENITY_FLAGS = {
23 + "chauffage": "Chauffage inclus",
24 + "electricite": "Électricité incluse",
25 + "eauChaude": "Eau chaude incluse",
26 + "balcon": "Balcon",
27 + "portePatio": "Porte-patio",
28 + "plancherBois": "Plancher de bois",
29 + "deuxEtage": "Sur deux étages",
30 + "adapte": "Logement adapté",
31 + "handicape": "Accessible (mobilité réduite)",
32 +}
33 +
34 +_BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}
35 +
36 +
37 +def _strip_html(s: str) -> str:
38 + s = re.sub(r"<[^>]+>", " ", s or "")
39 + s = s.replace("&rsquo;", "’").replace("&nbsp;", " ")
40 + s = re.sub(r"&[a-z]+;|&#\d+;", " ", s)
41 + return re.sub(r"\s+", " ", s).strip()
42 +
43 +
44 +class _Devalue:
45 + """Résolution des références du payload Nuxt 3 (format devalue)."""
46 +
47 + def __init__(self, data: list):
48 + self.data = data
49 +
50 + def resolve(self, ref, seen: frozenset = frozenset()):
51 + if not isinstance(ref, int) or not (0 <= ref < len(self.data)):
52 + return ref
53 + if ref in seen:
54 + return None
55 + seen = seen | {ref}
56 + val = self.data[ref]
57 + if isinstance(val, dict):
58 + return {k: self.resolve(v, seen) for k, v in val.items()}
59 + if isinstance(val, list):
60 + return [self.resolve(v, seen) for v in val]
61 + return val
62 +
63 +
64 +class SHDMConnector(BaseConnector):
65 + source_id = "shdm"
66 + request_delay = 0.6
67 +
68 + @staticmethod
69 + def _media_urls(medias) -> list[str]:
70 + """[{media:{node:{generic: url, ...}}}] -> URLs pleine taille."""
71 + urls: list[str] = []
72 + for m in medias or []:
73 + node = ((m or {}).get("media") or {}).get("node") or {}
74 + u = node.get("generic") or node.get("genericDesktop") or ""
75 + if u:
76 + urls.append(u)
77 + return urls
78 +
79 + def fetch(self) -> list[Listing]:
80 + listings: list[Listing] = []
81 + try:
82 + html = self.get(LIST_URL).text
83 + except Exception:
84 + return listings
85 +
86 + m = re.search(r'id="__NUXT_DATA__"[^>]*>(.*?)</script>', html, re.S)
87 + if not m:
88 + return listings
89 + try:
90 + data = json.loads(m.group(1))
91 + except ValueError:
92 + return listings
93 + if not isinstance(data, list):
94 + return listings
95 + dv = _Devalue(data)
96 +
97 + seen_ids: set[str] = set()
98 + for i, raw in enumerate(data):
99 + if not (isinstance(raw, dict) and "logementFields" in raw
100 + and "uri" in raw):
101 + continue
102 + try:
103 + node = dv.resolve(i)
104 + slug = node.get("slug") or ""
105 + lf = node.get("logementFields") or {}
106 + if not slug or slug in seen_ids or not isinstance(lf, dict):
107 + continue
108 +
109 + imm_nodes = ((lf.get("immeuble") or {}).get("nodes")) or [{}]
110 + imm = imm_nodes[0] if isinstance(imm_nodes[0], dict) else {}
111 + bf = imm.get("buildingFields") or {}
112 +
113 + building_title = (imm.get("title") or "").strip()
114 + address = (bf.get("adresse") or building_title).strip()
115 + quartier_nodes = ((bf.get("quartier") or {}).get("nodes")) or []
116 + sector = (quartier_nodes[0].get("title") or "").strip() \
117 + if quartier_nodes and isinstance(quartier_nodes[0], dict) else ""
118 +
119 + unit_no = str(lf.get("numeroLogement") or "").strip()
120 + typologie = lf.get("typologie") or []
121 + typ_raw = typologie[0] if typologie else ""
122 + unit_type = normalize_unit_type(typ_raw) or \
123 + _BEDROOMS_TO_TYPE.get(lf.get("chambre"), "")
124 +
125 + price = lf.get("prix")
126 + price = float(price) if isinstance(price, (int, float)) else None
127 + price_label = f"{price:,.0f}".replace(",", " ") + " $ / mois" \
128 + if price else ""
129 + if re.search(r"stationnement|commercial", typ_raw, re.I):
130 + continue
131 +
132 + amenities = [label for key, label in _AMENITY_FLAGS.items()
133 + if lf.get(key)]
134 +
135 + # images : photos du logement + photo(s) de l'immeuble
136 + images = (self._media_urls(lf.get("medias"))
137 + + self._media_urls(bf.get("medias")))
138 +
139 + title = building_title or address
140 + if unit_no:
141 + title = f"{title} — app. {unit_no}"
142 +
143 + # NB : les URI WordPress /fr/logement/<slug> répondent 404
144 + # (les fiches s'ouvrent en modale) -> on pointe vers la liste,
145 + # avec le slug en ancre pour garder une URL unique et valide.
146 + listings.append(Listing(
147 + source=self.source_id,
148 + external_id=slug,
149 + url=f"{LIST_URL}#{slug}",
150 + title=title,
151 + address=address,
152 + sector=sector,
153 + city="Montréal", # SHDM = société paramunicipale de Mtl
154 + unit_type=unit_type,
155 + price=price,
156 + price_label=price_label,
157 + availability=(lf.get("date") or "")[:10],
158 + description=_strip_html(bf.get("content") or "")[:600],
159 + amenities=amenities,
160 + images=list(dict.fromkeys(u for u in images if u))[:25],
161 + ))
162 + seen_ids.add(slug)
163 + except Exception:
164 + continue
165 +
166 + return listings
added louka/connectors/sibelanger.py +139 −0
@@ -0,0 +1,139 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sibelanger.py : connecteur Société immobilière Bélanger
5 +# (sibelanger.com) — page /appartements-a-louer/ : cartes d'unités avec
6 +# prix, « Disponible dès… », secteur, commodités et carrousel de photos.
7 +# Pages détail pour la galerie complète et la description.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
16 +from .base import BaseConnector
17 +
18 +BASE = "https://sibelanger.com"
19 +LIST_URL = f"{BASE}/appartements-a-louer/"
20 +
21 +IMG_RE = re.compile(
22 + r"https://sibelanger\.com/app/uploads/[^\"'\\\s\)]+"
23 + r"\.(?:jpg|jpeg|png|webp)", re.I)
24 +IMG_NOISE_RE = re.compile(r"logo|favicon|icon|sib_ico", re.I)
25 +
26 +
27 +class SibelangerConnector(BaseConnector):
28 + source_id = "sibelanger"
29 + request_delay = 0.6
30 + max_details = 60 # garde-fou
31 +
32 + def fetch(self) -> list[Listing]:
33 + html = self.get(LIST_URL).text
34 + soup = BeautifulSoup(html, "html.parser")
35 +
36 + listings: dict[str, Listing] = {}
37 + for card in soup.select("div.listing__thumbnail"):
38 + try:
39 + lst = self._parse_card(card)
40 + except Exception:
41 + continue
42 + if lst and lst.external_id not in listings:
43 + listings[lst.external_id] = lst
44 +
45 + # Pages détail : galerie complète + description
46 + for i, lst in enumerate(listings.values()):
47 + if i >= self.max_details:
48 + break
49 + try:
50 + self._enrich(lst)
51 + except Exception:
52 + continue
53 +
54 + return list(listings.values())
55 +
56 + def _parse_card(self, card) -> Listing | None:
57 + link = card.select_one("a.listing__thumbnail__content__title-wrapper") \
58 + or card.select_one("a[href*='/appartements-a-louer/']")
59 + if not link:
60 + return None
61 + url = link.get("href", "").split("?")[0]
62 + m = re.search(r"/appartements-a-louer/([a-z0-9\-]+)/?$", url)
63 + if not m:
64 + return None
65 + slug = m.group(1)
66 +
67 + fav = card.select_one("[data-unit-id]")
68 + ext_id = (fav.get("data-unit-id", "").strip() if fav else "") or slug
69 +
70 + h3 = card.select_one("h3")
71 + title = h3.get_text(" ", strip=True) if h3 else slug
72 + sector_el = card.select_one(
73 + ".listing__thumbnail__content__title-wrapper p")
74 + sector = sector_el.get_text(strip=True) if sector_el else ""
75 +
76 + price_el = card.select_one(".listing__thumbnail__price")
77 + price_label = price_el.get_text(" ", strip=True) if price_el else ""
78 + avail_el = card.select_one(".listing__thumbnail__availability")
79 + avail = avail_el.get_text(" ", strip=True) if avail_el else ""
80 + size_el = card.select_one(".listing__thumbnail__size")
81 + unit_raw = size_el.get_text(" ", strip=True) if size_el else ""
82 +
83 + # Exclusions (prudence : le site est résidentiel)
84 + if re.search(r"stationnement|commercial|rangement|entrepos",
85 + f"{title} {unit_raw}", re.I):
86 + return None
87 +
88 + # Adresse dérivée du slug : « 350-101-chemin-ste-foy-… » ->
89 + # « 350, Chemin Ste-Foy » (n° d'immeuble, n° d'unité, rue)
90 + address = ""
91 + s = re.sub(r"^copie-de-", "", slug)
92 + ma = re.match(r"^(\d+)-\d+[a-z]?-([a-z\-]+?)"
93 + r"(?:-selection|-modele|-app|$)", s)
94 + if ma:
95 + street = " ".join(w.capitalize() for w in ma.group(2).split("-"))
96 + address = f"{ma.group(1)}, {street}"
97 +
98 + amenities = [img.get("title") or img.get("alt", "")
99 + for img in card.select(
100 + ".listing__thumbnail__content__features img")]
101 + amenities = [a.strip() for a in amenities if a and a.strip()]
102 +
103 + images = []
104 + for img in card.select(".swiper-slide img"):
105 + src = img.get("src") or img.get("data-src") or ""
106 + if src.startswith("http") and not IMG_NOISE_RE.search(src):
107 + images.append(src)
108 +
109 + return Listing(
110 + source=self.source_id,
111 + external_id=ext_id,
112 + url=url,
113 + title=title,
114 + address=address,
115 + sector=sector,
116 + city=infer_city(sector),
117 + unit_type=normalize_unit_type(unit_raw),
118 + price=parse_price(price_label),
119 + price_label=price_label,
120 + availability=avail,
121 + amenities=amenities,
122 + images=list(dict.fromkeys(images)),
123 + )
124 +
125 + def _enrich(self, lst: Listing) -> None:
126 + html = self.get(lst.url).text
127 + soup = BeautifulSoup(html, "html.parser")
128 +
129 + og = soup.find("meta", attrs={"property": "og:description"}) or \
130 + soup.find("meta", attrs={"name": "description"})
131 + if og and og.get("content"):
132 + lst.description = og["content"].strip()[:600]
133 +
134 + imgs = [u for u in dict.fromkeys(IMG_RE.findall(html))
135 + if not IMG_NOISE_RE.search(u)
136 + and not re.search(r"-\d+x\d+\.", u)]
137 + merged = list(dict.fromkeys(imgs + lst.images))
138 + if merged:
139 + lst.images = merged[:25]
added louka/connectors/simard.py +153 −0
@@ -0,0 +1,153 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/simard.py : connecteur Immeubles Simard (immeublessimard.com)
5 +# Section « À louer » — catégorie appartement uniquement (les bureaux,
6 +# commerces et laboratoires sont exclus d'office). Pages détail pour le
7 +# prix, la disponibilité et toutes les images.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import re
12 +
13 +from bs4 import BeautifulSoup
14 +
15 +from ..schema import Listing, infer_city, normalize_unit_type
16 +from .base import BaseConnector
17 +
18 +BASE = "https://immeublessimard.com"
19 +LIST_URL = f"{BASE}/a-louer/categorie/appartement/"
20 +
21 +DETAIL_RE = re.compile(r"/a-louer/appartement/([a-z0-9\-]+)/([a-z0-9\-]+)/?$")
22 +IMG_RE = re.compile(
23 + r"https://immeublessimard\.com/wp-content/uploads/[^\"'\\\s\)]+"
24 + r"\.(?:jpg|jpeg|png|webp)", re.I)
25 +IMG_NOISE_RE = re.compile(r"logo|favicon|icon", re.I)
26 +# Prix affiché « $1975.00 » ou « 1 975$ »
27 +PRICE_RE = re.compile(r"(?:\$\s*([\d\s,]+(?:\.\d{2})?)|([\d][\d\s]*(?:,\d{2})?)\s*\$)")
28 +
29 +
30 +def _parse_price(text: str) -> float | None:
31 + """Gère les deux formats : « $1975.00 » et « 1 975$ »."""
32 + if not text:
33 + return None
34 + s = text.replace(" ", " ").replace(" ", " ")
35 + s = re.sub(r"(\d),(\d{3})", r"\1\2", s) # 1,975 -> 1975
36 + m = PRICE_RE.search(s)
37 + if not m:
38 + return None
39 + num = (m.group(1) or m.group(2)).replace(" ", "").replace(",", ".")
40 + try:
41 + val = float(num)
42 + except ValueError:
43 + return None
44 + return val if 100 <= val <= 20000 else None
45 +
46 +
47 +class SimardConnector(BaseConnector):
48 + source_id = "simard"
49 + request_delay = 0.6
50 + max_details = 60 # garde-fou
51 +
52 + def fetch(self) -> list[Listing]:
53 + html = self.get(LIST_URL).text
54 + soup = BeautifulSoup(html, "html.parser")
55 +
56 + listings: dict[str, Listing] = {}
57 + for card in soup.select("div.item a[href*='/a-louer/appartement/']"):
58 + try:
59 + lst = self._parse_card(card)
60 + except Exception:
61 + continue
62 + if lst and lst.external_id not in listings:
63 + listings[lst.external_id] = lst
64 +
65 + # Pages détail : prix, disponibilité, description, toutes les images
66 + for i, lst in enumerate(listings.values()):
67 + if i >= self.max_details:
68 + break
69 + try:
70 + self._enrich(lst)
71 + except Exception:
72 + continue
73 +
74 + return list(listings.values())
75 +
76 + def _parse_card(self, card) -> Listing | None:
77 + href = card.get("href", "").split("?")[0]
78 + m = DETAIL_RE.search(href)
79 + if not m:
80 + return None
81 + sector_slug, slug = m.groups()
82 + title_el = card.select_one("h2")
83 + sector_el = card.select_one(".secteur")
84 + bullets = [li.get_text(" ", strip=True) for li in card.select("li")]
85 + title = title_el.get_text(" ", strip=True) if title_el else slug
86 + sector = sector_el.get_text(strip=True) if sector_el else \
87 + sector_slug.replace("-", " ").title()
88 + unit_type = ""
89 + for b in bullets + [title]:
90 + m2 = re.search(r"\d\s*(?:½|1/2)|studio|loft", b, re.I)
91 + if m2:
92 + unit_type = normalize_unit_type(m2.group(0))
93 + break
94 + return Listing(
95 + source=self.source_id,
96 + external_id=slug,
97 + url=href if href.startswith("http") else BASE + href,
98 + title=title,
99 + sector=sector,
100 + city=infer_city(sector),
101 + unit_type=unit_type,
102 + description=" • ".join(b for b in bullets if b)[:400],
103 + )
104 +
105 + def _enrich(self, lst: Listing) -> None:
106 + html = self.get(lst.url).text
107 + soup = BeautifulSoup(html, "html.parser")
108 +
109 + # Description (meta) — contient prix, dispo, inclusions
110 + og = soup.find("meta", attrs={"property": "og:description"}) or \
111 + soup.find("meta", attrs={"name": "description"})
112 + desc = (og.get("content") or "").strip() if og else ""
113 + body = soup.get_text(" ", strip=True)
114 + blob = f"{desc} {body}"
115 +
116 + if desc:
117 + lst.description = desc[:600]
118 +
119 + # Prix : ignorer les mentions de stationnement (« Stationnement; $90.00 »)
120 + cleaned = re.sub(r"[Ss]tationnement[^.]{0,40}\$\s*[\d.,]+", " ", blob)
121 + price = _parse_price(cleaned)
122 + if price:
123 + lst.price = price
124 + m = PRICE_RE.search(re.sub(r"(\d),(\d{3})", r"\1\2", cleaned))
125 + lst.price_label = m.group(0).strip() if m else ""
126 +
127 + m = re.search(r"Disponibilité\s*:\s*[^.<(]{3,60}", blob) or \
128 + re.search(r"[Dd]isponible\s*(?:le|dès|pour|en|maintenant|immédiatement)"
129 + r"[^.<]{0,60}", blob)
130 + if m:
131 + avail = re.sub(r"\s+", " ", m.group(0)).strip()
132 + avail = re.split(r"\s+(?:Location\s*:|Caractéristiques|Tél|Prix|"
133 + r"Description|Superficie)", avail)[0]
134 + lst.availability = avail.strip()
135 +
136 + if not lst.unit_type:
137 + m = re.search(r"\d\s*(?:½|1/2)|studio|loft", blob, re.I)
138 + if m:
139 + lst.unit_type = normalize_unit_type(m.group(0))
140 +
141 + # Commodités : inclusions mentionnées
142 + amen = re.search(r"[Ii]ncluant\s+([^.]{5,140})", blob)
143 + if amen:
144 + lst.amenities = [a.strip(" .") for a in
145 + re.split(r",\s*|\bet\b", amen.group(1)) if a.strip(" .")]
146 +
147 + imgs = [u for u in dict.fromkeys(IMG_RE.findall(html))
148 + if not IMG_NOISE_RE.search(u)]
149 + # éliminer les variantes redimensionnées quand l'originale est là
150 + originals = {re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) for u in imgs}
151 + lst.images = [u for u in imgs
152 + if re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) == u
153 + or re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) not in originals][:25]
added louka/connectors/terra.py +153 −0
@@ -0,0 +1,153 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/terra.py : connecteur TERRA Condos locatifs (terracondolocatif.ca)
5 +# Projet de 4 phases sur la route Mgr-Bourget (arrondissement Desjardins,
6 +# Lévis). Site Webflow : chaque page de phase liste les unités par immeuble
7 +# avec statut Disponible/Réservée/Louée (classes w-condition-invisible).
8 +# Prix « à partir de » par type affichés en entête (3½+/4½+).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Listing, infer_city, normalize_unit_type, parse_price
17 +from .base import BaseConnector
18 +
19 +BASE = "https://www.terracondolocatif.ca"
20 +PHASES = {
21 + "phase-1-2": "Phases 1-2",
22 + "phase-3": "Phase 3",
23 + "phase-4": "Phase 4",
24 +}
25 +SECTOR = "Desjardins (Lévis)"
26 +
27 +# images générales du projet (photo + plan d'ensemble)
28 +_GALLERY_RE = re.compile(
29 + r'https://cdn\.prod\.website-files\.com/[^"\s,]+\.(?:jpg|jpeg|webp)', re.I)
30 +
31 +
32 +class TerraConnector(BaseConnector):
33 + source_id = "terra"
34 + request_delay = 0.6
35 +
36 + def fetch(self) -> list[Listing]:
37 + listings: dict[str, Listing] = {}
38 +
39 + for slug, phase_name in PHASES.items():
40 + try:
41 + html = self.get(f"{BASE}/{slug}").text
42 + except Exception:
43 + continue
44 + soup = BeautifulSoup(html, "html.parser")
45 +
46 + # Prix « à partir de » par type (ex. "3 1/2 + à partir de 1185 $/mois")
47 + type_prices: dict[str, tuple[float | None, str]] = {}
48 + for m in re.finditer(r"(\d)\s*1/2\s*\+?\s*à partir de\s*"
49 + r"([\d\s ]+)\$\s*/\s*mois",
50 + soup.get_text(" ", strip=True)):
51 + label = (f"{m.group(1)}½ à partir de "
52 + f"{m.group(2).strip()} $/mois")
53 + type_prices[m.group(1)] = (parse_price(f"{m.group(2)}$"), label)
54 +
55 + # Galerie générale (photo du projet)
56 + gallery = [u for u in dict.fromkeys(_GALLERY_RE.findall(html))
57 + if not re.search(r"logo|favicon|icon", u, re.I)][:3]
58 +
59 + # Onglets = immeubles ("Le 939", "Le 943", ...)
60 + for pane in soup.select("div.w-tab-pane"):
61 + building = (pane.get("data-w-tab") or "").strip()
62 + for card in pane.select("div.listeunitecms"):
63 + try:
64 + lst = self._parse_card(card, building, slug,
65 + phase_name, type_prices,
66 + gallery)
67 + if lst and lst.external_id not in listings:
68 + listings[lst.external_id] = lst
69 + except Exception:
70 + continue
71 +
72 + return list(listings.values())
73 +
74 + def _parse_card(self, card, building, slug, phase_name,
75 + type_prices, gallery) -> Listing | None:
76 + # Statut : disponible si .tagdispo n'a PAS la classe w-condition-invisible
77 + tag = card.select_one(".tagdispo")
78 + if tag is None or "w-condition-invisible" in tag.get("class", []):
79 + return None
80 +
81 + text = card.get_text(" ", strip=True)
82 + num_m = re.search(r"\(\s*(\d+)\s*\)", text)
83 + if not num_m:
84 + return None
85 + unit_no = num_m.group(1)
86 +
87 + type_m = re.search(r"(\d\s*1/2\s*\+?)", text)
88 + raw_type = type_m.group(1) if type_m else ""
89 + unit_type = normalize_unit_type(raw_type)
90 +
91 + floor_m = re.search(r"(RDC|Étage\s*\d+)", text)
92 + sqft_m = re.search(r"(\d{3,4})\s*pi", text)
93 + beds_m = re.search(r"(\d+)\s*chambres?", text)
94 + expo_m = re.search(r"Exposition\s+([\w.]+)", text)
95 + model_m = re.search(r"Type\s+([\w.]+)", text)
96 +
97 + # Fiche PDF de l'unité (le lien visible), ex. .../Fiche_939-101.pdf,
98 + # .../FIche_943-402.pdf ou .../68f5..._951-106.pdf
99 + fiche = ""
100 + for a in card.select("a.buttonplans[href]"):
101 + href = a.get("href", "")
102 + if href.startswith("http") and href.lower().endswith(".pdf"):
103 + fiche = href
104 + break
105 + fm = re.search(r"(\d{3})-(\d{3})\.pdf$", fiche, re.I)
106 + if fm:
107 + building = fm.group(1)
108 + unit_no = fm.group(2)
109 + elif not building.isdigit():
110 + # sans fiche ni onglet d'immeuble (onglet d'étage) : doublon
111 + return None
112 +
113 + digit = re.search(r"(\d)", raw_type)
114 + price, price_label = (None, "")
115 + if digit and digit.group(1) in type_prices:
116 + price, price_label = type_prices[digit.group(1)]
117 +
118 + desc_parts = []
119 + if floor_m:
120 + desc_parts.append(floor_m.group(1))
121 + if sqft_m:
122 + desc_parts.append(f"{sqft_m.group(1)} pi²")
123 + if beds_m:
124 + desc_parts.append(f"{beds_m.group(1)} chambre(s)")
125 + if expo_m:
126 + desc_parts.append(f"Exposition {expo_m.group(1)}")
127 + if model_m:
128 + desc_parts.append(f"Modèle {model_m.group(1)}")
129 + if fiche:
130 + desc_parts.append(f"Fiche : {fiche}")
131 +
132 + ext_id = f"{building or slug}-{unit_no}"
133 + address = (f"{building}, route Mgr-Bourget, Lévis"
134 + if building.isdigit() else "route Mgr-Bourget, Lévis")
135 +
136 + return Listing(
137 + source=self.source_id,
138 + external_id=ext_id,
139 + url=f"{BASE}/{slug}",
140 + title=f"TERRA {phase_name} — Le {building}, unité {unit_no}"
141 + f" ({unit_type})",
142 + address=address,
143 + sector=SECTOR,
144 + city=infer_city(SECTOR, default="Lévis"),
145 + unit_type=unit_type,
146 + price=price,
147 + price_label=price_label,
148 + availability="Disponible",
149 + description=" | ".join(desc_parts),
150 + amenities=["Chauffé et climatisé", "Eau chaude incluse",
151 + "Internet inclus", "Stationnement intérieur"],
152 + images=list(gallery),
153 + )
added louka/connectors/trudel.py +143 −0
@@ -0,0 +1,143 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/trudel.py : connecteur Trudel — Fleur de Lys condos locatifs
5 +# (le21mars.ca / 18juillet.trudel.ca — 550, boul. Wilfrid-Hamel, Québec,
6 +# secteur Vanier). Le sélecteur de plans du Vingt-et-un Mars est un embed
7 +# RealVuu (client=trudel, project=fdl) dont la page contient toutes les
8 +# unités en JSON (numéro, pièces, prix, disponibilité, plans/images).
9 +# Le Dix-Huit Juillet n'affiche aucune unité (landing de contact seulement).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import json
14 +
15 +from ..schema import Listing, infer_city
16 +from .base import BaseConnector
17 +
18 +RV_URL = "https://app.realvuu.com/fr/external/trudel/fdl/plans"
19 +SITE_URL = "https://www.le21mars.ca/plans/"
20 +SECTOR = "Vanier (Fleur de Lys)"
21 +
22 +
23 +def _extract_json_array(html: str, key: str) -> list:
24 + """Extrait le plus grand tableau JSON `"key":[...]` de la page RealVuu.
25 +
26 + La clé peut apparaître plusieurs fois (souvent vide ailleurs) : on garde
27 + l'occurrence contenant le plus d'éléments.
28 + """
29 + dec = json.JSONDecoder()
30 + needle = f'"{key}":['
31 + best: list = []
32 + start = 0
33 + while True:
34 + i = html.find(needle, start)
35 + if i == -1:
36 + break
37 + try:
38 + arr, _ = dec.raw_decode(html[i + len(needle) - 1:])
39 + if isinstance(arr, list) and len(arr) > len(best):
40 + best = arr
41 + except Exception:
42 + pass
43 + start = i + len(needle)
44 + return best
45 +
46 +
47 +def _unit_type_from_rooms(rooms: float) -> str:
48 + if not rooms:
49 + return ""
50 + if rooms <= 1:
51 + return "Studio"
52 + return f"{int(rooms)}½"
53 +
54 +
55 +class TrudelConnector(BaseConnector):
56 + source_id = "trudel"
57 + request_delay = 0.6
58 +
59 + def fetch(self) -> list[Listing]:
60 + html = self.get(RV_URL).text
61 +
62 + units = _extract_json_array(html, "units")
63 + buildings = {b.get("buildingId"): b
64 + for b in _extract_json_array(html, "buildings")}
65 +
66 + avail = [u for u in units
67 + if u.get("availability") == "AVAILABLE"
68 + and u.get("rental")
69 + and u.get("typeType") == "APARTMENT"
70 + and u.get("segment") in ("RESIDENTIAL", "", None)
71 + and u.get("isMarketable", True)]
72 +
73 + # Garde-fou prix placeholder : prix unique pour des types différents
74 + prices = {u.get("rentalPrice") for u in avail}
75 + rooms_set = {u.get("rooms") for u in avail}
76 + placeholder = len(prices) == 1 and len(rooms_set) > 1
77 +
78 + listings: list[Listing] = []
79 + for u in avail:
80 + try:
81 + b = buildings.get(u.get("buildingId"), {})
82 + bname = (b.get("name") or "").strip()
83 + if "ensemble" in bname.lower():
84 + continue # entrée technique « plan d'ensemble »
85 + address = (u.get("address") or b.get("address") or "").strip()
86 + number = str(u.get("number") or "").strip()
87 + unit_type = _unit_type_from_rooms(u.get("rooms") or 0)
88 +
89 + price = None
90 + price_label = ""
91 + rp = u.get("rentalPrice") or 0
92 + if rp and not placeholder and 100 <= rp <= 20000:
93 + price = float(rp)
94 + price_label = f"{rp} $/mois"
95 +
96 + availability = "Disponible"
97 + if u.get("futureAvailability"):
98 + availability = f"Disponible : {u['futureAvailability']}"
99 +
100 + images: list[str] = []
101 + for ti in (u.get("typeImages") or []):
102 + if ti.get("fullUrl"):
103 + images.append(ti["fullUrl"])
104 + for im in (u.get("images") or []):
105 + if isinstance(im, dict) and im.get("fullUrl"):
106 + images.append(im["fullUrl"])
107 + if u.get("floorPlanImageUrl"):
108 + images.append(u["floorPlanImageUrl"])
109 + images = list(dict.fromkeys(images))
110 +
111 + building_label = bname or "Le Vingt-et-un Mars"
112 + desc = []
113 + if u.get("unitSize"):
114 + desc.append(f"{u['unitSize']} pi²")
115 + if u.get("roomsBed"):
116 + desc.append(f"{u['roomsBed']} chambre(s)")
117 + if u.get("roomsBath"):
118 + desc.append(f"{u['roomsBath']} salle(s) de bain")
119 + if u.get("typeName"):
120 + desc.append(f"Modèle {u['typeName']}")
121 + desc.append(f"Immeuble {building_label} — Fleur de Lys")
122 +
123 + listings.append(Listing(
124 + source=self.source_id,
125 + external_id=u.get("unitId") or f"{bname}-{number}",
126 + url=SITE_URL,
127 + title=f"Fleur de Lys ({building_label}) — Unité {number}"
128 + f"{' (' + unit_type + ')' if unit_type else ''}",
129 + address=address or "550, boul. Wilfrid-Hamel, Québec",
130 + sector=SECTOR,
131 + city=infer_city(SECTOR, default="Québec"),
132 + unit_type=unit_type,
133 + price=price,
134 + price_label=price_label,
135 + availability=availability,
136 + description=" | ".join(desc),
137 + amenities=["Eau chaude incluse", "Air climatisé"],
138 + images=images,
139 + ))
140 + except Exception:
141 + continue
142 +
143 + return listings
added louka/connectors/trylon.py +146 −0
@@ -0,0 +1,146 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/trylon.py : connecteur Trylon Montréal (trylonmontreal.com)
5 +# Immeuble unique au 1400, avenue des Pins Ouest (centre-ville, au pied
6 +# du Mont-Royal). WordPress/Elementor rendu serveur : la page /fr/des-pins/
7 +# liste les unités disponibles sous forme de sections « Semi-Meublé - <mois>
8 +# <année> » suivies de cartes (type, prix $/mo, étage, surface, inclusions).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Listing, normalize_unit_type
17 +from .base import BaseConnector
18 +
19 +BASE = "https://trylonmontreal.com"
20 +PAGE_URL = f"{BASE}/fr/des-pins/"
21 +
22 +ADDRESS = "1400, avenue des Pins Ouest, Montréal"
23 +BUILDING_AMENITIES = ["Piscine chauffée intérieure", "Salle d'entraînement",
24 + "Terrasse sur le toit"]
25 +
26 +IMG_RE = re.compile(
27 + r"https://trylonmontreal\.com/wp-content/uploads/"
28 + r"[^\"\s\\]+?\.(?:jpg|jpeg|png|webp)", re.I)
29 +
30 +AVAIL_RE = re.compile(
31 + r"^(?:Semi-)?(?:Non-)?Meubl[ée]\s*[-–]\s*\w+\s*\d{4}$|"
32 + r"^Disponible[\w\s]*$|^Libre[\w\s]*$", re.I)
33 +UNIT_RE = re.compile(r"^Appartement\s+\d\s*(?:1/2|½)", re.I)
34 +PRICE_RE = re.compile(r"\$\s*([\d\s,]+)\s*/\s*mo", re.I)
35 +
36 +
37 +class TrylonConnector(BaseConnector):
38 + source_id = "trylon"
39 + request_delay = 0.6
40 +
41 + def fetch(self) -> list[Listing]:
42 + listings: list[Listing] = []
43 + try:
44 + html = self.get(PAGE_URL).text
45 + except Exception:
46 + return listings
47 + soup = BeautifulSoup(html, "html.parser")
48 +
49 + # Photos de l'immeuble (les cartes d'unités n'ont pas de galerie propre)
50 + images = [u for u in dict.fromkeys(IMG_RE.findall(html))
51 + if not re.search(r"logo|icon|favicon|-\d+x\d+\.", u, re.I)][:20]
52 +
53 + # Parcours en ordre du document : un en-tête « Semi-Meublé - <mois> »
54 + # définit la disponibilité des cartes qui suivent.
55 + availability = ""
56 + current: dict | None = None
57 + seen: dict[str, int] = {}
58 +
59 + def flush():
60 + nonlocal current
61 + if not current or not current.get("unit_type"):
62 + current = None
63 + return
64 + ut = current["unit_type"]
65 + price = current.get("price")
66 + floor = current.get("floor", "")
67 + base_id = re.sub(r"[^\w.\-]", "",
68 + f"despins-{ut.replace('½', '.5')}-{floor}"
69 + f"-{int(price) if price else 'na'}")
70 + seen[base_id] = seen.get(base_id, 0) + 1
71 + ext_id = base_id if seen[base_id] == 1 else f"{base_id}-{seen[base_id]}"
72 + desc_bits = [b for b in (
73 + f"Étage : {floor}" if floor else "",
74 + f"Surface : {current.get('sqft', '')}" if current.get("sqft") else "",
75 + current.get("furnished", ""),
76 + ) if b]
77 + listings.append(Listing(
78 + source=self.source_id,
79 + external_id=ext_id,
80 + url=PAGE_URL,
81 + title=f"Appartement {ut} — 1400 des Pins",
82 + address=ADDRESS,
83 + sector="Centre-ville",
84 + city="Montréal",
85 + unit_type=ut,
86 + price=price,
87 + price_label=current.get("price_label", ""),
88 + availability=current.get("availability", ""),
89 + description=" | ".join(desc_bits)[:600],
90 + amenities=list(dict.fromkeys(
91 + current.get("inclusions", []) + BUILDING_AMENITIES)),
92 + images=images,
93 + ))
94 + current = None
95 +
96 + widgets = soup.select(".elementor-widget-heading .elementor-heading-title,"
97 + " .elementor-widget-text-editor"
98 + " .elementor-widget-container")
99 + for w in widgets:
100 + try:
101 + text = w.get_text(" ", strip=True)
102 + except Exception:
103 + continue
104 + if not text:
105 + continue
106 + if AVAIL_RE.match(text):
107 + flush()
108 + availability = text
109 + continue
110 + if UNIT_RE.match(text):
111 + flush()
112 + furnished = ""
113 + m = re.match(r"(Semi-|Non-)?Meubl[ée]", availability, re.I)
114 + if m:
115 + furnished = m.group(0)
116 + current = {
117 + "unit_type": normalize_unit_type(text),
118 + "availability": availability,
119 + "furnished": furnished,
120 + }
121 + continue
122 + if current is not None:
123 + m = PRICE_RE.search(text)
124 + if m and "price" not in current:
125 + num = m.group(1).replace(" ", "").replace(" ", "").replace(",", "")
126 + try:
127 + val = float(num)
128 + if 100 <= val <= 20000:
129 + current["price"] = val
130 + current["price_label"] = m.group(0)
131 + except ValueError:
132 + pass
133 + continue
134 + m = re.search(r"Étage\s*:\s*([\w]+)", text)
135 + if m:
136 + current["floor"] = m.group(1)
137 + m = re.search(r"Surface\s*:\s*([\d\s]+\s*pc)", text)
138 + if m:
139 + current["sqft"] = m.group(1).strip()
140 + m = re.search(r"Inclusions?\s*:\s*(.+?)(?:$|CONTACTER)", text)
141 + if m:
142 + current["inclusions"] = [
143 + a.strip(" .").capitalize()
144 + for a in re.split(r",| et ", m.group(1)) if a.strip(" .")]
145 + flush()
146 + return listings
added louka/connectors/utile.py +182 −0
@@ -0,0 +1,182 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/utile.py : connecteur UTILE (utile.org)
5 +# OBNL de logement étudiant. Immeubles montréalais : Angus (Rosemont),
6 +# Griffintown, Milton-Parc, Saint-Laurent, Saint-Patrick
7 +# (Pointe-Saint-Charles), Parc La Fontaine (Plateau), Des Carrières,
8 +# Hutchison... Site Webflow rendu serveur : une annonce par immeuble ×
9 +# typologie (Studio, 2 bedroom...) avec prix "From $x/month", photos de la
10 +# galerie (templates Webflow encodés en URL) et statut de l'immeuble
11 +# (For rent / Fully rented — balises conditionnelles w-condition-invisible).
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +import urllib.parse
17 +
18 +from bs4 import BeautifulSoup
19 +
20 +from ..schema import Listing, parse_price
21 +from .base import BaseConnector
22 +
23 +BASE = "https://www.utile.org"
24 +INDEX_URL = f"{BASE}/en/immeubles"
25 +
26 +STATUSES = ("For rent", "Fully rented", "Under construction", "In development")
27 +
28 +_TYPE_MAP = {
29 + "studio": "Studio",
30 + "mezzanine studio": "Studio (mezzanine)",
31 + "1 bedroom": "3½",
32 + "2 bedroom": "4½",
33 + "3 bedroom": "5½",
34 + "4 bedroom": "6½",
35 +}
36 +
37 +
38 +def _visible(el) -> bool:
39 + """Webflow masque les éléments conditionnels avec w-condition-invisible."""
40 + for node in [el] + list(el.parents)[:6]:
41 + if node is None or not hasattr(node, "get"):
42 + break
43 + if "w-condition-invisible" in (node.get("class") or []):
44 + return False
45 + return True
46 +
47 +
48 +def _unit_type(label: str) -> str:
49 + key = re.sub(r"\s+", " ", (label or "").strip().lower())
50 + for k, v in _TYPE_MAP.items():
51 + if key.startswith(k):
52 + return v
53 + m = re.search(r"(\d)\s*bedroom", key)
54 + if m:
55 + return f"{int(m.group(1)) + 2}½"
56 + return label.strip()
57 +
58 +
59 +class UtileConnector(BaseConnector):
60 + source_id = "utile"
61 + request_delay = 0.6
62 + max_buildings = 20 # garde-fou
63 +
64 + def fetch(self) -> list[Listing]:
65 + listings: list[Listing] = []
66 + try:
67 + index = self.get(INDEX_URL).text
68 + except Exception:
69 + return listings
70 + slugs = list(dict.fromkeys(
71 + re.findall(r'href="/en/immeubles/([a-z0-9\-]+)"', index)))
72 +
73 + for slug in slugs[:self.max_buildings]:
74 + try:
75 + listings.extend(self._fetch_building(slug))
76 + except Exception:
77 + continue
78 + return listings
79 +
80 + # -- un immeuble -------------------------------------------------------------
81 + def _fetch_building(self, slug: str) -> list[Listing]:
82 + url = f"{BASE}/en/immeubles/{slug}"
83 + html = self.get(url).text
84 + soup = BeautifulSoup(html, "html.parser")
85 +
86 + # Secteur + adresse (bloc c-location-tag du hero)
87 + sector = address = ""
88 + infos = soup.select(".c-location-tag__adress-wrapper "
89 + ".c-location-tag__info")
90 + if len(infos) >= 2:
91 + sector = infos[0].get_text(" ", strip=True)
92 + address = infos[1].get_text(" ", strip=True)
93 + elif infos:
94 + address = infos[0].get_text(" ", strip=True)
95 +
96 + # Hors Grand Montréal (Québec, Trois-Rivières, Sherbrooke, Rimouski...)
97 + if "montreal" not in address.lower().replace("é", "e"):
98 + return []
99 + city = "Montréal"
100 + address = re.sub(r",\s*Montreal$", ", Montréal", address)
101 +
102 + # Statut de l'immeuble : dernière balise de statut visible (hero)
103 + status = ""
104 + for el in soup.find_all(string=re.compile(
105 + r"^(For rent|Fully rented|Under construction|In development)$")):
106 + if _visible(el.find_parent()):
107 + status = el.strip()
108 + if status in ("Under construction", "In development"):
109 + return [] # pas encore en location
110 +
111 + name_el = soup.select_one("h1")
112 + building = name_el.get_text(" ", strip=True) if name_el else slug
113 +
114 + # Description (og:description)
115 + desc = ""
116 + og = soup.find("meta", attrs={"property": "og:description"})
117 + if og and og.get("content"):
118 + desc = og["content"].strip()[:600]
119 +
120 + avail_map = {"For rent": "En location",
121 + "Fully rented": "Complet (prochaine période de location)"}
122 + building_avail = avail_map.get(status, status)
123 +
124 + out: list[Listing] = []
125 + for card in soup.select(".c-building-apartment__collection-item"):
126 + try:
127 + title_el = card.select_one(".c-apartment-details__title")
128 + price_el = card.select_one(".c-apartment-price__content")
129 + if not title_el or not price_el:
130 + continue
131 + typology = title_el.get_text(" ", strip=True)
132 + price_label = price_el.get_text(" ", strip=True)
133 + from_el = card.select_one(".c-apartment-price__title")
134 + if from_el and from_el.get_text(strip=True):
135 + price_label = (f"{from_el.get_text(strip=True)} "
136 + f"{price_label}")
137 + price = parse_price(
138 + re.sub(r"\$(\d+)", r"\1$", price_label))
139 +
140 + # "Complet" affiché sur la carte ?
141 + complet = any(
142 + _visible(s.find_parent()) for s in
143 + card.find_all(string=re.compile(r"^Complet$")))
144 +
145 + # Photos : image de la carte + galerie rendue + template
146 + # Webflow (galerie modale, encodée en URL dans
147 + # <script type="text/x-wf-template">)
148 + images: list[str] = []
149 + for img in card.select("img"):
150 + src = img.get("src") or ""
151 + if src.startswith("http") and not src.endswith(".svg"):
152 + images.append(src)
153 + for tmpl in card.select('script[type="text/x-wf-template"]'):
154 + decoded = urllib.parse.unquote(tmpl.get_text())
155 + images.extend(re.findall(
156 + r'https://cdn\.prod\.website-files\.com/'
157 + r'[^"\s]+?\.(?:jpe?g|png|webp)', decoded))
158 + images = [u for u in dict.fromkeys(images)
159 + if "-p-" not in u] # variantes responsive
160 +
161 + type_slug = re.sub(r"[^a-z0-9]+", "-", typology.lower()).strip("-")
162 + out.append(Listing(
163 + source=self.source_id,
164 + external_id=f"{slug}-{type_slug}",
165 + url=url,
166 + title=f"UTILE {building}{typology}",
167 + address=address,
168 + sector=sector,
169 + city=city,
170 + unit_type=_unit_type(typology),
171 + price=price,
172 + price_label=price_label,
173 + availability="Complet" if complet else building_avail,
174 + description=desc,
175 + amenities=["Logement étudiant", "Bail 12 mois",
176 + "Non meublé", "Cuisinière et réfrigérateur "
177 + "inclus"],
178 + images=images,
179 + ))
180 + except Exception:
181 + continue
182 + return out
added louka/connectors/viridi.py +173 −0
@@ -0,0 +1,173 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/viridi.py : connecteur Le Viridi (condosleviridi.ca)
5 +# Immeuble de 89 unités dans l'Écoquartier Pointe-aux-Lièvres (Québec).
6 +# Le site présente 16 modèles d'unités (types A à O) avec prix
7 +# « à partir de » par catégorie (Studio/Loft/3½/4½/5½/6½, maison de ville).
8 +# Une annonce par modèle (pas de liste d'unités individuelles).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import re
13 +
14 +from bs4 import BeautifulSoup
15 +
16 +from ..schema import Listing, normalize_unit_type, parse_price
17 +from .base import BaseConnector
18 +
19 +BASE = "https://condosleviridi.ca"
20 +LIST_URL = f"{BASE}/condos-a-louer-quebec/"
21 +SECTOR = "Pointe-aux-Lièvres (Saint-Roch)"
22 +CITY = "Québec"
23 +
24 +_SKIP_IMG = re.compile(r"logo|favicon|icon|affichez", re.I)
25 +
26 +
27 +class ViridiConnector(BaseConnector):
28 + source_id = "viridi"
29 + request_delay = 0.6
30 +
31 + # ------------------------------------------------------------------
32 + def _price_map(self, text: str) -> dict[tuple[str, bool], tuple[float | None, str]]:
33 + """Construit {(catégorie, maison_de_ville): (prix, libellé)}.
34 +
35 + Le bloc de prix de la page liste, dans l'ordre :
36 + Loft 1285$, Studio 1300$, 3½ 1400$, 4½ 1995$,
37 + 4½ MV 2450$, 5½ MV 2800$, 6½ MV 3300$.
38 + """
39 + out: dict[tuple[str, bool], tuple[float | None, str]] = {}
40 + # découpage en lignes propres
41 + lines = [l.strip() for l in text.split("\n") if l.strip()]
42 + current: str | None = None
43 + mv = False
44 + for line in lines:
45 + low = line.lower()
46 + if re.fullmatch(r"(loft|studio|\d\s*½|\d\s*1/2)", low):
47 + current = ("loft" if low == "loft" else
48 + "studio" if low == "studio" else
49 + re.search(r"\d", low).group(0))
50 + mv = False
51 + elif "maison de ville" in low and current:
52 + mv = True
53 + elif current and "à partir de" in low:
54 + price = parse_price(line)
55 + label = re.sub(r"\s+", " ", line)
56 + if mv:
57 + label += " (maison de ville)"
58 + out[(current, mv)] = (price, label)
59 + current = None
60 + mv = False
61 + return out
62 +
63 + # ------------------------------------------------------------------
64 + def fetch(self) -> list[Listing]:
65 + html = self.get(LIST_URL).text
66 + soup = BeautifulSoup(html, "html.parser")
67 + prices = self._price_map(soup.get_text("\n", strip=True))
68 +
69 + # Cartes de modèles : image plan + titre h3 + bouton "Plus d'infos"
70 + cards: list[tuple[str, str, str]] = [] # (titre, url, img)
71 + for a in soup.select('a[title="Lien vers le modèle"][href]'):
72 + href = a["href"].strip()
73 + if href.startswith("http:"):
74 + href = "https:" + href[5:]
75 + wrapper = a.find_parent("div", class_="wpb_wrapper")
76 + title = img = ""
77 + if wrapper:
78 + h3 = wrapper.select_one("h3")
79 + if h3:
80 + title = h3.get_text(" ", strip=True)
81 + im = wrapper.select_one("img[src]")
82 + if im:
83 + img = im["src"]
84 + if title and (title, href, img) not in cards:
85 + cards.append((title, href, img))
86 +
87 + listings: list[Listing] = []
88 + for title, url, thumb in cards:
89 + if not re.search(r"\(type\s", title, re.I):
90 + continue # carte non-modèle (ex. bouton de contact)
91 + try:
92 + mv = "maison de ville" in title.lower()
93 + cat = None
94 + if re.search(r"studio", title, re.I):
95 + cat = "studio"
96 + elif re.search(r"loft", title, re.I):
97 + cat = "loft"
98 + else:
99 + d = re.search(r"(\d)\s*½", title)
100 + if d:
101 + cat = d.group(1)
102 + price, price_label = prices.get((cat, mv), (None, ""))
103 + if price is None and cat and not mv:
104 + # tolérance si la carte MV/condo ne matche pas exactement
105 + price, price_label = prices.get((cat, True), (None, ""))
106 +
107 + unit_type = normalize_unit_type(title)
108 + if cat == "studio":
109 + unit_type = "Studio"
110 + elif cat == "loft":
111 + unit_type = "Loft"
112 +
113 + # Fiche du modèle : description, caractéristiques, plan
114 + description = ""
115 + amenities: list[str] = []
116 + images: list[str] = []
117 + try:
118 + dhtml = self.get(url).text
119 + dsoup = BeautifulSoup(dhtml, "html.parser")
120 + dtext = dsoup.get_text("\n", strip=True)
121 + # premier paragraphe descriptif après le titre
122 + pm = re.search(r"Cette unité[^\n]+|Ce (?:condo|loft|modèle)"
123 + r"[^\n]+", dtext)
124 + if pm:
125 + description = pm.group(0)[:600]
126 + for pat in (r"Nombre de chambres\s*:\s*\d+",
127 + r"Nombre de salles? de bain\s*:\s*\d+",
128 + r"Superficie\s*:\s*[\d\s]+pi²"):
129 + m = re.search(pat, dtext)
130 + if m:
131 + amenities.append(re.sub(r"\s+", " ", m.group(0)))
132 + for extra in ("Climatisation incluse", "Internet inclus",
133 + "Eau chaude : Incluse",
134 + "Électricité: Incluse",
135 + "Électroménagers: Inclus"):
136 + if extra.lower() in dtext.lower():
137 + amenities.append(extra.replace(": ", " ")
138 + .replace(":", ""))
139 + imgs = re.findall(
140 + rf'src="({re.escape(BASE)}/wp-content/uploads/[^"]+'
141 + rf'\.(?:jpg|jpeg|png|webp))"', dhtml, re.I)
142 + images = [u for u in dict.fromkeys(imgs)
143 + if not _SKIP_IMG.search(u)][:10]
144 + except Exception:
145 + pass
146 + if thumb and thumb not in images and not _SKIP_IMG.search(thumb):
147 + # version pleine grandeur de la vignette
148 + full = re.sub(r"-\d+x\d+(\.(?:jpg|jpeg|png|webp))$",
149 + r"\1", thumb)
150 + images.insert(0, full)
151 + images = list(dict.fromkeys(images))
152 +
153 + ext_id = url.rstrip("/").split("/")[-1]
154 + listings.append(Listing(
155 + source=self.source_id,
156 + external_id=ext_id,
157 + url=url,
158 + title=f"Le Viridi — {title}",
159 + address="Écoquartier Pointe-aux-Lièvres, Québec",
160 + sector=SECTOR,
161 + city=CITY,
162 + unit_type=unit_type,
163 + price=price,
164 + price_label=price_label,
165 + availability="",
166 + description=description,
167 + amenities=amenities,
168 + images=images,
169 + ))
170 + except Exception:
171 + continue
172 +
173 + return listings
added louka/connectors/werkliv.py +179 −0
@@ -0,0 +1,179 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/werkliv.py : connecteur Werkliv (logement étudiant)
5 +# werkliv.com est le site corporatif ; la location de ses immeubles passe
6 +# par sa plateforme University Apartments (universityapartments.ca —
7 +# WordPress + FacetWP rendu serveur). Immeubles montréalais : Palay
8 +# (2025 rue Peel, centre-ville) et Le Mojave (3476 rue Saint-Dominique,
9 +# Plateau/Milton-Parc). Une annonce par typologie (1-BEDROOM, 4-BEDROOM...),
10 +# loyer par personne (colocation étudiante meublée).
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +
16 +from bs4 import BeautifulSoup
17 +
18 +from ..schema import Listing
19 +from .base import BaseConnector
20 +
21 +BASE = "https://universityapartments.ca"
22 +LIST_URL = f"{BASE}/apartment-listings/?_listings_city_en=montreal"
23 +
24 +# Immeuble (nom affiché) -> (slug fiche immeuble, secteur)
25 +BUILDINGS = {
26 + "palay": ("palay", "Centre-ville (Ville-Marie)"),
27 + "le mojave": ("le-mojave", "Milton-Parc / Plateau-Mont-Royal"),
28 + "mojave": ("le-mojave", "Milton-Parc / Plateau-Mont-Royal"),
29 +}
30 +
31 +ADDR_RE = re.compile(
32 + r"\d{2,5}[^<>\"|]{2,60}?(?:Montr[ée]al)[,\s]+QC(?:\s+[A-Z]\d[A-Z]\s?\d[A-Z]\d)?")
33 +
34 +
35 +def _unit_type(label: str) -> str:
36 + """'1-BEDROOM' -> 3½, '4-BEDROOM' -> 6½, 'STUDIO' -> Studio."""
37 + s = (label or "").lower()
38 + if "studio" in s:
39 + return "Studio"
40 + m = re.search(r"(\d+)", s)
41 + if m:
42 + n = int(m.group(1))
43 + return "Studio" if n == 0 else f"{n + 2}½"
44 + return label.strip()
45 +
46 +
47 +class WerklivConnector(BaseConnector):
48 + source_id = "werkliv"
49 + request_delay = 0.6
50 + max_details = 20 # garde-fou
51 +
52 + def fetch(self) -> list[Listing]:
53 + listings: list[Listing] = []
54 + try:
55 + html = self.get(LIST_URL).text
56 + except Exception:
57 + return listings
58 + soup = BeautifulSoup(html, "html.parser")
59 +
60 + addresses: dict[str, str] = {} # slug immeuble -> adresse
61 + seen: set[str] = set()
62 + for card in soup.select(".lcl-card"):
63 + try:
64 + a = card.select_one('a.lcl-link[href*="/listings/"]') or \
65 + card.select_one('a[href*="/listings/"]')
66 + if not a:
67 + continue
68 + url = (a.get("href") or "").split("?")[0]
69 + m = re.search(r"/listings/([^/]+)/?$", url)
70 + if not m or m.group(1) in seen:
71 + continue
72 + slug = m.group(1)
73 + seen.add(slug)
74 +
75 + city_el = card.select_one(".lcl-city")
76 + city_raw = city_el.get_text(" ", strip=True) if city_el else ""
77 + if "montreal" not in city_raw.lower():
78 + continue # hors Montréal (Halifax, PEI...)
79 +
80 + typo_el = card.select_one(".lcl-title .h4, .lcl-title")
81 + typology = typo_el.get_text(" ", strip=True) if typo_el else ""
82 + prop_el = card.select_one(".lcl-property")
83 + building = prop_el.get_text(" ", strip=True) if prop_el else ""
84 + price_el = card.select_one(".lcl-price")
85 + price_label = price_el.get_text(" ", strip=True) \
86 + if price_el else ""
87 + price = None
88 + pm = re.search(r"\$\s*([\d,]+)", price_label)
89 + if pm:
90 + try:
91 + v = float(pm.group(1).replace(",", ""))
92 + price = v if 100 <= v <= 20000 else None
93 + except ValueError:
94 + pass
95 +
96 + avail = ""
97 + av_el = card.select_one(".lcl-available span")
98 + if av_el:
99 + avail = re.sub(r"\s+", " ",
100 + av_el.get_text(" ", strip=True))
101 + amenities = ["Logement étudiant"]
102 + for chip in card.select(".lcl-chip"):
103 + amenities.append(re.sub(r"\s+", " ",
104 + chip.get_text(" ", strip=True)))
105 +
106 + bslug, sector = BUILDINGS.get(building.strip().lower(),
107 + ("", ""))
108 + # adresse depuis la fiche de l'immeuble (mise en cache)
109 + address = ""
110 + if bslug:
111 + if bslug not in addresses:
112 + addresses[bslug] = self._building_address(bslug)
113 + address = addresses[bslug]
114 +
115 + images = []
116 + header = card.select_one("[data-bg]")
117 + if header and header.get("data-bg", "").startswith("http"):
118 + images.append(header["data-bg"])
119 +
120 + listings.append(Listing(
121 + source=self.source_id,
122 + external_id=slug,
123 + url=url,
124 + title=f"{building}{typology} (par chambre)",
125 + address=address,
126 + sector=sector,
127 + city="Montréal",
128 + unit_type=_unit_type(typology),
129 + price=price,
130 + price_label=f"{price_label} (par personne)"
131 + if price_label else "",
132 + availability=avail,
133 + amenities=list(dict.fromkeys(amenities)),
134 + images=images,
135 + ))
136 + except Exception:
137 + continue
138 +
139 + # Fiches détaillées : toutes les photos + description
140 + for i, lst in enumerate(listings):
141 + if i >= self.max_details:
142 + break
143 + try:
144 + self._enrich(lst)
145 + except Exception:
146 + continue
147 + return listings
148 +
149 + # -- fiche immeuble (adresse) --------------------------------------------------
150 + def _building_address(self, slug: str) -> str:
151 + try:
152 + html = self.get(f"{BASE}/properties/{slug}/").text
153 + except Exception:
154 + return ""
155 + m = ADDR_RE.search(html.replace("+", " "))
156 + if not m:
157 + return ""
158 + addr = re.sub(r"\s+", " ", m.group(0)).strip()
159 + return addr.replace("Montreal", "Montréal")
160 +
161 + # -- fiche annonce ---------------------------------------------------------------
162 + def _enrich(self, lst: Listing) -> None:
163 + html = self.get(lst.url).text
164 + imgs = re.findall(
165 + r'https://universityapartments\.ca/wp-content/uploads/'
166 + r'[^"\s\\\)]+\.(?:jpg|jpeg|png|webp)', html)
167 + imgs = [u for u in dict.fromkeys(imgs)
168 + if not re.search(r"logo|icon|favicon|chrome|-\d{2,3}x\d{2,3}\.",
169 + u, re.I)]
170 + if imgs:
171 + lst.images = list(dict.fromkeys(lst.images + imgs))[:40]
172 + soup = BeautifulSoup(html, "html.parser")
173 + og = soup.find("meta", attrs={"property": "og:description"})
174 + if og and og.get("content"):
175 + lst.description = og["content"].strip()[:600]
176 + else:
177 + p = soup.select_one(".fl-rich-text p, article p")
178 + if p:
179 + lst.description = p.get_text(" ", strip=True)[:600]
added louka/db.py +148 −0
@@ -0,0 +1,148 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# db.py : persistance SQLite, upsert avec détection de changements,
5 +# désactivation automatique des annonces disparues (contenu toujours à jour)
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import json
10 +import sqlite3
11 +import time
12 +from pathlib import Path
13 +
14 +from .schema import Listing
15 +
16 +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "louka.db"
17 +
18 +_SCHEMA = """
19 +CREATE TABLE IF NOT EXISTS listings (
20 + uid TEXT PRIMARY KEY,
21 + source TEXT NOT NULL,
22 + external_id TEXT NOT NULL,
23 + url TEXT,
24 + title TEXT,
25 + address TEXT,
26 + sector TEXT,
27 + city TEXT,
28 + unit_type TEXT,
29 + price REAL,
30 + price_label TEXT,
31 + availability TEXT,
32 + description TEXT,
33 + amenities TEXT, -- JSON
34 + images TEXT, -- JSON
35 + lat REAL,
36 + lng REAL,
37 + content_hash TEXT,
38 + first_seen REAL,
39 + last_seen REAL,
40 + updated_at REAL,
41 + active INTEGER DEFAULT 1
42 +);
43 +CREATE INDEX IF NOT EXISTS idx_listings_source ON listings(source);
44 +CREATE INDEX IF NOT EXISTS idx_listings_city ON listings(city);
45 +CREATE INDEX IF NOT EXISTS idx_listings_active ON listings(active);
46 +
47 +CREATE TABLE IF NOT EXISTS sync_log (
48 + id INTEGER PRIMARY KEY AUTOINCREMENT,
49 + source TEXT,
50 + ts REAL,
51 + found INTEGER,
52 + added INTEGER,
53 + updated INTEGER,
54 + removed INTEGER,
55 + ok INTEGER,
56 + message TEXT
57 +);
58 +"""
59 +
60 +
61 +def connect() -> sqlite3.Connection:
62 + DB_PATH.parent.mkdir(parents=True, exist_ok=True)
63 + con = sqlite3.connect(DB_PATH)
64 + con.row_factory = sqlite3.Row
65 + con.executescript(_SCHEMA)
66 + return con
67 +
68 +
69 +def sync_source(con: sqlite3.Connection, source: str,
70 + listings: list[Listing]) -> dict:
71 + """Synchronise les annonces d'une source.
72 +
73 + - nouvelle annonce -> insertion
74 + - annonce modifiée -> mise à jour (comparaison de content_hash)
75 + - annonce disparue -> active=0 (elle n'est plus affichée)
76 + C'est l'équivalent « webhook » : chaque exécution détecte les changements.
77 + """
78 + now = time.time()
79 + added = updated = 0
80 + seen_uids = set()
81 +
82 + for lst in listings:
83 + seen_uids.add(lst.uid)
84 + h = lst.content_hash()
85 + row = con.execute("SELECT content_hash FROM listings WHERE uid=?",
86 + (lst.uid,)).fetchone()
87 + params = dict(
88 + uid=lst.uid, source=lst.source, external_id=lst.external_id,
89 + url=lst.url, title=lst.title, address=lst.address,
90 + sector=lst.sector, city=lst.city, unit_type=lst.unit_type,
91 + price=lst.price, price_label=lst.price_label,
92 + availability=lst.availability, description=lst.description,
93 + amenities=json.dumps(lst.amenities, ensure_ascii=False),
94 + images=json.dumps(lst.images, ensure_ascii=False),
95 + lat=lst.lat, lng=lst.lng, content_hash=h, now=now,
96 + )
97 + if row is None:
98 + con.execute(
99 + """INSERT INTO listings (uid, source, external_id, url, title,
100 + address, sector, city, unit_type, price, price_label,
101 + availability, description, amenities, images, lat, lng,
102 + content_hash, first_seen, last_seen, updated_at, active)
103 + VALUES (:uid,:source,:external_id,:url,:title,:address,
104 + :sector,:city,:unit_type,:price,:price_label,:availability,
105 + :description,:amenities,:images,:lat,:lng,:content_hash,
106 + :now,:now,:now,1)""", params)
107 + added += 1
108 + else:
109 + if row["content_hash"] != h:
110 + con.execute(
111 + """UPDATE listings SET url=:url, title=:title,
112 + address=:address, sector=:sector, city=:city,
113 + unit_type=:unit_type, price=:price,
114 + price_label=:price_label, availability=:availability,
115 + description=:description, amenities=:amenities,
116 + images=:images, lat=:lat, lng=:lng,
117 + content_hash=:content_hash, last_seen=:now,
118 + updated_at=:now, active=1 WHERE uid=:uid""", params)
119 + updated += 1
120 + else:
121 + con.execute("UPDATE listings SET last_seen=?, active=1 WHERE uid=?",
122 + (now, lst.uid))
123 +
124 + # Annonces de cette source qui n'apparaissent plus -> inactives
125 + removed = 0
126 + if seen_uids:
127 + rows = con.execute(
128 + "SELECT uid FROM listings WHERE source=? AND active=1", (source,))
129 + for r in rows.fetchall():
130 + if r["uid"] not in seen_uids:
131 + con.execute("UPDATE listings SET active=0, updated_at=? WHERE uid=?",
132 + (now, r["uid"]))
133 + removed += 1
134 +
135 + con.execute(
136 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"
137 + " VALUES (?,?,?,?,?,?,1,?)",
138 + (source, now, len(listings), added, updated, removed, "ok"))
139 + con.commit()
140 + return {"source": source, "found": len(listings), "added": added,
141 + "updated": updated, "removed": removed}
142 +
143 +
144 +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
145 + con.execute(
146 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"
147 + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message))
148 + con.commit()
added louka/ingest.py +52 −0
@@ -0,0 +1,52 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# ingest.py : pipeline d'ingestion — exécute les connecteurs et synchronise
5 +# la base (ajouts / mises à jour / retraits) = contenu toujours à jour
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import sys
10 +import time
11 +import traceback
12 +
13 +from . import db
14 +from .connectors import CONNECTORS
15 +
16 +
17 +def run(sources: list[str] | None = None) -> list[dict]:
18 + """Exécute l'ingestion pour toutes les sources (ou celles demandées)."""
19 + con = db.connect()
20 + results = []
21 + targets = sources or list(CONNECTORS.keys())
22 + for sid in targets:
23 + cls = CONNECTORS.get(sid)
24 + if cls is None:
25 + print(f"[lou-ka] connecteur inconnu : {sid}", file=sys.stderr)
26 + continue
27 + t0 = time.time()
28 + print(f"[lou-ka] sync {sid} ...")
29 + try:
30 + listings = cls().fetch()
31 + stats = db.sync_source(con, sid, listings)
32 + stats["seconds"] = round(time.time() - t0, 1)
33 + print(f"[lou-ka] {stats}")
34 + results.append(stats)
35 + except Exception as exc: # robustesse : une source ne bloque pas les autres
36 + db.log_failure(con, sid, f"{exc}")
37 + traceback.print_exc()
38 + results.append({"source": sid, "error": str(exc)})
39 + con.close()
40 + return results
41 +
42 +
43 +def watch(interval_seconds: int = 3600) -> None:
44 + """Boucle de rafraîchissement périodique (pseudo-webhook par sondage)."""
45 + while True:
46 + run()
47 + print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s")
48 + time.sleep(interval_seconds)
49 +
50 +
51 +if __name__ == "__main__":
52 + run(sys.argv[1:] or None)
added louka/schema.py +113 −0
@@ -0,0 +1,113 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# schema.py : modèle de données standardisé + fonctions de normalisation
5 +# -----------------------------------------------------------------------------
6 +"""Schéma standard d'une annonce (Listing) et normalisation des champs.
7 +
8 +Chaque connecteur, peu importe le site source, doit produire des objets
9 +`Listing` conformes à ce schéma. C'est la couche de standardisation qui
10 +permet d'agréger des sites hétérogènes.
11 +"""
12 +from __future__ import annotations
13 +
14 +import hashlib
15 +import json
16 +import re
17 +import unicodedata
18 +from dataclasses import dataclass, field, asdict
19 +
20 +
21 +@dataclass
22 +class Listing:
23 + """Annonce standardisée Lou-Ka."""
24 +
25 + source: str # id de la source (voir data/sources.json)
26 + external_id: str # identifiant chez la source
27 + url: str # page de l'annonce chez la source
28 + title: str = "" # ex. "555, avenue du Fleuve"
29 + address: str = "" # adresse civique
30 + sector: str = "" # quartier/arrondissement (ex. Beauport)
31 + city: str = "" # Québec, Lévis, ...
32 + unit_type: str = "" # 1½, 2½, 3½, 4½, 5½, Loft, Studio...
33 + price: float | None = None # loyer mensuel ($ CAD), le plus bas si "à partir de"
34 + price_label: str = "" # texte original (ex. "à partir de 799$")
35 + availability: str = "" # ex. "Libre immédiatement", "juillet 2026"
36 + description: str = ""
37 + amenities: list[str] = field(default_factory=list)
38 + images: list[str] = field(default_factory=list) # URLs absolues
39 + lat: float | None = None
40 + lng: float | None = None
41 +
42 + @property
43 + def uid(self) -> str:
44 + return f"{self.source}:{self.external_id}"
45 +
46 + def content_hash(self) -> str:
47 + """Hash du contenu pour la détection de changements (pseudo-webhook)."""
48 + payload = asdict(self)
49 + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
50 + return hashlib.sha256(blob.encode("utf-8")).hexdigest()
51 +
52 +
53 +# ---------------------------------------------------------------------------
54 +# Normalisation
55 +# ---------------------------------------------------------------------------
56 +
57 +_TYPE_MAP = {
58 + "1 1/2": "1½", "2 1/2": "2½", "3 1/2": "3½", "4 1/2": "4½",
59 + "5 1/2": "5½", "6 1/2": "6½", "studio": "Studio", "loft": "Loft",
60 + "chambre": "Chambre", "maison": "Maison",
61 +}
62 +
63 +
64 +def normalize_unit_type(raw: str) -> str:
65 + """Standardise le type d'unité : '4 1/2', '4½', '4 ½' -> '4½'."""
66 + if not raw:
67 + return ""
68 + s = raw.strip().lower()
69 + s = s.replace(" ", " ")
70 + m = re.search(r"(\d)\s*(?:½|1/2)", s)
71 + if m:
72 + return f"{m.group(1)}½"
73 + for k, v in _TYPE_MAP.items():
74 + if k in s:
75 + return v
76 + return raw.strip()
77 +
78 +
79 +def parse_price(raw: str) -> float | None:
80 + """Extrait un montant mensuel d'un texte : 'à partir de 1 250,00$' -> 1250.0."""
81 + if not raw:
82 + return None
83 + s = raw.replace(" ", " ").replace(" ", " ")
84 + m = re.search(r"(\d[\d\s]*(?:[.,]\d{2})?)\s*\$", s)
85 + if not m:
86 + return None
87 + num = m.group(1).replace(" ", "").replace(",", ".")
88 + try:
89 + val = float(num)
90 + except ValueError:
91 + return None
92 + return val if 100 <= val <= 20000 else None
93 +
94 +
95 +def strip_accents(s: str) -> str:
96 + return "".join(c for c in unicodedata.normalize("NFD", s)
97 + if unicodedata.category(c) != "Mn")
98 +
99 +
100 +# Secteur -> ville (agglomération Québec / Lévis)
101 +_LEVIS_SECTORS = {
102 + "levis", "saint-romuald", "st-romuald", "charny", "saint-nicolas",
103 + "st-nicolas", "saint-david", "st-david", "desjardins", "saint-redempteur",
104 + "st-redempteur", "breakeyville", "saint-jean-chrysostome", "st-jean-chrysostome",
105 + "pintendre", "saint-etienne", "vieux-levis",
106 +}
107 +
108 +
109 +def infer_city(sector: str, default: str = "Québec") -> str:
110 + key = strip_accents((sector or "").strip().lower())
111 + if key in _LEVIS_SECTORS or "levis" in key:
112 + return "Lévis"
113 + return default
added louka/web.py +158 −0
@@ -0,0 +1,158 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# web.py : API FastAPI (JSON) + service du frontend React (frontend/dist)
5 +# -----------------------------------------------------------------------------
6 +from __future__ import annotations
7 +
8 +import json
9 +import threading
10 +from pathlib import Path
11 +
12 +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
13 +from fastapi.middleware.cors import CORSMiddleware
14 +from fastapi.responses import FileResponse
15 +from fastapi.staticfiles import StaticFiles
16 +
17 +from . import db, ingest
18 +
19 +ROOT = Path(__file__).resolve().parent.parent
20 +SOURCES_PATH = ROOT / "data" / "sources.json"
21 +FRONTEND_DIST = ROOT / "frontend" / "dist"
22 +
23 +app = FastAPI(title="Lou-Ka API", version="1.0",
24 + description="Agrégateur de logements à louer — province de Québec")
25 +app.add_middleware(CORSMiddleware, allow_origins=["*"],
26 + allow_methods=["*"], allow_headers=["*"])
27 +
28 +_sync_lock = threading.Lock()
29 +
30 +
31 +def _row_to_dict(row) -> dict:
32 + d = dict(row)
33 + d["amenities"] = json.loads(d.get("amenities") or "[]")
34 + d["images"] = json.loads(d.get("images") or "[]")
35 + return d
36 +
37 +
38 +@app.get("/api/listings")
39 +def list_listings(
40 + city: str | None = None,
41 + sector: str | None = None,
42 + unit_type: str | None = None,
43 + source: str | None = None,
44 + price_max: float | None = None,
45 + price_min: float | None = None,
46 + q: str | None = None,
47 + active: int = 1,
48 + limit: int = Query(500, le=2000),
49 + offset: int = 0,
50 +):
51 + con = db.connect()
52 + sql = "SELECT * FROM listings WHERE 1=1"
53 + args: list = []
54 + if active in (0, 1):
55 + sql += " AND active=?"; args.append(active)
56 + if city:
57 + sql += " AND city=?"; args.append(city)
58 + if sector:
59 + sql += " AND sector LIKE ?"; args.append(f"%{sector}%")
60 + if unit_type:
61 + sql += " AND unit_type=?"; args.append(unit_type)
62 + if source:
63 + sql += " AND source=?"; args.append(source)
64 + if price_max is not None:
65 + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)
66 + if price_min is not None:
67 + sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)
68 + if q:
69 + sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"
70 + args += [f"%{q}%"] * 3
71 + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
72 + sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"
73 + args += [limit, offset]
74 + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]
75 + con.close()
76 + return {"total": total, "count": len(rows), "listings": rows}
77 +
78 +
79 +@app.get("/api/listings/{uid}")
80 +def get_listing(uid: str):
81 + con = db.connect()
82 + row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
83 + con.close()
84 + if row is None:
85 + raise HTTPException(404, "Annonce introuvable")
86 + return _row_to_dict(row)
87 +
88 +
89 +@app.get("/api/facets")
90 +def facets():
91 + """Valeurs distinctes pour construire les filtres du frontend."""
92 + con = db.connect()
93 + out = {
94 + "cities": [r["city"] for r in con.execute(
95 + "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],
96 + "sectors": [r["sector"] for r in con.execute(
97 + "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>'' ORDER BY sector")],
98 + "unit_types": [r["unit_type"] for r in con.execute(
99 + "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>'' ORDER BY unit_type")],
100 + "sources": [dict(r) for r in con.execute(
101 + "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source ORDER BY n DESC")],
102 + }
103 + con.close()
104 + return out
105 +
106 +
107 +@app.get("/api/sources")
108 +def sources():
109 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
110 + con = db.connect()
111 + counts = {r["source"]: r["n"] for r in con.execute(
112 + "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source")}
113 + last = {r["source"]: r["ts"] for r in con.execute(
114 + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}
115 + con.close()
116 + for s in registry:
117 + s["active_listings"] = counts.get(s["id"], 0)
118 + s["last_sync"] = last.get(s["id"])
119 + return {"sources": registry}
120 +
121 +
122 +@app.get("/api/stats")
123 +def stats():
124 + con = db.connect()
125 + row = con.execute(
126 + """SELECT COUNT(*) total,
127 + SUM(CASE WHEN city='Québec' THEN 1 ELSE 0 END) quebec,
128 + SUM(CASE WHEN city='Lévis' THEN 1 ELSE 0 END) levis,
129 + SUM(CASE WHEN city NOT IN ('Québec','Lévis') THEN 1 ELSE 0 END) montreal,
130 + COUNT(DISTINCT source) sources,
131 + AVG(price) avg_price
132 + FROM listings WHERE active=1""").fetchone()
133 + log = [dict(r) for r in con.execute(
134 + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
135 + con.close()
136 + return {**dict(row), "recent_syncs": log}
137 +
138 +
139 +@app.post("/api/sync")
140 +def trigger_sync(background: BackgroundTasks, source: str | None = None):
141 + """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
142 + def _job():
143 + with _sync_lock:
144 + ingest.run([source] if source else None)
145 + background.add_task(_job)
146 + return {"status": "démarré", "source": source or "toutes"}
147 +
148 +
149 +# --- Frontend React (build Vite) --------------------------------------------
150 +if FRONTEND_DIST.exists():
151 + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
152 +
153 + @app.get("/{full_path:path}")
154 + def spa(full_path: str):
155 + target = FRONTEND_DIST / full_path
156 + if full_path and target.is_file():
157 + return FileResponse(target)
158 + return FileResponse(FRONTEND_DIST / "index.html")
added requirements.txt +6 −0
@@ -0,0 +1,6 @@
1 +# Lou-Ka — dépendances backend
2 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +fastapi>=0.110
4 +uvicorn>=0.29
5 +requests>=2.31
6 +beautifulsoup4>=4.12
added run.py +47 −0
@@ -0,0 +1,47 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# run.py : point d'entrée — `sync`, `watch`, `serve`
6 +# -----------------------------------------------------------------------------
7 +"""Utilisation :
8 + python run.py sync [source ...] # synchronise les annonces
9 + python run.py watch [minutes] # synchronise en boucle (défaut 60 min)
10 + python run.py serve [port] # démarre l'API + le frontend (défaut 8080)
11 +"""
12 +from __future__ import annotations
13 +
14 +import os
15 +import sys
16 +from pathlib import Path
17 +
18 +# Charger .env (FIRECRAWL_API_KEY, etc.) sans dépendance externe
19 +_env = Path(__file__).parent / ".env"
20 +if _env.exists():
21 + for line in _env.read_text().splitlines():
22 + line = line.strip()
23 + if line and not line.startswith("#") and "=" in line:
24 + k, _, v = line.partition("=")
25 + os.environ.setdefault(k.strip(), v.strip())
26 +
27 +
28 +def main() -> None:
29 + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
30 + if cmd == "sync":
31 + from louka import ingest
32 + ingest.run(sys.argv[2:] or None)
33 + elif cmd == "watch":
34 + from louka import ingest
35 + minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 60
36 + ingest.watch(minutes * 60)
37 + elif cmd == "serve":
38 + import uvicorn
39 + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
40 + uvicorn.run("louka.web:app", host="0.0.0.0", port=port)
41 + else:
42 + print(__doc__)
43 + sys.exit(1)
44 +
45 +
46 +if __name__ == "__main__":
47 + main()
48