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>
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 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 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=§or=&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 | +[](mailto:contact@spboucher.ai) | |
| 172 | +[](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 "")) # &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("–", "–").replace("&", "&") | |
| 92 | + .replace("’", "'").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"N°\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;...).""" | |
| 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("&", "&")).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("’", "’").replace(" ", " ") | |
| 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 | ||