Portails & courtiers en location — 10 connecteurs portés d'Immo-Ka (Kijiji, LesPAC, RE/MAX, Sutton, Royal LePage, Via Capitale, Proprio Direct, Ubee, Barnes, M Immobilier)
- base.py : backends Scrapfly (scrapfly/get_scrapfly) + post() portés d'Immo-Ka - _detailutil.py : helper partagé d'enrichissement fiche détail (cache BD, budget par sync) adapté au schéma Listing - Kijiji c37 apparts/condos + c36 chambres/coloc (Apollo __NEXT_DATA__, QC l9001) - LesPAC b457 logements + b458 coloc + b460 aînés (searchResponse, ancre Montréal = province entière triée par distance) - RE/MAX Québec : index Meilisearch public shardé par RTA, slugs -a-louer seulement - Sutton : sitemap inscriptions typeInscription=2 (= locations) - Royal LePage : recherche transactionType=LEASE (formulaire caché), 28 villes d'ancrage - Via Capitale : criteresJson AVendre:false via Firecrawl (Cloudflare) - Proprio Direct : API searchListings filter rentOrSale:rent, volet résidentiel - Ubee : API SearchProperties listingType Renter (askPrice = loyer mensuel) - Barnes : Algolia facette type:rental (property_rent) ; M Immobilier : CODE_STATUT=AL - sources.json : 255 → 265 (10 nouvelles sources « Province »/Montréal) - Écartés après vérification live (pas de location résidentielle) : DuProprio, Profusion, Engel & Völkers, KW Prestige/Distinction/Urbain, Expert PM, Sotheby's Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13 changed files +2,759 −1
modified
data/sources.json
+100 −0
@@ -2548,6 +2548,106 @@ | ||
| 2548 | 2548 | "connector": null, |
| 2549 | 2549 | "status": "non connectable — archive Opal Estate (WP 5.5) de 167 fiches figées : prix uniquement en fiche détail et périmés (« 3 1/2 … 550.00$/mois », « LIBRE AU 1ER JUILLET 2021 »), aucune date de disponibilité maintenue, étiquette « trop tard déjà loué » posée sur une poignée de fiches seulement — inventaire non entretenu depuis ~2021", |
| 2550 | 2550 | "region": "Estrie" |
| 2551 | + }, | |
| 2552 | + { | |
| 2553 | + "id": "kijiji", | |
| 2554 | + "name": "Kijiji Québec (location)", | |
| 2555 | + "url": "https://www.kijiji.ca", | |
| 2556 | + "listing_url": "https://www.kijiji.ca/b-appartement-condo/quebec/c37l9001", | |
| 2557 | + "sectors": "Province entière — appartements/condos (c37) + chambres & colocation (c36), particuliers et gestionnaires", | |
| 2558 | + "connector": "kijiji", | |
| 2559 | + "status": "actif", | |
| 2560 | + "region": "Province" | |
| 2561 | + }, | |
| 2562 | + { | |
| 2563 | + "id": "lespac", | |
| 2564 | + "name": "LesPAC (location)", | |
| 2565 | + "url": "https://www.lespac.com", | |
| 2566 | + "listing_url": "https://www.lespac.com/montreal/immobilier-location-logements_b457g17567k1R2.jsa", | |
| 2567 | + "sectors": "Province entière — logements (b457), chambres & colocation (b458), résidences pour aînés (b460)", | |
| 2568 | + "connector": "lespac", | |
| 2569 | + "status": "actif", | |
| 2570 | + "region": "Province" | |
| 2571 | + }, | |
| 2572 | + { | |
| 2573 | + "id": "via_capitale", | |
| 2574 | + "name": "Via Capitale (location)", | |
| 2575 | + "url": "https://www.viacapitalevendu.com", | |
| 2576 | + "listing_url": "https://www.viacapitalevendu.com/recherche/residentiel/", | |
| 2577 | + "sectors": "Province entière — inscriptions résidentielles à louer de la bannière (criteresJson AVendre:false, via Firecrawl)", | |
| 2578 | + "connector": "via_capitale", | |
| 2579 | + "status": "actif", | |
| 2580 | + "region": "Province" | |
| 2581 | + }, | |
| 2582 | + { | |
| 2583 | + "id": "royal_lepage", | |
| 2584 | + "name": "Royal LePage (location)", | |
| 2585 | + "url": "https://www.royallepage.ca", | |
| 2586 | + "listing_url": "https://www.royallepage.ca/fr/qc/rentals/", | |
| 2587 | + "sectors": "Province entière — recherche transactionType=LEASE shardée par 28 villes d'ancrage", | |
| 2588 | + "connector": "royal_lepage", | |
| 2589 | + "status": "actif", | |
| 2590 | + "region": "Province" | |
| 2591 | + }, | |
| 2592 | + { | |
| 2593 | + "id": "proprio_direct", | |
| 2594 | + "name": "Proprio Direct (location)", | |
| 2595 | + "url": "https://propriodirect.com", | |
| 2596 | + "listing_url": "https://propriodirect.com/resultats/?rentOrSale=rent", | |
| 2597 | + "sectors": "Province entière — API searchListings filter rentOrSale:rent, volet résidentiel", | |
| 2598 | + "connector": "proprio_direct", | |
| 2599 | + "status": "actif", | |
| 2600 | + "region": "Province" | |
| 2601 | + }, | |
| 2602 | + { | |
| 2603 | + "id": "ubee", | |
| 2604 | + "name": "Ubee (location)", | |
| 2605 | + "url": "https://ubee.com", | |
| 2606 | + "listing_url": "https://ubee.com/a-louer", | |
| 2607 | + "sectors": "Province entière — API SearchProperties listingType Renter (résidentiel)", | |
| 2608 | + "connector": "ubee", | |
| 2609 | + "status": "actif", | |
| 2610 | + "region": "Province" | |
| 2611 | + }, | |
| 2612 | + { | |
| 2613 | + "id": "barnes_quebec", | |
| 2614 | + "name": "BARNES Québec (location)", | |
| 2615 | + "url": "https://barnes-quebec.com", | |
| 2616 | + "listing_url": "https://barnes-quebec.com/louer/", | |
| 2617 | + "sectors": "Montréal et environs — locations haut de gamme (index Algolia, facette type:rental)", | |
| 2618 | + "connector": "barnes_quebec", | |
| 2619 | + "status": "actif", | |
| 2620 | + "region": "Montréal" | |
| 2621 | + }, | |
| 2622 | + { | |
| 2623 | + "id": "m_immobilier", | |
| 2624 | + "name": "M Immobilier (location)", | |
| 2625 | + "url": "https://www.mimmobilier.com", | |
| 2626 | + "listing_url": "https://www.mimmobilier.com/properties?CODE_STATUT=AL", | |
| 2627 | + "sectors": "Grand Montréal — filtre serveur CODE_STATUT=AL, volet résidentiel", | |
| 2628 | + "connector": "m_immobilier", | |
| 2629 | + "status": "actif", | |
| 2630 | + "region": "Montréal" | |
| 2631 | + }, | |
| 2632 | + { | |
| 2633 | + "id": "remax_quebec", | |
| 2634 | + "name": "RE/MAX Québec (location)", | |
| 2635 | + "url": "https://www.remax-quebec.com", | |
| 2636 | + "listing_url": "https://www.remax-quebec.com/fr/proprietes", | |
| 2637 | + "sectors": "Province entière — index Meilisearch public shardé par RTA (G/H/J), slugs « -a-louer » seulement", | |
| 2638 | + "connector": "remax_quebec", | |
| 2639 | + "status": "actif", | |
| 2640 | + "region": "Province" | |
| 2641 | + }, | |
| 2642 | + { | |
| 2643 | + "id": "sutton", | |
| 2644 | + "name": "Groupe Sutton Québec (location)", | |
| 2645 | + "url": "https://www.suttonquebec.com", | |
| 2646 | + "listing_url": "https://www.suttonquebec.com/fr/proprietes-a-louer.html", | |
| 2647 | + "sectors": "Province entière — sitemap inscriptions, typeInscription=2 (locations)", | |
| 2648 | + "connector": "sutton", | |
| 2649 | + "status": "actif", | |
| 2650 | + "region": "Province" | |
| 2551 | 2651 | } |
| 2552 | 2652 | ] |
| 2553 | 2653 | } |
| \ No newline at end of file | ||
added
louka/connectors/_detailutil.py
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/_detailutil.py : utilitaires partagés d'enrichissement « page détail » | |
| 5 | +# Mutualise ce que les connecteurs de portails répètent pour capter TOUTES les | |
| 6 | +# infos de la fiche source : description JSON-LD, coordonnées, application | |
| 7 | +# au Listing avec cache BD et budget de requêtes par synchronisation. | |
| 8 | +# ----------------------------------------------------------------------------- | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import html as _html | |
| 12 | +import json | |
| 13 | +import re | |
| 14 | + | |
| 15 | +from ..schema import Listing | |
| 16 | + | |
| 17 | +_LD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I) | |
| 18 | +_COORD_RE = re.compile(r'(?:google\.[^"\']*?[?&](?:q|query|ll|center)=|maps/@)' | |
| 19 | + r'(-?\d{1,2}\.\d+)[ ,%+A-Za-z]+?(-?\d{2,3}\.\d+)') | |
| 20 | +_LD_PROP_TYPES = {"RealEstateListing", "Residence", "SingleFamilyResidence", | |
| 21 | + "House", "Apartment", "Product", "Offer", "Place", "Accommodation"} | |
| 22 | + | |
| 23 | + | |
| 24 | +def ld_nodes(html: str): | |
| 25 | + """Itère les objets JSON-LD (aplatis depuis @graph).""" | |
| 26 | + for block in _LD_RE.findall(html): | |
| 27 | + try: | |
| 28 | + data = json.loads(block) | |
| 29 | + except ValueError: | |
| 30 | + continue | |
| 31 | + graph = data.get("@graph", [data]) if isinstance(data, dict) else data | |
| 32 | + for node in (graph if isinstance(graph, list) else [graph]): | |
| 33 | + if isinstance(node, dict): | |
| 34 | + yield node | |
| 35 | + | |
| 36 | + | |
| 37 | +def ld_description(html: str) -> str: | |
| 38 | + """Description depuis un nœud JSON-LD de type propriété (le plus long trouvé).""" | |
| 39 | + best = "" | |
| 40 | + for n in ld_nodes(html): | |
| 41 | + t = n.get("@type") | |
| 42 | + types = t if isinstance(t, list) else [t] | |
| 43 | + if any(x in _LD_PROP_TYPES for x in types) and n.get("description"): | |
| 44 | + d = _html.unescape(str(n["description"])).strip() | |
| 45 | + if len(d) > len(best): | |
| 46 | + best = d | |
| 47 | + return best | |
| 48 | + | |
| 49 | + | |
| 50 | +def gmaps_coords(html: str) -> tuple[float, float] | None: | |
| 51 | + m = _COORD_RE.search(html) | |
| 52 | + if not m: | |
| 53 | + return None | |
| 54 | + try: | |
| 55 | + lat, lng = float(m.group(1)), float(m.group(2)) | |
| 56 | + except ValueError: | |
| 57 | + return None | |
| 58 | + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0: | |
| 59 | + return lat, lng | |
| 60 | + return None | |
| 61 | + | |
| 62 | + | |
| 63 | +def flatten(html: str) -> str: | |
| 64 | + """HTML -> texte « valeur | libellé » pour extraire les tableaux de fiches.""" | |
| 65 | + t = _html.unescape(re.sub(r"<[^>]+>", " | ", html)) | |
| 66 | + t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t) | |
| 67 | + return re.sub(r"[ \t]+", " ", t) | |
| 68 | + | |
| 69 | + | |
| 70 | +# libellés Centris standard (fiches de courtiers) cherchés dans un texte aplati | |
| 71 | +# « valeur | libellé » OU « libellé | valeur » — version location | |
| 72 | +_LABELS = [ | |
| 73 | + "Type de propriété", "Genre de propriété", "Style de bâtiment", | |
| 74 | + "Année de construction", "Superficie habitable", "Superficie du terrain", | |
| 75 | + "Nombre de pièces", "Nombre d'unités", "Stationnement (total)", | |
| 76 | + "Stationnement", "Garage", "Système de chauffage", | |
| 77 | + "Énergie pour le chauffage", "Piscine", "Déménagement", "Date d'emménagement", | |
| 78 | + "Disponibilité", "Bail", "Durée du bail", "Meublé", "Animaux", | |
| 79 | + "Inclus dans le loyer", "Cuisine", | |
| 80 | +] | |
| 81 | + | |
| 82 | + | |
| 83 | +def centris_details(text: str) -> dict: | |
| 84 | + """Extrait les caractéristiques Centris d'un texte aplati (les deux ordres).""" | |
| 85 | + out: dict = {} | |
| 86 | + for label in _LABELS: | |
| 87 | + lab = re.escape(label) | |
| 88 | + m = (re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text) | |
| 89 | + or re.search(lab + r"\b\s*\|\s*([^|]{1,55})", text)) | |
| 90 | + if m: | |
| 91 | + val = m.group(1).strip(" |") | |
| 92 | + if val and 1 <= len(val) <= 55 and val.lower() != label.lower(): | |
| 93 | + out[label] = val | |
| 94 | + return out | |
| 95 | + | |
| 96 | + | |
| 97 | +def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None): | |
| 98 | + """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`. | |
| 99 | + | |
| 100 | + - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail. | |
| 101 | + - `key` : versionne le cache (changer pour forcer un rafraîchissement). | |
| 102 | + - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer | |
| 103 | + connector.get_rendered / get_scrapfly pour les sites derrière anti-bot. | |
| 104 | + Le budget `limit` fait que chaque sync n'enrichit qu'un quota de fiches ; | |
| 105 | + le parc se complète sur plusieurs cycles (le cache est permanent tant que | |
| 106 | + la clé ne change pas). | |
| 107 | + """ | |
| 108 | + if limit <= 0: | |
| 109 | + return | |
| 110 | + from .. import db | |
| 111 | + fetch_html = fetch_html or (lambda u: connector.get(u).text) | |
| 112 | + con = db.connect() | |
| 113 | + budget = limit | |
| 114 | + try: | |
| 115 | + for lst in listings: | |
| 116 | + cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key) | |
| 117 | + if cached is None: | |
| 118 | + if budget <= 0: | |
| 119 | + continue | |
| 120 | + try: | |
| 121 | + cached = parse_fn(fetch_html(lst.url)) | |
| 122 | + except Exception: | |
| 123 | + cached = {} | |
| 124 | + db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached) | |
| 125 | + budget -= 1 | |
| 126 | + apply_detail(lst, cached) | |
| 127 | + finally: | |
| 128 | + con.close() | |
| 129 | + | |
| 130 | + | |
| 131 | +def apply_detail(lst: Listing, d: dict) -> None: | |
| 132 | + """Applique un payload détail au Listing sans écraser les valeurs déjà | |
| 133 | + présentes (sauf images : on garde la plus grande galerie).""" | |
| 134 | + if not d: | |
| 135 | + return | |
| 136 | + imgs = d.get("images") | |
| 137 | + if imgs and len(imgs) > len(lst.images): | |
| 138 | + lst.images = imgs | |
| 139 | + if d.get("amenities"): | |
| 140 | + seen = {a.lower() for a in lst.amenities} | |
| 141 | + for a in d["amenities"]: | |
| 142 | + if a.lower() not in seen: | |
| 143 | + lst.amenities.append(a) | |
| 144 | + seen.add(a.lower()) | |
| 145 | + if d.get("details"): | |
| 146 | + merged = dict(d["details"]) | |
| 147 | + merged.update(lst.details) # les valeurs du connecteur priment | |
| 148 | + lst.details = merged | |
| 149 | + # description : on garde la plus riche (la fiche détail bat le résumé liste) | |
| 150 | + if d.get("description") and len(d["description"]) > len(lst.description or ""): | |
| 151 | + lst.description = d["description"] | |
| 152 | + for f in ("price_label", "address", "city", "sector", "unit_type", | |
| 153 | + "availability", "title"): | |
| 154 | + if d.get(f) and not getattr(lst, f, ""): | |
| 155 | + setattr(lst, f, d[f]) | |
| 156 | + for f in ("price", "area_sqft", "lat", "lng", "availability_date", | |
| 157 | + "pets", "furnished"): | |
| 158 | + if d.get(f) is not None and getattr(lst, f, None) is None: | |
| 159 | + setattr(lst, f, d[f]) | |
added
louka/connectors/barnes_quebec.py
+233 −0
@@ -0,0 +1,233 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/barnes_quebec.py : BARNES Québec (barnes-quebec.com) — LOCATIONS | |
| 5 | +# Agence de prestige (Montréal, Québec, Laval, Mont-Tremblant…). Site | |
| 6 | +# WordPress indexé dans Algolia ; la config expose l'App ID et une clé API | |
| 7 | +# dans le HTML. L'index « quebec_all » mélange ventes (type "property") et | |
| 8 | +# locations (type "rental") : on filtre côté serveur avec | |
| 9 | +# facetFilters=[["type:rental"]]. Chaque location porte le loyer mensuel | |
| 10 | +# numérique (property_rent), l'adresse, chambres/salles de bains, superficie | |
| 11 | +# et géolocalisation. Les enregistrements sont dupliqués par langue (FR/EN) → | |
| 12 | +# on dédoublonne par titre normalisé en gardant la fiche FR. Adapté du | |
| 13 | +# connecteur « à vendre » d'Immo-Ka (agent-courtage/immoka). | |
| 14 | +# ----------------------------------------------------------------------------- | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import os | |
| 18 | +import re | |
| 19 | + | |
| 20 | +from ..schema import Listing | |
| 21 | +from .base import BaseConnector | |
| 22 | + | |
| 23 | +from . import _detailutil as du | |
| 24 | + | |
| 25 | +APP_ID = "HCW55VIQNM" | |
| 26 | +API_KEY = "f4a20779fb6ded84a9c96a9b5976328b" | |
| 27 | +INDEX = "quebec_all" | |
| 28 | +QUERY_URL = f"https://{APP_ID}-dsn.algolia.net/1/indexes/{INDEX}/query" | |
| 29 | +SITE = "https://barnes-quebec.com" | |
| 30 | +STAGING = "stg-quebec-staging.kinsta.cloud" # permaliens parfois en staging | |
| 31 | +HITS_PER_PAGE = 100 | |
| 32 | +MAX_PAGES = 40 | |
| 33 | +# facetFilters=[["type:rental"]] URL-encodé (le corps Algolia est un query string) | |
| 34 | +FACET_RENTAL = "facetFilters=%5B%5B%22type%3Arental%22%5D%5D" | |
| 35 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_BARNES_DETAIL_LIMIT", "200")) | |
| 36 | + | |
| 37 | +# Galerie WordPress : .../wp-content/uploads/AAAA/MM/{ref}-{hash}-{L}x{H}.jpg | |
| 38 | +_IMG_RE = re.compile( | |
| 39 | + r'https://barnes-quebec\.com/wp-content/uploads/\d{4}/\d{2}/[^"\'\\ ]+?\.(?:jpg|jpeg|png|webp)', | |
| 40 | + re.I) | |
| 41 | +_SIZE_RE = re.compile(r'-(\d{2,4})x(\d{2,4})(?=\.[a-z]+$)', re.I) | |
| 42 | +# ville Algolia au format « Montréal (Rosemont/La Petite-Patrie) » | |
| 43 | +_CITY_SECTOR_RE = re.compile(r"^(.*?)\s*\(([^)]+)\)\s*$") | |
| 44 | + | |
| 45 | +# caractéristiques de la fiche Barnes (table Centris rendue « libellé | valeur » | |
| 46 | +# uniquement — l'ordre inverse capte la table des pièces et produit du bruit) | |
| 47 | +_DETAIL_LABELS = [ | |
| 48 | + "Année de construction", "Superficie habitable", "Superficie du terrain", | |
| 49 | + "Stationnement", "Garage", "Mode de chauffage", "Énergie pour le chauffage", | |
| 50 | + "Piscine", "Vue", "Zonage", "Date d'emménagement", "Bail", "Meublé", | |
| 51 | + "Animaux", "Inclus dans le loyer", | |
| 52 | +] | |
| 53 | + | |
| 54 | + | |
| 55 | +class BarnesQuebecConnector(BaseConnector): | |
| 56 | + source_id = "barnes_quebec" | |
| 57 | + request_delay = 0.25 | |
| 58 | + | |
| 59 | + def fetch(self) -> list[Listing]: | |
| 60 | + # une location apparaît en plusieurs langues (FR/EN) avec des objectID | |
| 61 | + # distincts mais le même titre. Clé stable = titre normalisé ; on garde | |
| 62 | + # la meilleure fiche (loyer connu + FR). | |
| 63 | + best: dict[str, tuple[int, Listing]] = {} | |
| 64 | + page = 0 | |
| 65 | + while page < MAX_PAGES: | |
| 66 | + data = self._query(page) | |
| 67 | + hits = data.get("hits", []) | |
| 68 | + if not hits: | |
| 69 | + break | |
| 70 | + for h in hits: | |
| 71 | + if h.get("type") != "rental": # ceinture + bretelles | |
| 72 | + continue | |
| 73 | + lst = self._to_listing(h) | |
| 74 | + if lst is None: | |
| 75 | + continue | |
| 76 | + key = " ".join(lst.title.split()).lower() | |
| 77 | + score = (2 if lst.price else 0) + (1 if h.get("lang_fr") == 1 else 0) | |
| 78 | + if key not in best or score > best[key][0]: | |
| 79 | + best[key] = (score, lst) | |
| 80 | + if page + 1 >= data.get("nbPages", 0): | |
| 81 | + break | |
| 82 | + page += 1 | |
| 83 | + listings = [lst for _, lst in best.values()] | |
| 84 | + # Algolia n'expose qu'une photo : la galerie vient de la fiche. | |
| 85 | + du.enrich(self, listings, DETAIL_LIMIT, parse_barnes_detail, key="v1") | |
| 86 | + return listings | |
| 87 | + | |
| 88 | + def _query(self, page: int) -> dict: | |
| 89 | + resp = self.post( | |
| 90 | + QUERY_URL, | |
| 91 | + headers={"X-Algolia-API-Key": API_KEY, | |
| 92 | + "X-Algolia-Application-Id": APP_ID, | |
| 93 | + "Content-Type": "application/json"}, | |
| 94 | + json={"params": f"hitsPerPage={HITS_PER_PAGE}&page={page}&{FACET_RENTAL}"}, | |
| 95 | + ) | |
| 96 | + return resp.json() | |
| 97 | + | |
| 98 | + def _to_listing(self, h: dict) -> Listing | None: | |
| 99 | + object_id = str(h.get("objectID") or "") | |
| 100 | + mls = str(h.get("property_mls_reference") or "").strip() | |
| 101 | + if not object_id and not mls: | |
| 102 | + return None | |
| 103 | + | |
| 104 | + permalink = (h.get("permalink") or "").replace(STAGING, "barnes-quebec.com") | |
| 105 | + if permalink.startswith("http://"): | |
| 106 | + permalink = "https://" + permalink[len("http://"):] | |
| 107 | + | |
| 108 | + # loyer mensuel : champ numérique dédié (property_price est null pour | |
| 109 | + # les locations). property_pretty_rent = libellé propre sans HTML. | |
| 110 | + rent = h.get("property_rent") or 0 | |
| 111 | + try: | |
| 112 | + rent = float(rent) | |
| 113 | + except (TypeError, ValueError): | |
| 114 | + rent = 0.0 | |
| 115 | + price_label = (h.get("property_pretty_rent") or "").strip() | |
| 116 | + | |
| 117 | + # « Montréal (Rosemont/La Petite-Patrie) » -> ville + secteur | |
| 118 | + city_raw = (h.get("property_address_city") or "").strip() | |
| 119 | + m = _CITY_SECTOR_RE.match(city_raw) | |
| 120 | + city, sector = (m.group(1), m.group(2)) if m else (city_raw, "") | |
| 121 | + regions = h.get("regions") or [] | |
| 122 | + if not city and regions: # rare : fiche sans ville renseignée | |
| 123 | + city = str(regions[0]).strip() | |
| 124 | + | |
| 125 | + # le titre concatène adresse + ville (+ région) : on isole la rue | |
| 126 | + title = (h.get("title") or "").strip() | |
| 127 | + address = title | |
| 128 | + if city_raw and city_raw in title: | |
| 129 | + address = title[:title.find(city_raw)].strip(" ,-") | |
| 130 | + | |
| 131 | + beds = _pos(h.get("property_bedrooms_integer")) | |
| 132 | + unit_type = "" | |
| 133 | + if beds is not None: | |
| 134 | + unit_type = "Studio" if beds == 0 else f"{beds} chambres" | |
| 135 | + | |
| 136 | + lat = h.get("property_address_latitude") or None | |
| 137 | + lng = h.get("property_address_longitude") or None | |
| 138 | + try: | |
| 139 | + lat = float(lat) if lat else None | |
| 140 | + lng = float(lng) if lng else None | |
| 141 | + except (TypeError, ValueError): | |
| 142 | + lat = lng = None | |
| 143 | + if lat == 0 or lng == 0: | |
| 144 | + lat = lng = None | |
| 145 | + | |
| 146 | + details: dict = {"Agence": "BARNES Québec"} | |
| 147 | + if mls: | |
| 148 | + details["No Centris"] = mls | |
| 149 | + baths = _pos(h.get("property_bathrooms")) | |
| 150 | + if baths is not None: | |
| 151 | + details["Salles de bain"] = str(baths) | |
| 152 | + rooms = _pos(h.get("property_rooms")) | |
| 153 | + if rooms is not None: | |
| 154 | + details["Nombre de pièces"] = str(rooms) | |
| 155 | + ptype = (h.get("property_type") or "").strip() | |
| 156 | + if ptype: | |
| 157 | + details["Type de propriété"] = ptype | |
| 158 | + if regions: | |
| 159 | + details["Région"] = str(regions[0]) | |
| 160 | + | |
| 161 | + # image « liste » (la galerie complète vient de la fiche détail) | |
| 162 | + images = [] | |
| 163 | + for k in ("image_full", "image_large", "image_medium"): | |
| 164 | + if h.get(k): | |
| 165 | + images = [h[k]] | |
| 166 | + break | |
| 167 | + | |
| 168 | + return Listing( | |
| 169 | + source=self.source_id, | |
| 170 | + external_id=mls or object_id, | |
| 171 | + url=permalink or SITE + "/rental/", | |
| 172 | + title=title, | |
| 173 | + address=address, | |
| 174 | + sector=sector, | |
| 175 | + city=city, | |
| 176 | + unit_type=unit_type, | |
| 177 | + price=rent if rent > 0 else None, # explicite : pas de plafond parse_price | |
| 178 | + price_label=price_label, | |
| 179 | + area_sqft=_posf(h.get("property_area")), | |
| 180 | + description=(h.get("content") or "")[:4000], | |
| 181 | + details=details, | |
| 182 | + images=images, | |
| 183 | + lat=lat, | |
| 184 | + lng=lng, | |
| 185 | + ) | |
| 186 | + | |
| 187 | + | |
| 188 | +def _pos(v): | |
| 189 | + try: | |
| 190 | + n = float(v) | |
| 191 | + return int(n) if n and n > 0 else None | |
| 192 | + except (TypeError, ValueError): | |
| 193 | + return None | |
| 194 | + | |
| 195 | + | |
| 196 | +def _posf(v): | |
| 197 | + try: | |
| 198 | + n = float(v) | |
| 199 | + return n if n > 0 else None | |
| 200 | + except (TypeError, ValueError): | |
| 201 | + return None | |
| 202 | + | |
| 203 | + | |
| 204 | +def parse_barnes_detail(html: str) -> dict: | |
| 205 | + """Galerie photo pleine résolution (absente d'Algolia) + description/pièces.""" | |
| 206 | + out: dict = {} | |
| 207 | + # regroupe par image de base (sans le suffixe -LxH), garde la plus grande | |
| 208 | + best: dict[str, tuple[int, str]] = {} | |
| 209 | + for u in _IMG_RE.findall(html): | |
| 210 | + m = _SIZE_RE.search(u) | |
| 211 | + area = int(m.group(1)) * int(m.group(2)) if m else 10 ** 8 # sans suffixe = original | |
| 212 | + base = _SIZE_RE.sub("", u) | |
| 213 | + if base not in best or area > best[base][0]: | |
| 214 | + best[base] = (area, u) | |
| 215 | + imgs = [u for _, u in best.values()] | |
| 216 | + # ignore les vignettes de courtiers/logos (les photos gardent la réf Centris chiffrée) | |
| 217 | + imgs = [u for u in imgs if re.search(r"/\d{6,}", u)] or imgs | |
| 218 | + if imgs: | |
| 219 | + out["images"] = imgs[:60] | |
| 220 | + desc = du.ld_description(html) | |
| 221 | + if desc: | |
| 222 | + out["description"] = desc | |
| 223 | + text = du.flatten(html) | |
| 224 | + det: dict = {} | |
| 225 | + for label in _DETAIL_LABELS: | |
| 226 | + m = re.search(re.escape(label) + r"\b\s*\|\s*([^|]{1,55})", text) | |
| 227 | + if m: | |
| 228 | + val = m.group(1).strip(" |,") | |
| 229 | + if val and 1 <= len(val) <= 55 and val.lower() != label.lower(): | |
| 230 | + det[label] = val | |
| 231 | + if det: | |
| 232 | + out.setdefault("details", {}).update(det) | |
| 233 | + return out | |
modified
louka/connectors/base.py
+75 −1
@@ -2,10 +2,12 @@ | ||
| 2 | 2 | # Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| 3 | 3 | # Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | 4 | # connectors/base.py : classe de base des connecteurs + backends de fetch |
| 5 | −# (requests direct, ou Firecrawl pour les sites JavaScript) | |
| 5 | +# (requests direct, Firecrawl ou Scrapfly pour les sites | |
| 6 | +# JavaScript / derrière anti-bot) | |
| 6 | 7 | # ----------------------------------------------------------------------------- |
| 7 | 8 | from __future__ import annotations |
| 8 | 9 | |
| 10 | +import json | |
| 9 | 11 | import os |
| 10 | 12 | import time |
| 11 | 13 | |
@@ -18,6 +20,7 @@ USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " | ||
| 18 | 20 | "LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)") |
| 19 | 21 | |
| 20 | 22 | FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape" |
| 23 | +SCRAPFLY_API = "https://api.scrapfly.io/scrape" | |
| 21 | 24 | |
| 22 | 25 | |
| 23 | 26 | class BaseConnector: |
@@ -50,6 +53,16 @@ class BaseConnector: | ||
| 50 | 53 | resp.raise_for_status() |
| 51 | 54 | return resp |
| 52 | 55 | |
| 56 | + def post(self, url: str, **kw) -> requests.Response: | |
| 57 | + """POST direct avec throttling poli (APIs de recherche internes).""" | |
| 58 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 59 | + if wait > 0: | |
| 60 | + time.sleep(wait) | |
| 61 | + resp = self.session.post(url, timeout=self.timeout, **kw) | |
| 62 | + self._last_request = time.time() | |
| 63 | + resp.raise_for_status() | |
| 64 | + return resp | |
| 65 | + | |
| 53 | 66 | def get_rendered(self, url: str) -> str: |
| 54 | 67 | """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl. |
| 55 | 68 | |
@@ -69,6 +82,67 @@ class BaseConnector: | ||
| 69 | 82 | data = resp.json() |
| 70 | 83 | return (data.get("data") or {}).get("html", "") |
| 71 | 84 | |
| 85 | + def scrapfly(self, url: str, render_js: bool = True, asp: bool = True, | |
| 86 | + rendering_wait: int = 0, country: str = "ca", | |
| 87 | + wait_for_selector: str | None = None, | |
| 88 | + js_scenario: list | str | None = None, | |
| 89 | + proxy_pool: str | None = None, headers: dict | None = None, | |
| 90 | + method: str = "GET", body: str | None = None) -> dict: | |
| 91 | + """Appel Scrapfly complet — retourne le dict `result` (content, status_code…). | |
| 92 | + | |
| 93 | + - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (encodée base64) | |
| 94 | + pour charger les listes virtualisées (BoldTrail/kvCORE, etc.). | |
| 95 | + - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif). | |
| 96 | + - `headers`/`method`/`body` : pour REJOUER une API JSON interne via ASP. | |
| 97 | + """ | |
| 98 | + import base64 | |
| 99 | + key = os.environ.get("SCRAPFLY_KEY") | |
| 100 | + if not key: | |
| 101 | + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)") | |
| 102 | + params: dict = {"key": key, "url": url, "country": country} | |
| 103 | + if asp: | |
| 104 | + params["asp"] = "true" | |
| 105 | + if render_js: | |
| 106 | + params["render_js"] = "true" | |
| 107 | + if rendering_wait: | |
| 108 | + params["rendering_wait"] = rendering_wait | |
| 109 | + if wait_for_selector: | |
| 110 | + params["wait_for_selector"] = wait_for_selector | |
| 111 | + if proxy_pool: | |
| 112 | + params["proxy_pool"] = proxy_pool | |
| 113 | + if js_scenario is not None: | |
| 114 | + js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario) | |
| 115 | + params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode() | |
| 116 | + if headers: | |
| 117 | + for k, v in headers.items(): | |
| 118 | + params[f"headers[{k}]"] = v | |
| 119 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 120 | + if wait > 0: | |
| 121 | + time.sleep(wait) | |
| 122 | + if method.upper() == "POST": | |
| 123 | + resp = requests.post(SCRAPFLY_API, params=params, | |
| 124 | + data=(body or ""), timeout=180) | |
| 125 | + else: | |
| 126 | + resp = requests.get(SCRAPFLY_API, params=params, timeout=180) | |
| 127 | + self._last_request = time.time() | |
| 128 | + try: | |
| 129 | + return resp.json().get("result") or {} | |
| 130 | + except ValueError: | |
| 131 | + return {} | |
| 132 | + | |
| 133 | + def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True, | |
| 134 | + rendering_wait: int = 0, country: str = "ca", | |
| 135 | + wait_for_selector: str | None = None, | |
| 136 | + js_scenario: list | str | None = None, | |
| 137 | + proxy_pool: str | None = None) -> str: | |
| 138 | + """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne | |
| 139 | + le HTML (result.content) ou "" en cas d'échec ASP.""" | |
| 140 | + return self.scrapfly(url, render_js=render_js, asp=asp, | |
| 141 | + rendering_wait=rendering_wait, country=country, | |
| 142 | + wait_for_selector=wait_for_selector, | |
| 143 | + js_scenario=js_scenario, proxy_pool=proxy_pool | |
| 144 | + ).get("content") or "" | |
| 145 | + | |
| 72 | 146 | def detail(self, external_id: str, key: str, fetch_fn) -> dict: |
| 73 | 147 | """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si |
| 74 | 148 | l'annonce est nouvelle ou si sa clé (hash du contenu liste) a changé. |
added
louka/connectors/kijiji.py
+247 −0
@@ -0,0 +1,247 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/kijiji.py : Kijiji (kijiji.ca) — petites annonces de LOCATION | |
| 5 | +# UNIQUEMENT les catégories logement À LOUER, UNIQUEMENT le Québec (l9001) : | |
| 6 | +# c37 appartements & condos à louer · c36 chambres à louer & colocation | |
| 7 | +# Les pages listent 40+ annonces dans __NEXT_DATA__ (Apollo state) avec titre, | |
| 8 | +# prix, GPS, adresse, date de disponibilité et attributs (meublé, animaux, | |
| 9 | +# inclusions…) — aucune API privée nécessaire. Adapté du connecteur « à | |
| 10 | +# vendre » d'Immo-Ka (agent-courtage/immoka). | |
| 11 | +# ----------------------------------------------------------------------------- | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import json | |
| 15 | +import os | |
| 16 | +import re | |
| 17 | + | |
| 18 | +from ..schema import Listing | |
| 19 | +from .base import BaseConnector | |
| 20 | + | |
| 21 | +from . import _detailutil as du | |
| 22 | + | |
| 23 | +BASE = "https://www.kijiji.ca" | |
| 24 | +# (code catégorie, segment d'URL, type d'unité par défaut) | |
| 25 | +CATEGORIES = [ | |
| 26 | + (37, "b-appartement-condo", ""), # unité déduite des attributs | |
| 27 | + (36, "b-chambre-a-louer-colocataire", "Chambre"), | |
| 28 | +] | |
| 29 | +MAX_PAGES = int(os.environ.get("LOUKA_KIJIJI_MAX_PAGES", "100")) | |
| 30 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_KIJIJI_DETAIL_LIMIT", "400")) | |
| 31 | + | |
| 32 | +# les annonces vivent sous des clés Apollo « RealEstateListing:123 » (c37) | |
| 33 | +# ou « StandardListing:123 » (c36) | |
| 34 | +_LISTING_KEY_RE = re.compile(r"^(?:RealEstate|Standard)Listing:\d+$") | |
| 35 | +_NEXT_RE = re.compile( | |
| 36 | + r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S) | |
| 37 | + | |
| 38 | +# attributs binaires -> commodité affichable (uniquement si la valeur est vraie) | |
| 39 | +_AMENITY_LABELS = { | |
| 40 | + "heat": "Chauffage inclus", "hydro": "Électricité incluse", | |
| 41 | + "water": "Eau incluse", "internet": "Internet inclus", | |
| 42 | + "cabletv": "Câble/télé inclus", "laundryinunit": "Laveuse/sécheuse dans l'unité", | |
| 43 | + "laundryinbuilding": "Buanderie dans l'immeuble", "dishwasher": "Lave-vaisselle", | |
| 44 | + "fridgefreezer": "Réfrigérateur/congélateur", "airconditioning": "Air climatisé", | |
| 45 | + "balcony": "Balcon", "elevator": "Ascenseur", "gym": "Salle d'entraînement", | |
| 46 | + "pool": "Piscine", "concierge": "Concierge", | |
| 47 | + "twentyfourhoursecurity": "Sécurité 24 h", "storagelocker": "Espace de rangement", | |
| 48 | + "bicycleparking": "Stationnement pour vélo", "yard": "Cour", | |
| 49 | + "wheelchairaccessible": "Accessible en fauteuil roulant", | |
| 50 | +} | |
| 51 | +_UNIT_TYPES = { | |
| 52 | + "apartment": "Appartement", "condo": "Condo", | |
| 53 | + "basement-apartment": "Appartement au sous-sol", "house": "Maison", | |
| 54 | + "townhouse": "Maison de ville", "duplex-triplex": "Duplex/Triplex", | |
| 55 | +} | |
| 56 | +_AGREEMENTS = {"one-year": "Bail de 1 an", "month-to-month": "Au mois", | |
| 57 | + "not-available": ""} | |
| 58 | +# villes fréquentes sans accents dans les adresses Kijiji | |
| 59 | +_CITY_FIX = { | |
| 60 | + "montreal": "Montréal", "quebec": "Québec", "levis": "Lévis", | |
| 61 | + "trois-rivieres": "Trois-Rivières", "riviere-des-prairies": "Montréal", | |
| 62 | + "ville de montreal": "Montréal", "ville de quebec": "Québec", | |
| 63 | +} | |
| 64 | + | |
| 65 | + | |
| 66 | +def _fix_city(raw: str) -> str: | |
| 67 | + key = (raw or "").strip().lower() | |
| 68 | + if key in _CITY_FIX: | |
| 69 | + return _CITY_FIX[key] | |
| 70 | + return " ".join(w.capitalize() for w in key.replace("-", " ").split()) | |
| 71 | + | |
| 72 | + | |
| 73 | +def _attr_value(a: dict) -> str: | |
| 74 | + """Première valeur d'un attribut Apollo (canonique, sinon affichée).""" | |
| 75 | + for k in ("canonicalValues", "values"): | |
| 76 | + vals = a.get(k) or [] | |
| 77 | + if vals: | |
| 78 | + return str(vals[0]) | |
| 79 | + return "" | |
| 80 | + | |
| 81 | + | |
| 82 | +def _apply_attrs(attrs: list[dict], out: dict) -> None: | |
| 83 | + """Interprète les attributs Kijiji (mêmes clés en liste et en fiche).""" | |
| 84 | + amenities = out.setdefault("amenities", []) | |
| 85 | + details = out.setdefault("details", {}) | |
| 86 | + for a in attrs or []: | |
| 87 | + cn = a.get("canonicalName") or "" | |
| 88 | + val = _attr_value(a) | |
| 89 | + if not val: | |
| 90 | + continue | |
| 91 | + if cn in _AMENITY_LABELS: | |
| 92 | + if val == "1": | |
| 93 | + amenities.append(_AMENITY_LABELS[cn]) | |
| 94 | + elif cn == "furnished": | |
| 95 | + out["furnished"] = val == "1" | |
| 96 | + elif cn == "petsallowed": | |
| 97 | + out["pets"] = "oui" if val == "1" else "non" | |
| 98 | + elif cn == "numberbedrooms": | |
| 99 | + out["bedrooms"] = val # '0' = studio, sinon nb de chambres | |
| 100 | + elif cn == "numberbathrooms": | |
| 101 | + try: # canonique en dixièmes : '15' = 1.5 | |
| 102 | + n = int(val) / 10 | |
| 103 | + details["Salles de bain"] = f"{n:g}" | |
| 104 | + except ValueError: | |
| 105 | + pass | |
| 106 | + elif cn in ("areainfeet", "sizesqft"): | |
| 107 | + m = re.search(r"[\d.]+", val.replace(",", "")) | |
| 108 | + if m and float(m.group(0)) > 0: | |
| 109 | + out["area_sqft"] = float(m.group(0)) | |
| 110 | + elif cn == "dateavailable": | |
| 111 | + m = re.match(r"(\d{4}-\d{2}-\d{2})", val) | |
| 112 | + if m: | |
| 113 | + out["availability_date"] = m.group(1) | |
| 114 | + elif cn == "unittype": | |
| 115 | + details["Type d'unité"] = _UNIT_TYPES.get(val, val) | |
| 116 | + elif cn == "agreementtype": | |
| 117 | + bail = _AGREEMENTS.get(val, val) | |
| 118 | + if bail: | |
| 119 | + details["Bail"] = bail | |
| 120 | + elif cn == "numberparkingspots" and val.isdigit() and int(val) > 0: | |
| 121 | + amenities.append(f"Stationnement ({val})") | |
| 122 | + | |
| 123 | + | |
| 124 | +def _parse_kijiji_detail(html: str) -> dict: | |
| 125 | + """Fiche Kijiji : description complète, attributs, galerie haute résolution.""" | |
| 126 | + m = _NEXT_RE.search(html) | |
| 127 | + if not m: | |
| 128 | + return {} | |
| 129 | + try: | |
| 130 | + data = json.loads(m.group(1)) | |
| 131 | + except ValueError: | |
| 132 | + return {} | |
| 133 | + apollo = data.get("props", {}).get("pageProps", {}).get("__APOLLO_STATE__", {}) | |
| 134 | + it = next((v for k, v in apollo.items() | |
| 135 | + if _LISTING_KEY_RE.match(k) and isinstance(v, dict) | |
| 136 | + and v.get("description")), None) | |
| 137 | + if not it: | |
| 138 | + return {} | |
| 139 | + out: dict = {} | |
| 140 | + if it.get("description"): | |
| 141 | + out["description"] = str(it["description"]).strip()[:6000] | |
| 142 | + imgs = [re.sub(r"rule=kijijica-\d+-\w+", "rule=kijijica-1600-jpg", u) | |
| 143 | + for u in it.get("imageUrls") or []] | |
| 144 | + if imgs: | |
| 145 | + out["images"] = imgs | |
| 146 | + _apply_attrs((it.get("attributes") or {}).get("all") or [], out) | |
| 147 | + out.pop("bedrooms", None) # le type d'unité est déjà fixé au niveau liste | |
| 148 | + loc = it.get("location") or {} | |
| 149 | + addr = (loc.get("address") or "").replace(", Canada", "") | |
| 150 | + if re.match(r"\s*\d", addr): | |
| 151 | + out["address"] = addr.split(",")[0] | |
| 152 | + return out | |
| 153 | + | |
| 154 | + | |
| 155 | +class KijijiConnector(BaseConnector): | |
| 156 | + source_id = "kijiji" | |
| 157 | + request_delay = 1.2 | |
| 158 | + | |
| 159 | + def _page(self, seg: str, cat: int, page: int) -> list[dict]: | |
| 160 | + """Annonces (Apollo state) d'une page de catégorie.""" | |
| 161 | + path = (f"{seg}/quebec/c{cat}l9001" if page == 1 | |
| 162 | + else f"{seg}/quebec/page-{page}/c{cat}l9001") | |
| 163 | + html = self.get(f"{BASE}/{path}").text | |
| 164 | + m = _NEXT_RE.search(html) | |
| 165 | + data = json.loads(m.group(1)) if m else {} | |
| 166 | + apollo = (data.get("props", {}).get("pageProps", {}) | |
| 167 | + .get("__APOLLO_STATE__", {})) | |
| 168 | + return [v for k, v in apollo.items() | |
| 169 | + if _LISTING_KEY_RE.match(k) and isinstance(v, dict)] | |
| 170 | + | |
| 171 | + def _to_listing(self, it: dict, unit_default: str) -> Listing | None: | |
| 172 | + lid = str(it.get("id") or "") | |
| 173 | + url = it.get("url") or "" | |
| 174 | + if not lid or not url: | |
| 175 | + return None | |
| 176 | + price = None | |
| 177 | + pr = it.get("price") or {} | |
| 178 | + if isinstance(pr, dict) and pr.get("amount"): | |
| 179 | + price = round(pr["amount"] / 100.0, 0) # cents → $/mois | |
| 180 | + loc = it.get("location") or {} | |
| 181 | + coords = loc.get("coordinates") or {} | |
| 182 | + address = (loc.get("address") or "").replace(", Canada", "") | |
| 183 | + parts = [p.strip() for p in address.split(",") if p.strip()] | |
| 184 | + street = parts[0] if parts and re.match(r"\s*\d", parts[0]) else "" | |
| 185 | + city = _fix_city(parts[1] if street and len(parts) > 1 | |
| 186 | + else (loc.get("name") or (parts[0] if parts else ""))) | |
| 187 | + images = [re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u) | |
| 188 | + for u in it.get("imageUrls") or []] | |
| 189 | + extra: dict = {} | |
| 190 | + _apply_attrs((it.get("attributes") or {}).get("all") or [], extra) | |
| 191 | + unit_type = unit_default | |
| 192 | + beds = extra.pop("bedrooms", None) | |
| 193 | + if not unit_type and beds: | |
| 194 | + try: # Kijiji code parfois « 2.5 » (2 ch. + den) | |
| 195 | + n = int(float(beds)) | |
| 196 | + except ValueError: | |
| 197 | + n = 0 | |
| 198 | + unit_type = "Studio" if n == 0 else f"{n} chambres" # → n+2 ½ | |
| 199 | + lst = Listing( | |
| 200 | + source=self.source_id, | |
| 201 | + external_id=lid, | |
| 202 | + url=url, | |
| 203 | + title=it.get("title") or "", | |
| 204 | + address=street, | |
| 205 | + city=city, | |
| 206 | + unit_type=unit_type, | |
| 207 | + price=price, | |
| 208 | + price_label=(f"{price:,.0f} $/mois".replace(",", " ") | |
| 209 | + if price else ""), | |
| 210 | + description=(it.get("description") or "")[:2000], | |
| 211 | + amenities=extra.get("amenities") or [], | |
| 212 | + details=extra.get("details") or {}, | |
| 213 | + images=images, | |
| 214 | + lat=coords.get("latitude"), | |
| 215 | + lng=coords.get("longitude"), | |
| 216 | + ) | |
| 217 | + if extra.get("availability_date"): | |
| 218 | + lst.availability_date = extra["availability_date"] | |
| 219 | + lst.availability = f"Libre le {extra['availability_date']}" | |
| 220 | + if extra.get("furnished") is not None: | |
| 221 | + lst.furnished = extra["furnished"] | |
| 222 | + if extra.get("pets"): | |
| 223 | + lst.pets = extra["pets"] | |
| 224 | + if extra.get("area_sqft"): | |
| 225 | + lst.area_sqft = extra["area_sqft"] | |
| 226 | + return lst | |
| 227 | + | |
| 228 | + def fetch(self) -> list[Listing]: | |
| 229 | + out: dict[str, Listing] = {} | |
| 230 | + for cat, seg, unit_default in CATEGORIES: | |
| 231 | + for page in range(1, MAX_PAGES + 1): | |
| 232 | + try: | |
| 233 | + items = self._page(seg, cat, page) | |
| 234 | + except Exception: | |
| 235 | + break | |
| 236 | + fresh = 0 | |
| 237 | + for it in items: | |
| 238 | + lst = self._to_listing(it, unit_default) | |
| 239 | + if lst is not None and lst.uid not in out: | |
| 240 | + out[lst.uid] = lst | |
| 241 | + fresh += 1 | |
| 242 | + # plus rien de neuf (page de fin remplie de topAds répétés) | |
| 243 | + if fresh == 0 or len(items) < 10: | |
| 244 | + break | |
| 245 | + listings = list(out.values()) | |
| 246 | + du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v1") | |
| 247 | + return listings | |
added
louka/connectors/lespac.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/lespac.py : LesPAC (lespac.com) — petites annonces du Québec | |
| 5 | +# UNIQUEMENT l'immobilier LOCATION résidentielle : | |
| 6 | +# b457 logements · b458 chambres & colocation · b460 résidences pour aînés | |
| 7 | +# Les pages « /montreal/…_b{cat}g17567k{page}R2.jsa » embarquent | |
| 8 | +# `var searchResponse = {…}` côté serveur : 20-24 annonces/page + totalPages. | |
| 9 | +# Le site force une ville d'ancrage (g17567 = Montréal) mais le jeu de | |
| 10 | +# résultats couvre TOUTE la province, simplement trié par distance — vérifié : | |
| 11 | +# l'ancre Québec (g15398) donne le même totalPages. Adapté du connecteur | |
| 12 | +# « achat-vente » d'Immo-Ka (agent-courtage/immoka). | |
| 13 | +# ----------------------------------------------------------------------------- | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import html as _html | |
| 17 | +import json | |
| 18 | +import os | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from ..schema import Listing | |
| 22 | +from .base import BaseConnector | |
| 23 | + | |
| 24 | +from . import _detailutil as du | |
| 25 | + | |
| 26 | +BASE = "https://www.lespac.com" | |
| 27 | +ANCHOR = "montreal" # ville d'ancrage (tri par distance) | |
| 28 | +ANCHOR_GEO = "g17567" | |
| 29 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_LESPAC_DETAIL_LIMIT", "400")) | |
| 30 | +CATEGORIES = [ | |
| 31 | + (457, "immobilier-location-logements", ""), | |
| 32 | + (458, "immobilier-location-colocataires", "Chambre"), | |
| 33 | + (460, "immobilier-location-residences-pour-aines", ""), | |
| 34 | +] | |
| 35 | +_RE_RESP = re.compile(r"var searchResponse = (\{.*?\});\s*[\r\n]", re.S) | |
| 36 | +_RE_DEMI = re.compile(r"(\d+)\s*(?:½|1/2)") | |
| 37 | + | |
| 38 | + | |
| 39 | +def _clean(s: str) -> str: | |
| 40 | + return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip() | |
| 41 | + | |
| 42 | + | |
| 43 | +class LesPacConnector(BaseConnector): | |
| 44 | + source_id = "lespac" | |
| 45 | + request_delay = 0.9 | |
| 46 | + | |
| 47 | + def _search_page(self, slug: str, cat: int, page: int) -> dict | None: | |
| 48 | + url = f"{BASE}/{ANCHOR}/{slug}_b{cat}{ANCHOR_GEO}k{page}R2.jsa" | |
| 49 | + m = _RE_RESP.search(self.get(url).text) | |
| 50 | + return json.loads(m.group(1)) if m else None | |
| 51 | + | |
| 52 | + def _to_listing(self, r: dict, unit_default: str) -> Listing | None: | |
| 53 | + lid = str(r.get("listingPublicId") or "") | |
| 54 | + url = (r.get("listingDisplayUrl") or "").split("?")[0] | |
| 55 | + if not lid or not url: | |
| 56 | + return None | |
| 57 | + title = r.get("title") or "" | |
| 58 | + unit_type = unit_default | |
| 59 | + if not unit_type: | |
| 60 | + m = _RE_DEMI.search(title) | |
| 61 | + if m: | |
| 62 | + unit_type = f"{m.group(1)}½" | |
| 63 | + # prix : LesPAC affiche « Par mois » / « Par semaine » dans priceNote | |
| 64 | + note = (r.get("priceNote") or "").strip() | |
| 65 | + price, price_label = None, "" | |
| 66 | + if r.get("price") is not None and ("mois" in note.lower() or not note): | |
| 67 | + price = float(r["price"]) | |
| 68 | + price_label = f"{r.get('priceLabel') or ''} par mois".strip() | |
| 69 | + details: dict = {} | |
| 70 | + if note and "mois" not in note.lower(): | |
| 71 | + details["Fréquence du loyer"] = note # ex. « Par semaine » | |
| 72 | + track = r.get("searchPageTrackingInfo") or {} | |
| 73 | + region = ((track.get("listing-region-code") or {}).get("value") or "") | |
| 74 | + if region: | |
| 75 | + details["Région"] = region | |
| 76 | + # « Montréal / Centre-Sud / Centre-Ville » → ville + secteur ; | |
| 77 | + # « Autres Provinces » est souvent une erreur de classement LesPAC : | |
| 78 | + # on laisse vide, la vraie ville viendra de l'adresse en fiche détail | |
| 79 | + city_label = r.get("cityLabel") or "" | |
| 80 | + seg = [s.strip() for s in city_label.split("/") if s.strip()] | |
| 81 | + city = seg[0] if seg else "" | |
| 82 | + sector = " / ".join(seg[1:]) if len(seg) > 1 else "" | |
| 83 | + if city.lower() == "autres provinces": | |
| 84 | + city = "" | |
| 85 | + images = [i["formattableImageUrl"].replace("%FORMAT%", "zoomedGallery") | |
| 86 | + for i in r.get("images") or [] if i.get("formattableImageUrl")] | |
| 87 | + return Listing( | |
| 88 | + source=self.source_id, | |
| 89 | + external_id=lid, | |
| 90 | + url=url, | |
| 91 | + title=title, | |
| 92 | + city=city, | |
| 93 | + sector=sector, | |
| 94 | + unit_type=unit_type, | |
| 95 | + price=price, | |
| 96 | + price_label=price_label, | |
| 97 | + description=(r.get("description") or "")[:2000], | |
| 98 | + details=details, | |
| 99 | + images=images, | |
| 100 | + ) | |
| 101 | + | |
| 102 | + def fetch(self) -> list[Listing]: | |
| 103 | + out: dict[str, Listing] = {} | |
| 104 | + for cat, slug, unit_default in CATEGORIES: | |
| 105 | + page, total_pages = 1, 1 | |
| 106 | + while page <= total_pages: | |
| 107 | + try: | |
| 108 | + d = self._search_page(slug, cat, page) | |
| 109 | + except Exception: | |
| 110 | + break | |
| 111 | + if not d: | |
| 112 | + break | |
| 113 | + total_pages = min(int(d.get("totalPages") or 1), 400) | |
| 114 | + fresh = 0 | |
| 115 | + for r in d.get("searchResults") or []: | |
| 116 | + lst = self._to_listing(r, unit_default) | |
| 117 | + if lst is not None and lst.uid not in out: | |
| 118 | + out[lst.uid] = lst | |
| 119 | + fresh += 1 | |
| 120 | + if fresh == 0 and page > 1: # fin réelle malgré totalPages | |
| 121 | + break | |
| 122 | + page += 1 | |
| 123 | + listings = list(out.values()) | |
| 124 | + du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v1") | |
| 125 | + return listings | |
| 126 | + | |
| 127 | + | |
| 128 | +def _parse_lespac_detail(html: str) -> dict: | |
| 129 | + """Fiche LesPAC : description complète, adresse civique, caractéristiques | |
| 130 | + (boîte « Caractéristiques » : <p><span>Label</span><span>Valeur</span></p>, | |
| 131 | + valeurs parfois enrobées de liens) et galerie pleine taille (basephoto).""" | |
| 132 | + out: dict = {} | |
| 133 | + md = re.search(r'<div id="description">.*?<p class="title">Description</p>\s*<p>(.*?)</p>', | |
| 134 | + html, re.S | re.I) | |
| 135 | + if md: | |
| 136 | + desc = _clean(md.group(1)) | |
| 137 | + if desc: | |
| 138 | + out["description"] = desc[:6000] | |
| 139 | + amenities, details = [], {} | |
| 140 | + mbox = re.search(r'>Caractéristiques</p>\s*<div class="box">(.*?)</div>', | |
| 141 | + html, re.S) | |
| 142 | + if mbox: | |
| 143 | + for lm, vm in re.findall(r"<p><span>(.*?)</span>\s*<span>(.*?)</span>", | |
| 144 | + mbox.group(1), re.S): | |
| 145 | + label, value = _clean(lm), _clean(vm) | |
| 146 | + if not label or not value: | |
| 147 | + continue | |
| 148 | + amenities.append(f"{label} : {value}") | |
| 149 | + details[label] = value | |
| 150 | + if label == "Adresse": | |
| 151 | + # « 439 Rue Bellevue, Municipalité de Saint-Donat, QC, Canada » | |
| 152 | + parts = [p.strip() for p in value.split(",") if p.strip()] | |
| 153 | + if parts and re.match(r"\s*\d", parts[0]): | |
| 154 | + out["address"] = parts[0] | |
| 155 | + if len(parts) >= 3 and parts[-2].upper() == "QC": | |
| 156 | + ville = re.sub(r"^(?:Municipalité|Ville|Paroisse|Canton)" | |
| 157 | + r"(?:\s+de\s+|\s+d[e']\s*)?", "", parts[-3]).strip() | |
| 158 | + if ville: | |
| 159 | + out["city"] = ville | |
| 160 | + elif label == "Nombre de pièces": | |
| 161 | + out["unit_type"] = value # ex. « 4 1/2 pièces » | |
| 162 | + if amenities: | |
| 163 | + out["amenities"] = amenities | |
| 164 | + if details: | |
| 165 | + out["details"] = details | |
| 166 | + imgs, seen = [], set() | |
| 167 | + for u in re.findall(r'https://cdn\.lespac\.com/binary/basephoto/\d+\.jpg', html): | |
| 168 | + if u not in seen: | |
| 169 | + seen.add(u) | |
| 170 | + imgs.append(u) | |
| 171 | + if imgs: | |
| 172 | + out["images"] = imgs | |
| 173 | + return out | |
added
louka/connectors/m_immobilier.py
+195 −0
@@ -0,0 +1,195 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/m_immobilier.py : M Immobilier (mimmobilier.com) — LOCATIONS | |
| 5 | +# Agence indépendante de prestige (Grand Montréal), inscriptions Centris. | |
| 6 | +# La liste /properties est rendue serveur et paginée ; le formulaire expose | |
| 7 | +# un filtre statut « à louer » (radio name="CODE_STATUT" value="AL") qu'on | |
| 8 | +# rejoue en query string : /properties?CODE_STATUT=AL&page=N. Chaque carte | |
| 9 | +# « cardProperty » porte l'URL (avec no Centris), le badge « à louer », la | |
| 10 | +# ville, l'adresse, chambres, salles de bains, superficie (PC) et le loyer | |
| 11 | +# « X $ / M ». Adapté du connecteur « à vendre » d'Immo-Ka | |
| 12 | +# (agent-courtage/immoka), qui excluait justement ces cartes-là. | |
| 13 | +# ----------------------------------------------------------------------------- | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import html as _html | |
| 17 | +import os | |
| 18 | +import re | |
| 19 | + | |
| 20 | +from ..schema import Listing | |
| 21 | +from .base import BaseConnector | |
| 22 | + | |
| 23 | +from . import _detailutil as du | |
| 24 | + | |
| 25 | +BASE = "https://www.mimmobilier.com" | |
| 26 | +LISTING_URL = f"{BASE}/properties?CODE_STATUT=AL" # AL = à louer (serveur) | |
| 27 | +CARD_SPLIT = "cardProperty col-span-12" | |
| 28 | +HREF_RE = re.compile(r'href="(/properties/[^"]+/(\d+))"') | |
| 29 | +IMG_RE = re.compile(r'(/images/centris-slideshow/\d+-\d+-\d+\.(?:jpg|jpeg|png|webp))', re.I) | |
| 30 | +# loyer « 1 500.0 $ / M » (M = mois) — le montant précède le $ | |
| 31 | +_RENT_RE = re.compile(r"([\d\s ,]+(?:\.\d+)?)\s*\$\s*/\s*M", re.I) | |
| 32 | +_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S) | |
| 33 | +# l'agence loue aussi des locaux commerciaux au mois (bureaux, entrepôts…) sans | |
| 34 | +# ligne « chambres » : on ne garde ces cartes-là que si l'extrait de description | |
| 35 | +# contient un marqueur clairement résidentiel (studio, bachelor, logement…) | |
| 36 | +_RESIDENTIAL_RE = re.compile( | |
| 37 | + r"\b(studios?|bachelor|logements?|appartements?|condos?|chambres?|" | |
| 38 | + r"r[ée]sidentiel(?:le)?s?|unit[ée]s?|laveuse|maison|complexe)\b", re.I) | |
| 39 | +MAX_PAGES = int(os.environ.get("LOUKA_MIMMO_MAX_PAGES", "10")) | |
| 40 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_MIMMO_DETAIL_LIMIT", "100")) | |
| 41 | + | |
| 42 | + | |
| 43 | +class MImmobilierConnector(BaseConnector): | |
| 44 | + source_id = "m_immobilier" | |
| 45 | + request_delay = 0.6 | |
| 46 | + | |
| 47 | + def fetch(self) -> list[Listing]: | |
| 48 | + out: dict[str, Listing] = {} | |
| 49 | + for page in range(1, MAX_PAGES + 1): | |
| 50 | + url = LISTING_URL if page == 1 else f"{LISTING_URL}&page={page}" | |
| 51 | + try: | |
| 52 | + html = self.get(url).text | |
| 53 | + except Exception: | |
| 54 | + break | |
| 55 | + cards = html.split(CARD_SPLIT)[1:] | |
| 56 | + fresh = 0 | |
| 57 | + for card in cards: | |
| 58 | + lst = self._parse_card(CARD_SPLIT + card[:6000]) | |
| 59 | + if lst and lst.uid not in out: | |
| 60 | + out[lst.uid] = lst | |
| 61 | + fresh += 1 | |
| 62 | + # dernière page atteinte (vide ou uniquement des doublons) | |
| 63 | + if not cards or fresh == 0: | |
| 64 | + break | |
| 65 | + listings = list(out.values()) | |
| 66 | + # fiche détail : galerie Centris complète + description + adresse pleine | |
| 67 | + du.enrich(self, listings, DETAIL_LIMIT, parse_m_detail, key="v1") | |
| 68 | + return listings | |
| 69 | + | |
| 70 | + def _parse_card(self, card: str) -> Listing | None: | |
| 71 | + m = HREF_RE.search(card) | |
| 72 | + if not m: | |
| 73 | + return None | |
| 74 | + url = BASE + m.group(1) | |
| 75 | + external_id = m.group(2) | |
| 76 | + | |
| 77 | + images = [] | |
| 78 | + for im in IMG_RE.findall(card): | |
| 79 | + full = BASE + im | |
| 80 | + if full not in images: | |
| 81 | + images.append(full) | |
| 82 | + | |
| 83 | + # texte du carton, ligne par ligne : badge statut, ville, adresse, | |
| 84 | + # extrait de description, puis paires libellé/valeur | |
| 85 | + text = _html.unescape(re.sub(r"<[^>]+>", "\n", card)) | |
| 86 | + lines = [l.strip() for l in text.splitlines() if l.strip()] | |
| 87 | + try: | |
| 88 | + k = next(i for i, l in enumerate(lines) | |
| 89 | + if l.lower().startswith(("à louer", "a louer"))) | |
| 90 | + except StopIteration: | |
| 91 | + return None # vente/vendu/loué : pas une location active | |
| 92 | + city_raw = lines[k + 1] if k + 1 < len(lines) else "" | |
| 93 | + address = lines[k + 2] if k + 2 < len(lines) else "" | |
| 94 | + snippet = lines[k + 3] if k + 3 < len(lines) else "" | |
| 95 | + | |
| 96 | + price_label = _after(lines, "prix") | |
| 97 | + if "/ m" not in price_label.lower(): | |
| 98 | + return None # garde-fou : loyer mensuel attendu | |
| 99 | + price = None | |
| 100 | + pm = _RENT_RE.search(price_label) | |
| 101 | + if pm: | |
| 102 | + try: | |
| 103 | + price = float(pm.group(1).replace(" ", "").replace(" ", "") | |
| 104 | + .replace(",", "")) | |
| 105 | + except ValueError: | |
| 106 | + price = None | |
| 107 | + | |
| 108 | + beds = _int(_after(lines, "chambres")) # « 3 + 1 » -> 4 | |
| 109 | + baths = _after(lines, "salles de bains") | |
| 110 | + sqft = _int(_after(lines, "pc")) # PC = pieds carrés | |
| 111 | + | |
| 112 | + # sans chambres, c'est souvent un local commercial loué au mois : | |
| 113 | + # on exige un marqueur résidentiel dans l'extrait (studio, bachelor…) | |
| 114 | + unit_type = f"{beds} chambres" if beds else "" | |
| 115 | + if beds is None: | |
| 116 | + if not _RESIDENTIAL_RE.search(snippet): | |
| 117 | + return None | |
| 118 | + if re.search(r"\b(studios?|bachelor)\b", snippet, re.I): | |
| 119 | + unit_type = "Studio" | |
| 120 | + | |
| 121 | + # ville « Montréal (Le Plateau-Mont-Royal) » -> ville + secteur ; | |
| 122 | + # les cartes tronquent à ~30 caractères (« Notr... ») -> on nettoie | |
| 123 | + city, sector = city_raw, "" | |
| 124 | + cm = re.match(r"^(.*?)\s*\(([^)]*)\)?\s*$", city_raw) | |
| 125 | + if cm and cm.group(2): | |
| 126 | + city, sector = cm.group(1).strip(), cm.group(2).strip() | |
| 127 | + city = city.rstrip(".").rstrip() | |
| 128 | + sector = re.sub(r"\.{2,}$", "", sector).rstrip("/ -") | |
| 129 | + | |
| 130 | + # adresse parfois tronquée (« 9017 Rue Jean-Baptiste-Gauthie... ») : | |
| 131 | + # on la laisse vide et le H1 de la fiche détail la complète | |
| 132 | + truncated = address.endswith("...") | |
| 133 | + title = f"{re.sub(r'[.]{3,}$', '', address)}, {city}".strip(", ") | |
| 134 | + | |
| 135 | + lst = Listing( | |
| 136 | + source=self.source_id, | |
| 137 | + external_id=external_id, | |
| 138 | + url=url, | |
| 139 | + title=title, | |
| 140 | + address="" if truncated else address, | |
| 141 | + sector=sector, | |
| 142 | + city=city, | |
| 143 | + unit_type=unit_type, | |
| 144 | + price=price, | |
| 145 | + price_label=price_label, | |
| 146 | + description=re.sub(r"[.]{3,}$", "", snippet), | |
| 147 | + details={"Agence": "M Immobilier", "No Centris": external_id}, | |
| 148 | + images=images, | |
| 149 | + ) | |
| 150 | + if baths: | |
| 151 | + lst.details["Salles de bain"] = baths.replace(" + ", "+") | |
| 152 | + if sqft: | |
| 153 | + lst.area_sqft = float(sqft) | |
| 154 | + return lst | |
| 155 | + | |
| 156 | + | |
| 157 | +def parse_m_detail(html: str) -> dict: | |
| 158 | + """Galerie Centris complète + description (JSON-LD) + adresse pleine (H1).""" | |
| 159 | + out: dict = {} | |
| 160 | + imgs = [] | |
| 161 | + seen = set() | |
| 162 | + for im in IMG_RE.findall(html): | |
| 163 | + full = BASE + im | |
| 164 | + if full not in seen: | |
| 165 | + seen.add(full) | |
| 166 | + imgs.append(full) | |
| 167 | + if imgs: | |
| 168 | + out["images"] = imgs | |
| 169 | + desc = du.ld_description(html) | |
| 170 | + if desc: | |
| 171 | + out["description"] = desc | |
| 172 | + m = _H1_RE.search(html) | |
| 173 | + if m: | |
| 174 | + h1 = _html.unescape(re.sub(r"<[^>]+>", " ", m.group(1))).strip() | |
| 175 | + if h1 and re.match(r"\d", h1): | |
| 176 | + out["address"] = h1 # complète les adresses tronquées | |
| 177 | + # pas de table de caractéristiques exploitable sur la fiche (seule la table | |
| 178 | + # des pièces existe, qui produirait du bruit) : galerie + description suffisent | |
| 179 | + return out | |
| 180 | + | |
| 181 | + | |
| 182 | +def _after(lines: list[str], label: str) -> str: | |
| 183 | + lab = label.lower() | |
| 184 | + for i, l in enumerate(lines): | |
| 185 | + if l.lower() == lab and i + 1 < len(lines): | |
| 186 | + return lines[i + 1] | |
| 187 | + return "" | |
| 188 | + | |
| 189 | + | |
| 190 | +def _int(s: str): | |
| 191 | + if not s: | |
| 192 | + return None | |
| 193 | + # « 3 + 1 » -> 4 (chambres principales + sous-sol) | |
| 194 | + nums = [int(x) for x in re.findall(r"\d+", s)] | |
| 195 | + return sum(nums) if nums else None | |
added
louka/connectors/proprio_direct.py
+196 −0
@@ -0,0 +1,196 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/proprio_direct.py : Proprio Direct (propriodirect.com) — LOCATION | |
| 5 | +# L'inventaire (alimenté par le feed DDF de CREA) est servi par une API JSON | |
| 6 | +# interne : POST https://propriodirect.com/fr/api/searchListings | |
| 7 | +# Corps { "from": <offset>, "filter": {"rentOrSale": "rent"} } — le filtre | |
| 8 | +# côté API fonctionne (~260 locations vs ~3200 annonces au total) ; 30 | |
| 9 | +# résultats/page, champ `total`. Chaque item porte forRent/forSale/sold, | |
| 10 | +# le loyer en cents (rentPriceInCents, rentPriceFrequency), adresse, ville, | |
| 11 | +# région, chambres, sdb, GPS, tags et galerie photos. On ne garde que le | |
| 12 | +# volet RÉSIDENTIEL (tag « residential ») : les locaux commerciaux et | |
| 13 | +# industriels (loyer en $/pi²/an) sont écartés. Adapté du connecteur | |
| 14 | +# « à vendre » d'Immo-Ka (agent-courtage/immoka). | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import os | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from ..schema import Listing | |
| 22 | +from .base import BaseConnector | |
| 23 | + | |
| 24 | +from . import _detailutil as du | |
| 25 | + | |
| 26 | +SITE = "https://propriodirect.com" | |
| 27 | +SEARCH_URL = f"{SITE}/fr/api/searchListings" | |
| 28 | +PAGE_SIZE = 30 | |
| 29 | +MAX_PAGES = 60 # garde-fou (~260 locations = 9 pages) | |
| 30 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_PD_DETAIL_LIMIT", "400")) | |
| 31 | + | |
| 32 | +# tags source (feed DDF) -> caractéristiques lisibles | |
| 33 | +_TAG_LABELS = { | |
| 34 | + "car_garage": "Garage", "car_parking": "Stationnement", "car_foyer": "Foyer", | |
| 35 | + "car_piscine": "Piscine", "car_climatisation": "Climatisation", | |
| 36 | + "car_sous_sol": "Sous-sol", "car_bord_eau": "Bord de l'eau", | |
| 37 | + "prox_park": "Parc à proximité", "prox_ecole_primaire": "École primaire", | |
| 38 | + "prox_ecole_secondaire": "École secondaire", "prox_transport": "Transport en commun", | |
| 39 | + "prox_garderie": "Garderie", "prox_piste_cyclable": "Piste cyclable", | |
| 40 | + "prox_hopital": "Hôpital", "prox_autoroute": "Autoroute", | |
| 41 | + "new_price": "Nouveau prix", | |
| 42 | +} | |
| 43 | + | |
| 44 | + | |
| 45 | +class ProprioDirectConnector(BaseConnector): | |
| 46 | + source_id = "proprio_direct" | |
| 47 | + request_delay = 0.4 | |
| 48 | + | |
| 49 | + def _headers(self) -> dict: | |
| 50 | + return { | |
| 51 | + "Accept": "application/json", | |
| 52 | + "Content-Type": "application/json", | |
| 53 | + "Origin": SITE, | |
| 54 | + "Referer": f"{SITE}/propriete-a-louer/", | |
| 55 | + } | |
| 56 | + | |
| 57 | + def fetch(self) -> list[Listing]: | |
| 58 | + out: list[Listing] = [] | |
| 59 | + seen: set[str] = set() | |
| 60 | + offset = 0 | |
| 61 | + for _ in range(MAX_PAGES): | |
| 62 | + try: | |
| 63 | + data = self.post(SEARCH_URL, headers=self._headers(), | |
| 64 | + json={"from": offset, | |
| 65 | + "includeGeoJson": False, | |
| 66 | + "filter": {"rentOrSale": "rent"}}).json() | |
| 67 | + except Exception: | |
| 68 | + break | |
| 69 | + items = data.get("listings", []) if isinstance(data, dict) else [] | |
| 70 | + if not items: | |
| 71 | + break | |
| 72 | + for it in items: | |
| 73 | + lst = self._to_listing(it) | |
| 74 | + if lst and lst.uid not in seen: | |
| 75 | + seen.add(lst.uid) | |
| 76 | + out.append(lst) | |
| 77 | + total = data.get("total") or 0 | |
| 78 | + offset += PAGE_SIZE | |
| 79 | + if offset >= total: | |
| 80 | + break | |
| 81 | + # fiche détail : description (la galerie est déjà complète via l'API) | |
| 82 | + du.enrich(self, out, DETAIL_LIMIT, _parse_pd_detail, key="v3") | |
| 83 | + return out | |
| 84 | + | |
| 85 | + def _to_listing(self, it: dict) -> Listing | None: | |
| 86 | + rid = it.get("id") | |
| 87 | + tags = it.get("tags") or [] | |
| 88 | + # location résidentielle seulement (ceinture + bretelles : le filtre | |
| 89 | + # API renvoie déjà uniquement des locations, on revalide chaque item) | |
| 90 | + if (not rid or it.get("sold") or not it.get("forRent") | |
| 91 | + or "residential" not in tags): | |
| 92 | + return None | |
| 93 | + # loyer mensuel : rentPriceInCents (fallback priceInCents si le feed | |
| 94 | + # met le loyer dans le prix principal) — on écarte les baux annuels | |
| 95 | + # au pi² qui se seraient glissés dans le volet résidentiel | |
| 96 | + freq = it.get("rentPriceFrequency") or "monthly" | |
| 97 | + if freq != "monthly": | |
| 98 | + return None | |
| 99 | + cents = it.get("rentPriceInCents") or it.get("priceInCents") | |
| 100 | + price = (cents / 100.0) if isinstance(cents, (int, float)) and cents > 0 else None | |
| 101 | + | |
| 102 | + slug = it.get("slugURLFr") or it.get("slugURL") or "" | |
| 103 | + url = f"{SITE}{slug}" if slug.startswith("/") else (slug or SITE) | |
| 104 | + geo = it.get("geoLocation") or {} | |
| 105 | + | |
| 106 | + # type d'unité : nb de chambres (normalisé n+2 ½ par finalize()) ; | |
| 107 | + # le genre Loft/Studio (LS) sans chambre est un studio | |
| 108 | + genre = (it.get("genre") or "").upper() | |
| 109 | + rooms = _pos_int(it.get("numberOfRooms")) | |
| 110 | + unit_type = f"{rooms} chambres" if rooms else ("Studio" if genre == "LS" else "") | |
| 111 | + | |
| 112 | + details: dict = {"Courtier": "Proprio Direct"} | |
| 113 | + if it.get("genreName"): | |
| 114 | + details["Type"] = it["genreName"] | |
| 115 | + if it.get("regionName"): | |
| 116 | + details["Région"] = it["regionName"] | |
| 117 | + baths = _pos_int(it.get("numberOfBathrooms")) | |
| 118 | + if baths: | |
| 119 | + details["Salles de bain"] = str(baths) | |
| 120 | + if it.get("postalCode"): | |
| 121 | + details["Code postal"] = it["postalCode"] | |
| 122 | + | |
| 123 | + return Listing( | |
| 124 | + source=self.source_id, | |
| 125 | + external_id=str(rid), | |
| 126 | + url=url, | |
| 127 | + title=it.get("addressLine", ""), | |
| 128 | + address=it.get("addressLine", ""), | |
| 129 | + sector=it.get("neighbourhood", "") or "", | |
| 130 | + city=it.get("cityName", ""), | |
| 131 | + unit_type=unit_type, | |
| 132 | + price=price, | |
| 133 | + price_label=it.get("rentPriceText", "") or "", | |
| 134 | + amenities=[_TAG_LABELS[t] for t in tags if t in _TAG_LABELS], | |
| 135 | + details=details, | |
| 136 | + images=list(it.get("photosOriginal") or it.get("photosSmall") or []), | |
| 137 | + lat=geo.get("lat"), | |
| 138 | + lng=geo.get("lon"), | |
| 139 | + ) | |
| 140 | + | |
| 141 | + | |
| 142 | +# libellés des « Points saillants » d'une fiche Proprio Direct — la page les | |
| 143 | +# rend en liste « libellé | valeur » stricte (contrairement aux fiches Centris, | |
| 144 | +# d'où un parseur dédié plutôt que du.centris_details qui appariait de travers) | |
| 145 | +_PD_LABELS = [ | |
| 146 | + "Année de construction", "Superficie habitable", "Niveau", | |
| 147 | + "Équipement disponible", "Zonage", "Mode de chauffage", | |
| 148 | + "Énergie pour le chauffage", "Approvisionnement en eau", | |
| 149 | + "Système d'égouts", "Restrictions/Permissions", | |
| 150 | + "Garage", "Piscine", "Foyer-Poêle", "Meublé", "Animaux", | |
| 151 | + "Disponibilité", "Date d'emménagement", "Bail", | |
| 152 | +] | |
| 153 | +_PD_SECTIONS = {"points saillants", "détails", "pièces", "inclus", "exclus", | |
| 154 | + "style de vie", "description"} | |
| 155 | + | |
| 156 | + | |
| 157 | +def _pd_details(text: str) -> dict: | |
| 158 | + """Paires « libellé | valeur » des Points saillants (ordre strict).""" | |
| 159 | + stop = {l.lower() for l in _PD_LABELS} | _PD_SECTIONS | |
| 160 | + out: dict = {} | |
| 161 | + for label in _PD_LABELS: | |
| 162 | + m = re.search(re.escape(label) + r"\s*\|\s*([^|]{1,90})", text) | |
| 163 | + if m: | |
| 164 | + val = m.group(1).strip(" |") | |
| 165 | + if val and val.lower() not in stop: | |
| 166 | + out[label] = val | |
| 167 | + return out | |
| 168 | + | |
| 169 | + | |
| 170 | +def _parse_pd_detail(html: str) -> dict: | |
| 171 | + """Fiche Proprio Direct : description + points saillants | |
| 172 | + (photos déjà fournies par l'API).""" | |
| 173 | + out: dict = {} | |
| 174 | + desc = du.ld_description(html) | |
| 175 | + # le JSON-LD des fiches sans texte retombe sur la description « méta » | |
| 176 | + # générique (« … | No. Inscription 12345678 | Proprio Direct ») — on l'écarte | |
| 177 | + if desc and "No. Inscription" not in desc: | |
| 178 | + out["description"] = desc | |
| 179 | + det = _pd_details(du.flatten(html)) | |
| 180 | + if det: | |
| 181 | + out.setdefault("details", {}).update(det) | |
| 182 | + # « Animaux permis sous conditions » / « Animaux non admis »… | |
| 183 | + restr = det.get("Restrictions/Permissions", "").lower() | |
| 184 | + if "animaux" in restr: | |
| 185 | + out["pets"] = ("conditions" if "condition" in restr | |
| 186 | + else "non" if ("non" in restr or "pas" in restr) | |
| 187 | + else "oui") | |
| 188 | + return out | |
| 189 | + | |
| 190 | + | |
| 191 | +def _pos_int(v): | |
| 192 | + try: | |
| 193 | + n = int(v) | |
| 194 | + return n if n > 0 else None | |
| 195 | + except (TypeError, ValueError): | |
| 196 | + return None | |
added
louka/connectors/remax_quebec.py
+406 −0
@@ -0,0 +1,406 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/remax_quebec.py : RE/MAX Québec (remax-quebec.com) — LOCATIONS | |
| 5 | +# Le site interroge un index Meilisearch public (search-only key exposée dans | |
| 6 | +# la config de la page). L'index « inscriptions » couvre toute la province et | |
| 7 | +# mélange ventes et locations : on ne garde QUE les slugs « -a-louer » / | |
| 8 | +# « -for-rent » (et on écarte les Loué/Vendu que l'index conserve). | |
| 9 | +# Meilisearch plafonne à 1000 hits par requête (maxTotalHits) : on shard donc | |
| 10 | +# par région de tri d'acheminement postale (FSA, 3 premiers caractères) et on | |
| 11 | +# dédoublonne par numéro d'inscription. Adapté du connecteur « à vendre » | |
| 12 | +# d'Immo-Ka (agent-courtage/immoka). | |
| 13 | +# ----------------------------------------------------------------------------- | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import html as _html | |
| 17 | +import os | |
| 18 | +import re | |
| 19 | + | |
| 20 | +from ..normalize import parse_area_sqft, parse_price | |
| 21 | +from ..schema import Listing | |
| 22 | +from .base import BaseConnector | |
| 23 | + | |
| 24 | +from . import _detailutil as du | |
| 25 | + | |
| 26 | +SEARCH_URL = "https://search.remax-quebec.com/indexes/inscriptions/search" | |
| 27 | +# Clé de recherche (search-only) exposée publiquement dans la config du site. | |
| 28 | +SEARCH_KEY = "b0b93998ab78573e8b937b528ad37d2ce3fbc97e07a9f2c909c4220db910c152" | |
| 29 | +SITE = "https://www.remax-quebec.com" | |
| 30 | + | |
| 31 | +# Territoires postaux du Québec : préfixes G, H et J. | |
| 32 | +FSA_LETTERS = ("G", "H", "J") | |
| 33 | +# annonces conclues que l'index conserve encore quelque temps | |
| 34 | +GONE_TOKENS = ("loué", "loue", "rented", "vendu", "sold") | |
| 35 | + | |
| 36 | +# Enrichissement page détail (photos + description + specs) : plafonné par | |
| 37 | +# exécution pour garder chaque sync borné. Les fiches en cache sont réutilisées | |
| 38 | +# gratis ; le parc se complète sur plusieurs cycles. | |
| 39 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_REMAX_DETAIL_LIMIT", "400")) | |
| 40 | + | |
| 41 | +# slug « condo-a-louer-montreal/... » -> type affichable. Les appartements / | |
| 42 | +# condos / plex restent sans unit_type figé : la fiche détail fournit le nombre | |
| 43 | +# de chambres (converti en n+2½ par la normalisation lou-ka). | |
| 44 | +_TYPE_SLUG = { | |
| 45 | + "maison": "Maison", "house": "Maison", | |
| 46 | + "chalet": "Chalet", "cottage": "Chalet", | |
| 47 | + "loft": "Loft", | |
| 48 | + "condo": "Condo", "appartement": "Appartement", "apartment": "Appartement", | |
| 49 | + "plex": "Multiplex", "duplex": "Duplex", "triplex": "Triplex", | |
| 50 | +} | |
| 51 | +# ce qui n'est pas un logement (lou-ka = résidentiel locatif uniquement) | |
| 52 | +_EXCLUDE_SLUG = ("commercial", "commerciale", "terrain", "land", "industriel", | |
| 53 | + "bureau", "ferme", "fermette") | |
| 54 | +_RENT_SLUG_RE = re.compile(r"-(?:a-louer|for-rent)\b") | |
| 55 | + | |
| 56 | + | |
| 57 | +class RemaxQuebecConnector(BaseConnector): | |
| 58 | + source_id = "remax_quebec" | |
| 59 | + request_delay = 0.15 # API JSON rapide ; on reste poli | |
| 60 | + | |
| 61 | + def fetch(self) -> list[Listing]: | |
| 62 | + by_id: dict[int, dict] = {} | |
| 63 | + for fsa in self._fsa_candidates(): | |
| 64 | + for h in self._search(fsa): | |
| 65 | + nid = h.get("no_inscription") | |
| 66 | + if nid is not None: | |
| 67 | + by_id[nid] = h # dédoublonnage inter-shards | |
| 68 | + listings = [] | |
| 69 | + for h in by_id.values(): | |
| 70 | + lst = self._to_listing(h) | |
| 71 | + if lst is not None: | |
| 72 | + listings.append(lst) | |
| 73 | + du.enrich(self, listings, DETAIL_LIMIT, parse_remax_detail, key="v1") | |
| 74 | + for lst in listings: | |
| 75 | + _unit_from_bedrooms(lst) | |
| 76 | + return listings | |
| 77 | + | |
| 78 | + # -- sharding -------------------------------------------------------------- | |
| 79 | + @staticmethod | |
| 80 | + def _fsa_candidates(): | |
| 81 | + for letter in FSA_LETTERS: | |
| 82 | + for digit in "0123456789": | |
| 83 | + for last in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": | |
| 84 | + yield f"{letter}{digit}{last}" | |
| 85 | + | |
| 86 | + def _search(self, q: str) -> list[dict]: | |
| 87 | + try: | |
| 88 | + resp = self.post( | |
| 89 | + SEARCH_URL, | |
| 90 | + headers={"Authorization": f"Bearer {SEARCH_KEY}", | |
| 91 | + "Content-Type": "application/json"}, | |
| 92 | + json={"q": q, "limit": 1000}, | |
| 93 | + ) | |
| 94 | + except Exception: | |
| 95 | + return [] | |
| 96 | + data = resp.json() | |
| 97 | + return data.get("hits", []) if isinstance(data, dict) else [] | |
| 98 | + | |
| 99 | + # -- mapping --------------------------------------------------------------- | |
| 100 | + def _to_listing(self, h: dict) -> Listing | None: | |
| 101 | + nid = h.get("no_inscription") | |
| 102 | + if nid is None: | |
| 103 | + return None | |
| 104 | + slug = (h.get("slug") or {}).get("fr", "") or "" | |
| 105 | + slug_en = (h.get("slug") or {}).get("en", "") or "" | |
| 106 | + # UNIQUEMENT les locations (l'index mélange ventes et locations) ; | |
| 107 | + # l'URL de la fiche exige le slug français | |
| 108 | + if not slug or not (_RENT_SLUG_RE.search(slug) or _RENT_SLUG_RE.search(slug_en)): | |
| 109 | + return None | |
| 110 | + price_label = (h.get("display_price") or {}).get("fr", "") or "" | |
| 111 | + # exclure les logements déjà loués / retirés (l'index les conserve) | |
| 112 | + if any(tok in price_label.lower() for tok in GONE_TOKENS): | |
| 113 | + return None | |
| 114 | + prop_type, region = _from_slug(slug) | |
| 115 | + if prop_type is None: # commercial/terrain : pas un logement | |
| 116 | + return None | |
| 117 | + | |
| 118 | + url = f"{SITE}/fr/proprietes/{slug}" | |
| 119 | + full_addr = (h.get("full_address") or {}).get("fr", "") or "" | |
| 120 | + address, sector, city = _split_address(full_addr) | |
| 121 | + | |
| 122 | + details: dict = {"MLS": str(nid)} | |
| 123 | + if prop_type: | |
| 124 | + details["Type"] = prop_type | |
| 125 | + if region: | |
| 126 | + details["Région"] = region | |
| 127 | + return Listing( | |
| 128 | + source=self.source_id, | |
| 129 | + external_id=str(nid), | |
| 130 | + url=url, | |
| 131 | + title=address or full_addr, | |
| 132 | + address=address, | |
| 133 | + sector=sector, | |
| 134 | + city=city, | |
| 135 | + # appartement/condo/plex : la fiche détail donne les chambres | |
| 136 | + unit_type=prop_type if prop_type in ("Maison", "Chalet", "Loft") else "", | |
| 137 | + price=parse_price(price_label), # « 2 690$ par mois » -> 2690.0 | |
| 138 | + price_label=price_label, | |
| 139 | + details=details, | |
| 140 | + ) | |
| 141 | + | |
| 142 | + | |
| 143 | +def _unit_from_bedrooms(lst: Listing) -> None: | |
| 144 | + """Condo/appartement : « Chambres » de la fiche détail -> unit_type | |
| 145 | + « n chambres » (la normalisation lou-ka convertit en n+2½).""" | |
| 146 | + if lst.unit_type not in ("", "Condo", "Appartement"): | |
| 147 | + return | |
| 148 | + m = re.match(r"\d+", str(lst.details.get("Chambres", ""))) | |
| 149 | + if not m: | |
| 150 | + if not lst.unit_type: | |
| 151 | + lst.unit_type = lst.details.get("Type", "") | |
| 152 | + return | |
| 153 | + n = int(m.group(0)) | |
| 154 | + lst.unit_type = "Studio" if n == 0 else f"{n} chambres" | |
| 155 | + | |
| 156 | + | |
| 157 | +# --------------------------------------------------------------------------- | |
| 158 | +# Analyse de l'adresse et du slug | |
| 159 | +# --------------------------------------------------------------------------- | |
| 160 | + | |
| 161 | +_PAREN_RE = re.compile(r"\(([^)]*)\)") | |
| 162 | +_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I) | |
| 163 | + | |
| 164 | + | |
| 165 | +def _split_address(full: str) -> tuple[str, str, str]: | |
| 166 | + """« 9561 Boul. Perras, Montréal (Rivière-des-Prairies/…) (RDP), H1E4C4 » | |
| 167 | + -> (adresse, secteur, ville).""" | |
| 168 | + if not full: | |
| 169 | + return "", "", "" | |
| 170 | + parts = [p.strip() for p in full.split(",")] | |
| 171 | + # retirer le code postal final | |
| 172 | + if parts and _POSTAL_RE.search(parts[-1]): | |
| 173 | + parts = parts[:-1] | |
| 174 | + address = parts[0] if parts else "" | |
| 175 | + city = sector = "" | |
| 176 | + if len(parts) >= 2: | |
| 177 | + muni = parts[1] | |
| 178 | + parens = _PAREN_RE.findall(muni) | |
| 179 | + city = _PAREN_RE.sub("", muni).strip() | |
| 180 | + if parens: | |
| 181 | + sector = parens[-1].strip() | |
| 182 | + # secteur supplémentaire dans les champs suivants (avant le postal) | |
| 183 | + for extra in parts[2:]: | |
| 184 | + e = _PAREN_RE.sub("", extra).strip() or extra.strip() | |
| 185 | + if e and not sector: | |
| 186 | + sector = e | |
| 187 | + return address, sector, city | |
| 188 | + | |
| 189 | + | |
| 190 | +def _from_slug(slug: str) -> tuple[str | None, str]: | |
| 191 | + """slug « condo-a-louer-montreal/9561-boul-perras-…-20357732 » | |
| 192 | + -> (type affichable, région). Type None = pas un logement (commercial…).""" | |
| 193 | + if not slug: | |
| 194 | + return "", "" | |
| 195 | + head = slug.split("/", 1)[0] # condo-a-louer-montreal | |
| 196 | + if any(re.search(rf"\b{x}", head) for x in _EXCLUDE_SLUG): | |
| 197 | + return None, "" | |
| 198 | + prop_type = "" | |
| 199 | + for key, canon in _TYPE_SLUG.items(): | |
| 200 | + if re.search(rf"\b{key}", head): | |
| 201 | + prop_type = canon | |
| 202 | + break | |
| 203 | + # région = ce qui suit « -a-louer-/-for-rent- » | |
| 204 | + m = re.search(r"(?:a-louer|for-rent)-(.+)$", head) | |
| 205 | + region = m.group(1).replace("-", " ").title() if m else "" | |
| 206 | + return prop_type, region | |
| 207 | + | |
| 208 | + | |
| 209 | +# --------------------------------------------------------------------------- | |
| 210 | +# Page détail : photos + description + caractéristiques + courtier | |
| 211 | +# --------------------------------------------------------------------------- | |
| 212 | + | |
| 213 | +# Les photos apparaissent dans plusieurs buckets de taille (www_full, _medium, | |
| 214 | +# _small…) selon le lazy-load ; on les capte toutes et on les normalise en | |
| 215 | +# pleine résolution, dédoublonnées par nom de fichier. | |
| 216 | +_IMG_RE = re.compile( | |
| 217 | + r'https://media\.remax-quebec\.com/img/www_[a-z]+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)', | |
| 218 | + re.I) | |
| 219 | +_IMG_SIZE_RE = re.compile(r"/www_[a-z]+/", re.I) | |
| 220 | +_COORD_RE = re.compile(r"query=(-?\d+\.\d+)%2C\+?(-?\d+\.\d+)") | |
| 221 | +# en tête de fiche, la valeur précède le libellé : « 2 | Chambres », « 1 | Salle de bain » | |
| 222 | +_BED_RE = re.compile(r"(\d+)(?:\s*\([^)]*\))?\s*\|\s*Chambres?\b", re.I) | |
| 223 | +_BATH_RE = re.compile(r"(\d+)\s*\|\s*Salles? de bain", re.I) | |
| 224 | +_WATER_RE = re.compile(r"(\d+)\s*\|\s*Salles? d'eau", re.I) | |
| 225 | +_QSTAT_RE = re.compile( | |
| 226 | + r"quick-stat-text[^>]*>\s*<span[^>]*>\s*(.*?)\s*</span>\s*<span[^>]*>\s*(.*?)\s*</span>", re.S) | |
| 227 | +_INCL_RE = re.compile( | |
| 228 | + r"inclusions-exclusions-section(.*?)(?:</section>|realtor-section|financial-section)", re.S) | |
| 229 | +_ROOM_RE = re.compile(r"rooms-details-section__vertical-table[^>]*>(.*?)</div>\s*</div>", re.S) | |
| 230 | + | |
| 231 | +# Sur les fiches LOCATION, le tableau « Particularités » est en ordre | |
| 232 | +# libellé | valeur (l'inverse des fiches vente) : on privilégie donc cet ordre | |
| 233 | +# et du.centris_details ne sert que de repli. | |
| 234 | +_RENT_LABELS = [ | |
| 235 | + "Type de propriété", "Genre de propriété", "Style de bâtiment", | |
| 236 | + "Année de construction", "Superficie habitable", "Superficie du terrain", | |
| 237 | + "Nombre de pièces", "Nombre d'unités", "Stationnement (total)", "Garage", | |
| 238 | + "Mode de chauffage", "Système de chauffage", "Énergie pour le chauffage", | |
| 239 | + "Approvisionnement en eau", "Système d'égouts", "Piscine", "Déménagement", | |
| 240 | + "Date d'emménagement", "Disponibilité", "Bail", "Durée du bail", "Meublé", | |
| 241 | + "Animaux", "Inclus dans le loyer", "Cuisine", | |
| 242 | +] | |
| 243 | + | |
| 244 | + | |
| 245 | +# une « valeur » qui est en fait un titre de section = extraction décalée | |
| 246 | +_SECTION_VAL_RE = re.compile( | |
| 247 | + r"^(?:Particularit[ée]s|Caract[ée]ristiques|Inclusions|Exclusions|Addenda)", | |
| 248 | + re.I) | |
| 249 | + | |
| 250 | + | |
| 251 | +def _labels_first(text: str, labels: list[str]) -> dict: | |
| 252 | + """Extraction « libellé | valeur » (ordre des fiches location RE/MAX).""" | |
| 253 | + out: dict = {} | |
| 254 | + for label in labels: | |
| 255 | + boundary = r"\b" if label[-1].isalnum() else "" | |
| 256 | + m = re.search(re.escape(label) + boundary + r"\s*\|\s*([^|]{1,55})", text) | |
| 257 | + if m: | |
| 258 | + val = m.group(1).strip(" |") | |
| 259 | + if (val and 1 <= len(val) <= 55 and val.lower() != label.lower() | |
| 260 | + and val not in labels and not _SECTION_VAL_RE.match(val)): | |
| 261 | + out[label] = val | |
| 262 | + return out | |
| 263 | + | |
| 264 | + | |
| 265 | +def _drop_section_values(details: dict) -> None: | |
| 266 | + """Purge les valeurs qui sont des titres de section (repli valeur|libellé | |
| 267 | + de du.centris_details décalé sur les fiches en ordre libellé|valeur).""" | |
| 268 | + for k in [k for k, v in details.items() | |
| 269 | + if isinstance(v, str) and _SECTION_VAL_RE.match(v)]: | |
| 270 | + del details[k] | |
| 271 | + | |
| 272 | + | |
| 273 | +def _ld_agents(html: str): | |
| 274 | + """Nœuds RealEstateAgent (dans l'ordre du document), même imbriqués.""" | |
| 275 | + for n in du.ld_nodes(html): | |
| 276 | + queue = [n] | |
| 277 | + while queue: | |
| 278 | + cur = queue.pop(0) | |
| 279 | + if isinstance(cur, dict): | |
| 280 | + if cur.get("@type") == "RealEstateAgent" and cur.get("name"): | |
| 281 | + yield cur | |
| 282 | + queue.extend(cur.values()) | |
| 283 | + elif isinstance(cur, list): | |
| 284 | + queue.extend(cur) | |
| 285 | + | |
| 286 | + | |
| 287 | +def parse_remax_detail(html: str) -> dict: | |
| 288 | + """Fiche RE/MAX location : description JSON-LD, galerie pleine résolution, | |
| 289 | + GPS, caractéristiques Centris, pièces, inclusions, courtier/agence.""" | |
| 290 | + out: dict = {} | |
| 291 | + details: dict = {} | |
| 292 | + | |
| 293 | + # description via JSON-LD RealEstateListing | |
| 294 | + desc = du.ld_description(html) | |
| 295 | + if desc: | |
| 296 | + out["description"] = desc[:6000] | |
| 297 | + | |
| 298 | + # photos : toutes tailles -> pleine résolution, dédoublonnées par fichier | |
| 299 | + seen, images = set(), [] | |
| 300 | + for u in _IMG_RE.findall(html): | |
| 301 | + full = _IMG_SIZE_RE.sub("/www_full/", u) | |
| 302 | + fn = full.rsplit("/", 1)[-1] | |
| 303 | + if fn not in seen and "nophoto" not in full: | |
| 304 | + seen.add(fn) | |
| 305 | + images.append(full) | |
| 306 | + if images: | |
| 307 | + out["images"] = images | |
| 308 | + | |
| 309 | + # coordonnées GPS (lien Google Maps) | |
| 310 | + m = _COORD_RE.search(html) | |
| 311 | + if m: | |
| 312 | + lat, lng = float(m.group(1)), float(m.group(2)) | |
| 313 | + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0: | |
| 314 | + out["lat"], out["lng"] = lat, lng | |
| 315 | + | |
| 316 | + # courtier + agence via JSON-LD RealEstateAgent (souvent imbriqué) | |
| 317 | + for n in _ld_agents(html): | |
| 318 | + details.setdefault("Courtier", str(n["name"]).strip().title()) | |
| 319 | + if n.get("telephone"): | |
| 320 | + details.setdefault("Téléphone", str(n["telephone"]).strip()) | |
| 321 | + org = n.get("parentOrganization") or {} | |
| 322 | + org_name = org.get("name", "") if isinstance(org, dict) else "" | |
| 323 | + if org_name: | |
| 324 | + agence = str(org_name).strip().title() \ | |
| 325 | + .replace("Re/Max", "RE/MAX").replace("Inc.", "inc.") | |
| 326 | + details.setdefault("Agence", agence) | |
| 327 | + | |
| 328 | + text = du.flatten(html) | |
| 329 | + | |
| 330 | + # chambres / salles de bain (en tête : « 2 | Chambres », « 1 | Salle de bain ») | |
| 331 | + m = _BED_RE.search(text) | |
| 332 | + if m: | |
| 333 | + details["Chambres"] = m.group(1) | |
| 334 | + m = _BATH_RE.search(text) | |
| 335 | + if m: | |
| 336 | + details["Salles de bain"] = m.group(1) | |
| 337 | + m = _WATER_RE.search(text) | |
| 338 | + if m: | |
| 339 | + details["Salles d'eau"] = m.group(1) | |
| 340 | + | |
| 341 | + # caractéristiques : repli valeur|libellé (du), écrasé par libellé|valeur | |
| 342 | + details.update(du.centris_details(text)) | |
| 343 | + details.update(_labels_first(text, _RENT_LABELS)) | |
| 344 | + _drop_section_values(details) | |
| 345 | + # « Cuisine » happé depuis le tableau des pièces (dimensions/niveau) : retirer | |
| 346 | + if re.search(r"\d\s*[xX]\s*\d|Niveau|^\d+$", str(details.get("Cuisine", ""))): | |
| 347 | + details.pop("Cuisine", None) | |
| 348 | + | |
| 349 | + # quick stats (Nb de pièces, Superficie habitable…) | |
| 350 | + for label, val in _QSTAT_RE.findall(html): | |
| 351 | + label = _html.unescape(re.sub(r"\s+", " ", label)).strip() | |
| 352 | + val = _html.unescape(re.sub(r"\s+", " ", val)).strip() | |
| 353 | + if label and val and label not in details: | |
| 354 | + details[label] = val | |
| 355 | + | |
| 356 | + # pièces avec dimensions/niveau/revêtement | |
| 357 | + rooms = [] | |
| 358 | + for blk in _ROOM_RE.findall(html): | |
| 359 | + rt = _html.unescape(re.sub(r"<[^>]+>", " ", blk)) | |
| 360 | + rt = re.sub(r"\s+", " ", rt).strip() | |
| 361 | + name = re.split(r"Niveau\s*:", rt)[0].strip() | |
| 362 | + niveau = re.search(r"Niveau\s*:\s*([^:]+?)(?:Dimensions|Revêtement|$)", rt) | |
| 363 | + dim = re.search(r"Dimensions\s*:\s*([0-9\'\".,X x×]+)", rt) | |
| 364 | + rev = re.search(r"Revêtement\s*:\s*([A-Za-zÀ-ÿ ,-]+?)(?:\s*Détails|\s*$)", rt) | |
| 365 | + if name: | |
| 366 | + rooms.append({ | |
| 367 | + "nom": name[:40], | |
| 368 | + "niveau": (niveau.group(1).strip() if niveau else ""), | |
| 369 | + "dimensions": (dim.group(1).strip() if dim else ""), | |
| 370 | + "revetement": (rev.group(1).strip() if rev else ""), | |
| 371 | + }) | |
| 372 | + if rooms: | |
| 373 | + details["pieces"] = rooms[:20] | |
| 374 | + | |
| 375 | + # inclusions / exclusions -> commodités affichables | |
| 376 | + mi = _INCL_RE.search(html) | |
| 377 | + feats = [] | |
| 378 | + if mi: | |
| 379 | + blk = _html.unescape(re.sub(r"<[^>]+>", "\n", mi.group(1))) | |
| 380 | + for line in blk.split("\n"): | |
| 381 | + line = line.strip(" \t•-") | |
| 382 | + if 3 <= len(line) <= 120 and not line.lower().startswith( | |
| 383 | + ("inclusion", "exclusion")): | |
| 384 | + feats.append(line) | |
| 385 | + if feats: | |
| 386 | + out["amenities"] = feats[:25] | |
| 387 | + | |
| 388 | + # superficie habitable (quick stat ou tableau) -> pi² | |
| 389 | + if details.get("Superficie habitable"): | |
| 390 | + out["area_sqft"] = parse_area_sqft(details["Superficie habitable"]) | |
| 391 | + # date d'emménagement / déménagement -> disponibilité | |
| 392 | + for k in ("Date d'emménagement", "Déménagement", "Disponibilité"): | |
| 393 | + if details.get(k): | |
| 394 | + out["availability"] = details[k] | |
| 395 | + break | |
| 396 | + # chambres -> type d'unité (les fiches liste condo/appartement sont vides) | |
| 397 | + if details.get("Chambres"): | |
| 398 | + try: | |
| 399 | + n = int(details["Chambres"]) | |
| 400 | + out["unit_type"] = "Studio" if n == 0 else f"{n} chambres" | |
| 401 | + except ValueError: | |
| 402 | + pass | |
| 403 | + | |
| 404 | + if details: | |
| 405 | + out["details"] = details | |
| 406 | + return out | |
added
louka/connectors/royal_lepage.py
+191 −0
@@ -0,0 +1,191 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/royal_lepage.py : Royal LePage (royallepage.ca) — LOCATIONS Québec | |
| 5 | +# | |
| 6 | +# Le portail rend ses résultats côté serveur. La recherche par ville est | |
| 7 | +# filtrable : /fr/search/homes/{page}/?search_str=...&prov_code=QC&city_name= | |
| 8 | +# ...&lat=..&lng=..&search_type=city + « transactionType=LEASE » (trouvé dans | |
| 9 | +# le formulaire caché du site) renvoie les propriétés À LOUER (46/page, | |
| 10 | +# prix « 1 850.00 $ /mois » sur la carte). Chaque recherche plafonne à | |
| 11 | +# ~1 250 résultats ; on SHARD par points d'ancrage répartis dans la province | |
| 12 | +# et on dédoublonne par numéro MLS (dans l'URL /.../mls{no}/). Aucune | |
| 13 | +# dépendance Firecrawl : requêtes directes. Adapté du connecteur « à vendre » | |
| 14 | +# d'Immo-Ka (agent-courtage/immoka). | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import html as _html | |
| 19 | +import os | |
| 20 | +import re | |
| 21 | + | |
| 22 | +from .base import BaseConnector | |
| 23 | +from . import _detailutil as du | |
| 24 | +from ..schema import Listing | |
| 25 | + | |
| 26 | +BASE = "https://www.royallepage.ca/fr/search/homes" | |
| 27 | +MAX_PAGES = 30 # une recherche plafonne à ~27 pages (~1250) | |
| 28 | +# Le nom du BUREAU (« Royal LePage Humania »…) n'est pas sur la carte : il vient | |
| 29 | +# de la fiche détail. Enrichissement plafonné + cache (se remplit au fil des cycles). | |
| 30 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_RLP_DETAIL_LIMIT", "300")) | |
| 31 | +_OFFICE_RE = re.compile(r'agent-info__brokerage".*?<a[^>]*>\s*([^<,\n]+?)\s*(?:,|\n|</a>)', | |
| 32 | + re.S | re.I) | |
| 33 | + | |
| 34 | +# Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches | |
| 35 | +# + dédoublonnage MLS couvre la province. | |
| 36 | +ANCHORS = [ | |
| 37 | + ("Montréal", 45.5089, -73.5542), ("Québec", 46.8139, -71.2080), | |
| 38 | + ("Gatineau", 45.4765, -75.7013), ("Sherbrooke", 45.4040, -71.8929), | |
| 39 | + ("Trois-Rivières", 46.3432, -72.5432), ("Saguenay", 48.4280, -71.0680), | |
| 40 | + ("Laval", 45.6066, -73.7124), ("Longueuil", 45.5312, -73.5185), | |
| 41 | + ("Drummondville", 45.8833, -72.4833), ("Saint-Jérôme", 45.7803, -74.0038), | |
| 42 | + ("Granby", 45.4001, -72.7300), ("Rimouski", 48.4489, -68.5236), | |
| 43 | + ("Rouyn-Noranda", 48.2360, -79.0230), ("Val-d'Or", 48.0975, -77.7972), | |
| 44 | + ("Gaspé", 48.8330, -64.4870), ("Sept-Îles", 50.2001, -66.3821), | |
| 45 | + ("Baie-Comeau", 49.2166, -68.1487), ("Joliette", 46.0167, -73.4500), | |
| 46 | + ("Saint-Hyacinthe", 45.6300, -72.9569), ("Sorel-Tracy", 46.0500, -73.1200), | |
| 47 | + ("Victoriaville", 46.0533, -71.9667), ("Mont-Laurier", 46.5500, -75.5000), | |
| 48 | + ("Alma", 48.5500, -71.6500), ("Matane", 48.8500, -67.5300), | |
| 49 | + ("Saint-Sauveur", 45.8939, -74.2051), ("Baie-Saint-Paul", 47.4400, -70.5000), | |
| 50 | + ("Salaberry-de-Valleyfield", 45.2500, -74.1300), ("Thetford Mines", 46.1000, -71.3000), | |
| 51 | +] | |
| 52 | + | |
| 53 | +_CARD_RE = re.compile(r'card--listing-card js-listing js-property-details(.*?)' | |
| 54 | + r'(?=card--listing-card js-listing js-property-details|</main>|$)', re.S) | |
| 55 | +_URL_RE = re.compile(r'/fr/property/quebec/[^"\']+?/mls[a-z0-9]+/', re.I) | |
| 56 | +_MLS_RE = re.compile(r'/mls([a-z0-9]+)/', re.I) | |
| 57 | +_KEY_RE = re.compile(r'data-rlp-key="(-?\d+\.\d+)\.(-?\d+\.\d+)"') | |
| 58 | +_PHOTO_RE = re.compile(r'(//rlp\.jumplisting\.com/photos/[^"\']+)') | |
| 59 | +_CAC_RE = re.compile(r'(\d+)\s*CAC', re.I) | |
| 60 | +_SDB_RE = re.compile(r'(\d+)(?:\+\d+)?\s*SDB', re.I) | |
| 61 | +# loyer sur la carte, aplatie en « 1 850.00 | $ | /mois » (une carte peut aussi | |
| 62 | +# afficher un prix de VENTE : on ne retient que le montant suivi de /mois) | |
| 63 | +_RENT_RE = re.compile(r'(\d[\d\s ,.]*?)\s*\|?\s*\$\s*\|?\s*/\s*mois', re.I) | |
| 64 | +_TYPE_RE = re.compile(r'\|\s*(Maison|Condo|Copropriété|Appartement|Duplex|Triplex|' | |
| 65 | + r'Plex|Chalet|Loft|Studio|Maison de ville|Jumelé)\s*\|', re.I) | |
| 66 | + | |
| 67 | + | |
| 68 | +def _parse_rent(raw: str) -> float | None: | |
| 69 | + """« 1 850.00 » / « 1,850 » -> 1850.0 ($/mois).""" | |
| 70 | + s = raw.replace(" ", "").replace("\xa0", "").replace(" ", "") | |
| 71 | + if re.search(r",\d{3}\b", s): | |
| 72 | + s = s.replace(",", "") | |
| 73 | + else: | |
| 74 | + s = s.replace(",", ".") | |
| 75 | + try: | |
| 76 | + v = float(s) | |
| 77 | + except ValueError: | |
| 78 | + return None | |
| 79 | + return v if 100 <= v <= 50000 else None | |
| 80 | + | |
| 81 | + | |
| 82 | +class RoyalLepageConnector(BaseConnector): | |
| 83 | + source_id = "royal_lepage" | |
| 84 | + request_delay = 0.4 | |
| 85 | + | |
| 86 | + def fetch(self) -> list[Listing]: | |
| 87 | + by_id: dict[str, Listing] = {} | |
| 88 | + for name, lat, lng in ANCHORS: | |
| 89 | + self._search_anchor(name, lat, lng, by_id) | |
| 90 | + listings = list(by_id.values()) | |
| 91 | + # bureau (sous-agence) via la fiche détail — plafonné, cache accumulé | |
| 92 | + du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_office, key="office1") | |
| 93 | + return listings | |
| 94 | + | |
| 95 | + def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None: | |
| 96 | + empty_streak = 0 | |
| 97 | + for page in range(1, MAX_PAGES + 1): | |
| 98 | + params = { | |
| 99 | + "search_str": f"{name}, QC", "prov_code": "QC", | |
| 100 | + "city_name": name, "lat": lat, "lng": lng, "search_type": "city", | |
| 101 | + "transactionType": "LEASE", | |
| 102 | + } | |
| 103 | + try: | |
| 104 | + html = self.get(f"{BASE}/{page}/", params=params, | |
| 105 | + headers={"X-Requested-With": "XMLHttpRequest"}).text | |
| 106 | + except Exception: | |
| 107 | + break | |
| 108 | + new = 0 | |
| 109 | + for lst in self._parse_cards(html): | |
| 110 | + if lst.external_id not in out: | |
| 111 | + out[lst.external_id] = lst | |
| 112 | + new += 1 | |
| 113 | + if new == 0: | |
| 114 | + empty_streak += 1 | |
| 115 | + if empty_streak >= 2: # cap serveur atteint (page répétée) | |
| 116 | + break | |
| 117 | + else: | |
| 118 | + empty_streak = 0 | |
| 119 | + | |
| 120 | + def _parse_cards(self, html: str) -> list[Listing]: | |
| 121 | + out = [] | |
| 122 | + for seg in _CARD_RE.findall(html): | |
| 123 | + murl = _URL_RE.search(seg) | |
| 124 | + if not murl: | |
| 125 | + continue | |
| 126 | + url = "https://www.royallepage.ca" + murl.group(0) | |
| 127 | + mls = _MLS_RE.search(url) | |
| 128 | + ext = mls.group(1) if mls else url | |
| 129 | + # /fr/property/quebec/{ville}/{adresse}/{id}/mls{no}/ | |
| 130 | + parts = murl.group(0).strip("/").split("/") | |
| 131 | + city = parts[3].replace("-", " ").title() if len(parts) > 3 else "" | |
| 132 | + address = parts[4].replace("-", " ").title() if len(parts) > 4 else "" | |
| 133 | + sector = "" | |
| 134 | + # « Montreal Ville Marie » → ville + arrondissement | |
| 135 | + for big, accent in (("Montreal", "Montréal"), ("Quebec", "Québec"), | |
| 136 | + ("Levis", "Lévis"), ("Gatineau", "Gatineau"), | |
| 137 | + ("Longueuil", "Longueuil"), ("Laval", "Laval")): | |
| 138 | + if city == big: | |
| 139 | + city = accent | |
| 140 | + break | |
| 141 | + if city.startswith(big + " "): | |
| 142 | + sector = city[len(big) + 1:] | |
| 143 | + city = accent | |
| 144 | + break | |
| 145 | + flat = _html.unescape(re.sub(r"<[^>]+>", " | ", seg)) | |
| 146 | + flat = re.sub(r"(\s*\|\s*)+", " | ", re.sub(r"\s+", " ", flat)) | |
| 147 | + rent = _RENT_RE.search(flat) | |
| 148 | + price = _parse_rent(rent.group(1)) if rent else None | |
| 149 | + if price is None: | |
| 150 | + continue # pas de « $ /mois » = pas une location | |
| 151 | + beds = _CAC_RE.search(flat) | |
| 152 | + baths = _SDB_RE.search(flat) | |
| 153 | + typ = _TYPE_RE.search(flat) | |
| 154 | + key = _KEY_RE.search(seg) | |
| 155 | + photo = _PHOTO_RE.search(seg) | |
| 156 | + images = [] | |
| 157 | + if photo: | |
| 158 | + images = ["https:" + photo.group(1).replace("_0_med", "_0_lg")] | |
| 159 | + unit_type = "" | |
| 160 | + if typ and typ.group(1).lower() in ("loft", "studio"): | |
| 161 | + unit_type = typ.group(1).title() | |
| 162 | + elif beds: | |
| 163 | + unit_type = f"{beds.group(1)} chambres" # normalisé en n+2 ½ | |
| 164 | + details = {"Courtier": "Royal LePage"} | |
| 165 | + if mls: | |
| 166 | + details["MLS"] = mls.group(1) | |
| 167 | + if typ: | |
| 168 | + details["Type"] = typ.group(1) | |
| 169 | + if baths: | |
| 170 | + details["Salles de bain"] = baths.group(1) | |
| 171 | + out.append(Listing( | |
| 172 | + source=self.source_id, external_id=str(ext), url=url, | |
| 173 | + title=address, address=address, city=city, sector=sector, | |
| 174 | + unit_type=unit_type, | |
| 175 | + price=price, | |
| 176 | + price_label=f"{price:,.0f} $/mois".replace(",", " "), | |
| 177 | + details=details, | |
| 178 | + images=images, | |
| 179 | + lat=float(key.group(1)) if key else None, | |
| 180 | + lng=float(key.group(2)) if key else None, | |
| 181 | + )) | |
| 182 | + return out | |
| 183 | + | |
| 184 | + | |
| 185 | +def _parse_rlp_office(html: str) -> dict: | |
| 186 | + """Extrait le bureau/brokerage (sous-agence) de la fiche détail.""" | |
| 187 | + m = _OFFICE_RE.search(html) | |
| 188 | + if not m: | |
| 189 | + return {} | |
| 190 | + office = _html.unescape(m.group(1)).strip() | |
| 191 | + return {"details": {"Agence": office}} if office else {} | |
added
louka/connectors/sutton.py
+431 −0
@@ -0,0 +1,431 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/sutton.py : Groupe Sutton Québec (suttonquebec.com) — LOCATIONS | |
| 5 | +# Couverture exhaustive via le sitemap : l'index /sitemap.cfm pointe vers des | |
| 6 | +# sitemaps d'inscriptions paginés où chaque URL porte noInscription (numéro | |
| 7 | +# Centris) et typeInscription (1 = à vendre, 2 = À LOUER — on ne garde que | |
| 8 | +# les 2). Le slug donne type/adresse/ville approximatifs ; la fiche détail | |
| 9 | +# fournit le loyer (« 1 950 $ / mois »), la galerie immo.vrtx.co, la | |
| 10 | +# description, les pièces et l'adresse exacte (H1). Adapté du connecteur | |
| 11 | +# « à vendre » d'Immo-Ka (agent-courtage/immoka). | |
| 12 | +# ----------------------------------------------------------------------------- | |
| 13 | +from __future__ import annotations | |
| 14 | + | |
| 15 | +import html as _html | |
| 16 | +import os | |
| 17 | +import re | |
| 18 | + | |
| 19 | +from ..normalize import parse_area_sqft, strip_accents | |
| 20 | +from ..schema import Listing | |
| 21 | +from .base import BaseConnector | |
| 22 | + | |
| 23 | +from . import _detailutil as du | |
| 24 | + | |
| 25 | +BASE = "https://www.suttonquebec.com" | |
| 26 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_SUTTON_DETAIL_LIMIT", "400")) | |
| 27 | + | |
| 28 | +# Sitemap : l'index /sitemap.cfm pointe vers des sitemaps d'inscriptions. | |
| 29 | +SITEMAP_INDEX = f"{BASE}/sitemap.cfm" | |
| 30 | +SITEMAP_MAX_PAGES = 40 # garde-fou (≈13 pages réelles × ~1000) | |
| 31 | +_SM_SUB_RE = re.compile(r'sitemap_inscription\.cfm\?[^<"\s]+', re.I) | |
| 32 | +_SM_LOC_RE = re.compile( | |
| 33 | + r'https://www\.suttonquebec\.com/fr/inscription/([^<"?]+)\.html\?' | |
| 34 | + r'noInscription=(\d+)&(?:amp;)?typeInscription=(\d)', re.I) | |
| 35 | + | |
| 36 | +# toutes les photos de la fiche (normalisées pic450, dédoublonnées id-index) | |
| 37 | +_DET_IMG_RE = re.compile( | |
| 38 | + r'https://immo\.vrtx\.co/pic\d+/(\d+(?:-\d+)?)\.(?:jpg|jpeg|png|webp)', re.I) | |
| 39 | +_DESC_RE = re.compile( | |
| 40 | + r'Description de la propri[ée]t[ée](?:\s+[àa]\s+(?:vendre|louer))?[^A-Za-z0-9]*(.*?)' | |
| 41 | + r'(?:Caract[ée]ristiques|Pi[èe]ce\s+Dimension|Inclusions|Exclusions|Addenda|$)', | |
| 42 | + re.I | re.S) | |
| 43 | +# loyer : montant suivi de « / mois » / « par mois » (le prix demandé précède | |
| 44 | +# les loyers des « prochaines propriétés » en bas de page) | |
| 45 | +_RENT_RE = re.compile(r"(?<!\d)(\d{1,2}\s\d{3}|\d{3,5})\s*\$\s*(?:/\s*|par\s+)mois", | |
| 46 | + re.I) | |
| 47 | +_ROOM_ROW_RE = re.compile( | |
| 48 | + r'([A-ZÀ-Ÿ][^\d|]{2,38}?)\s+(\d[\d.,]*\s*[xX]\s*\d[\d.,]*)\s*P?\s+' | |
| 49 | + r'([A-Za-zÀ-ÿ/ -]{3,30}?)\s+(RC|SS|AU|\d[eE]?r?e?)\b') | |
| 50 | +_AREA_RE = re.compile(r"Aire habitable\s*:?\s*([\d.,]+)\s*(mc|m2|m²|pc|pi)", re.I) | |
| 51 | +_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S) | |
| 52 | +_BROKER_RE = re.compile( | |
| 53 | + r'divResume courtier[^"]*">.*?class="h4">\s*([^<]{3,60}?)\s*</p>', re.S) | |
| 54 | +_PHONE_RE = re.compile(r"\d{3}[-\s]\d{3}-\d{4}") | |
| 55 | +_AGENCY_RE = re.compile(r'/agence-immobiliere/\d+-([a-z0-9-]+)\.html', re.I) | |
| 56 | +_PAREN_RE = re.compile(r"\(([^)]*)\)") | |
| 57 | +_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I) | |
| 58 | + | |
| 59 | +# types de slug qui ne sont pas des logements (lou-ka = résidentiel locatif) | |
| 60 | +_EXCLUDE_TYPE_RE = re.compile( | |
| 61 | + r"commercial|bureau|industriel|terrain|entrep[ôo]t|local\b", re.I) | |
| 62 | + | |
| 63 | +# Sur les fiches LOCATION, le tableau des caractéristiques est en ordre | |
| 64 | +# libellé | valeur : on privilégie cet ordre, du.centris_details en repli. | |
| 65 | +_RENT_LABELS = [ | |
| 66 | + "Type de propriété", "Genre de propriété", "Style de bâtiment", | |
| 67 | + "Année de construction", "Aire habitable", "Superficie habitable", | |
| 68 | + "Superficie du terrain", "Nombre de pièces", "Stationnement", | |
| 69 | + "Garage", "Mode de chauffage", "Système de chauffage", | |
| 70 | + "Énergie pour le chauffage", "Équipement disponible", "Zonage", | |
| 71 | + "Approvisionnement en eau", "Système d'égouts", "Piscine", | |
| 72 | + "Déménagement", "Date d'emménagement", "Disponibilité", "Bail", | |
| 73 | + "Durée du bail", "Meublé", "Animaux", "Inclus dans le loyer", "Cuisine", | |
| 74 | +] | |
| 75 | + | |
| 76 | + | |
| 77 | +class SuttonConnector(BaseConnector): | |
| 78 | + source_id = "sutton" | |
| 79 | + request_delay = 0.5 | |
| 80 | + | |
| 81 | + def fetch(self) -> list[Listing]: | |
| 82 | + by_id: dict[str, Listing] = {} | |
| 83 | + # 1) couverture exhaustive via le sitemap (type/adresse depuis le slug) | |
| 84 | + for slug, centris, url in self._sitemap_listings(): | |
| 85 | + if centris in by_id: | |
| 86 | + continue | |
| 87 | + ptype, addr, city = _from_slug(slug) | |
| 88 | + if ptype is None: # commercial/bureau : pas un logement | |
| 89 | + continue | |
| 90 | + details = {"MLS": centris} | |
| 91 | + if ptype: | |
| 92 | + details["Type"] = ptype | |
| 93 | + by_id[centris] = Listing( | |
| 94 | + source=self.source_id, external_id=centris, url=url, | |
| 95 | + title=addr or _deslug(slug), address=addr, city=city, | |
| 96 | + unit_type="Maison" if ptype.startswith("Maison") else "", | |
| 97 | + details=details, | |
| 98 | + ) | |
| 99 | + listings = list(by_id.values()) | |
| 100 | + # fiche détail : loyer, chambres, galerie complète, description, | |
| 101 | + # pièces, inclusions, adresse exacte — plafonné, cache accumulé entre | |
| 102 | + # cycles (tout le parc finit enrichi au fil des syncs). | |
| 103 | + du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v1") | |
| 104 | + for lst in listings: | |
| 105 | + # adresse exacte du H1 de la fiche : remplace l'approx. du slug | |
| 106 | + for det_key, attr in (("__adresse", "address"), ("__ville", "city"), | |
| 107 | + ("__secteur", "sector")): | |
| 108 | + val = lst.details.pop(det_key, "") | |
| 109 | + if val: | |
| 110 | + setattr(lst, attr, val) | |
| 111 | + if lst.address: | |
| 112 | + lst.title = lst.address | |
| 113 | + _unit_from_bedrooms(lst) | |
| 114 | + return listings | |
| 115 | + | |
| 116 | + def _sitemap_listings(self): | |
| 117 | + """Itère (slug, centris, url) de toutes les inscriptions À LOUER.""" | |
| 118 | + try: | |
| 119 | + index = self.get(SITEMAP_INDEX).text | |
| 120 | + except Exception: | |
| 121 | + return | |
| 122 | + subs = [] | |
| 123 | + for s in _SM_SUB_RE.findall(index): | |
| 124 | + s = s.replace("&", "&") | |
| 125 | + if s not in subs: | |
| 126 | + subs.append(s) | |
| 127 | + if not subs: # repli : pagination directe | |
| 128 | + subs = [f"sitemap_inscription.cfm?page={p}&platine=0" | |
| 129 | + for p in range(1, 14)] + ["sitemap_inscription.cfm?platine=1"] | |
| 130 | + seen = set() | |
| 131 | + for sub in subs[:SITEMAP_MAX_PAGES]: | |
| 132 | + try: | |
| 133 | + xml = self.get(f"{BASE}/{sub}").text | |
| 134 | + except Exception: | |
| 135 | + continue | |
| 136 | + for m in _SM_LOC_RE.finditer(xml): | |
| 137 | + slug, centris, typ = m.group(1), m.group(2), m.group(3) | |
| 138 | + if typ != "2" or centris in seen: # 2 = à louer uniquement | |
| 139 | + continue | |
| 140 | + seen.add(centris) | |
| 141 | + url = (f"{BASE}/fr/inscription/{slug}.html" | |
| 142 | + f"?noInscription={centris}&typeInscription=2") | |
| 143 | + yield slug, centris, url | |
| 144 | + | |
| 145 | + | |
| 146 | +def _unit_from_bedrooms(lst: Listing) -> None: | |
| 147 | + """« Chambres » de la fiche détail -> unit_type « n chambres » | |
| 148 | + (la normalisation lou-ka convertit en n+2½).""" | |
| 149 | + if lst.unit_type: | |
| 150 | + return | |
| 151 | + m = re.match(r"\d+", str(lst.details.get("Chambres", ""))) | |
| 152 | + if m: | |
| 153 | + n = int(m.group(0)) | |
| 154 | + lst.unit_type = "Studio" if n == 0 else f"{n} chambres" | |
| 155 | + else: | |
| 156 | + lst.unit_type = lst.details.get("Type", "") | |
| 157 | + | |
| 158 | + | |
| 159 | +def _deslug(s: str) -> str: | |
| 160 | + return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title() | |
| 161 | + | |
| 162 | + | |
| 163 | +# villes/arrondissements composés fréquents dans les slugs Sutton (fin du slug) | |
| 164 | +_CITY_HINTS = ( | |
| 165 | + "montreal-le-plateau-mont-royal", "montreal-ville-marie", "montreal-rosemont-la-petite-patrie", | |
| 166 | + "montreal-le-sud-ouest", "montreal-cote-des-neiges-notre-dame-de-grace", "montreal-ahuntsic-cartierville", | |
| 167 | + "montreal-villeray-saint-michel-parc-extension", "montreal-mercier-hochelaga-maisonneuve", | |
| 168 | + "montreal-riviere-des-prairies-pointe-aux-trembles", "montreal-verdun-ile-des-soeurs", | |
| 169 | + "montreal-saint-laurent", "montreal-lasalle", "montreal-outremont", "montreal-anjou", | |
| 170 | + "montreal-lachine", "montreal-pierrefonds-roxboro", "montreal-saint-leonard", | |
| 171 | + "montreal-montreal-nord", "montreal-l-ile-bizard-sainte-genevieve", | |
| 172 | + "longueuil-le-vieux-longueuil", "longueuil-saint-hubert", "longueuil-greenfield-park", | |
| 173 | + "laval-chomedey", "laval-sainte-dorothee", "laval-sainte-rose", "laval-duvernay", | |
| 174 | + "laval-fabreville", "laval-vimont", "laval-auteuil", "laval-laval-des-rapides", | |
| 175 | + "laval-pont-viau", "laval-laval-ouest", "laval-saint-francois", "laval-saint-vincent-de-paul", | |
| 176 | + "quebec-la-cite-limoilou", "quebec-sainte-foy-sillery-cap-rouge", "quebec-charlesbourg", | |
| 177 | + "quebec-beauport", "quebec-les-rivieres", "quebec-la-haute-saint-charles", | |
| 178 | + "sherbrooke-les-nations", "sherbrooke-fleurimont", "saguenay-chicoutimi", | |
| 179 | + "saguenay-jonquiere", "saguenay-la-baie", "trois-rivieres", "saint-jean-sur-richelieu", | |
| 180 | + "salaberry-de-valleyfield", "mont-saint-hilaire", "notre-dame-de-l-ile-perrot", | |
| 181 | + "vaudreuil-dorion", "sainte-adele", "sainte-therese", "saint-jerome", "levis", "quebec", | |
| 182 | + "laval", "gatineau", "longueuil", "brossard", "sherbrooke", "granby", "magog", | |
| 183 | + "sorel-tracy", "sainte-catherine", "saint-constant", "chateauguay", "coteau-du-lac", | |
| 184 | + "saint-zotique", "sainte-anne-de-bellevue", "sainte-anne-des-lacs", "pointe-claire", | |
| 185 | + "dorval", "beaconsfield", "kirkland", "dollard-des-ormeaux", "cote-saint-luc", | |
| 186 | + "mont-royal", "westmount", "saint-lambert", "boucherville", "sainte-julie", "varennes", | |
| 187 | + "saint-bruno-de-montarville", "candiac", "la-prairie", "chambly", "repentigny", | |
| 188 | + "terrebonne", "mascouche", "blainville", "boisbriand", "rosemere", "mirabel", | |
| 189 | + "deux-montagnes", "pincourt", | |
| 190 | +) | |
| 191 | +# accents perdus dans les slugs (villes fréquentes) | |
| 192 | +_CITY_ACCENTS = { | |
| 193 | + "montreal": "Montréal", "quebec": "Québec", "levis": "Lévis", | |
| 194 | + "trois-rivieres": "Trois-Rivières", "saint-jerome": "Saint-Jérôme", | |
| 195 | + "sainte-adele": "Sainte-Adèle", "sainte-therese": "Sainte-Thérèse", | |
| 196 | + "chateauguay": "Châteauguay", "rosemere": "Rosemère", "sainte-julie": "Sainte-Julie", | |
| 197 | +} | |
| 198 | +# arrondissement/secteur préfixé par sa grande ville dans le slug | |
| 199 | +_BOROUGH_PREFIXES = { | |
| 200 | + "montreal-": "Montréal", "longueuil-": "Longueuil", "laval-": "Laval", | |
| 201 | + "quebec-": "Québec", "sherbrooke-": "Sherbrooke", "saguenay-": "Saguenay", | |
| 202 | + "levis-": "Lévis", | |
| 203 | +} | |
| 204 | + | |
| 205 | + | |
| 206 | +def _hint_city(hint: str) -> str: | |
| 207 | + """Ville lisible depuis un indice de slug ; les arrondissements composés | |
| 208 | + « montreal-… » deviennent simplement « Montréal » (le secteur exact vient | |
| 209 | + de la fiche détail).""" | |
| 210 | + for prefix, city in _BOROUGH_PREFIXES.items(): | |
| 211 | + if hint.startswith(prefix): | |
| 212 | + return city | |
| 213 | + return _CITY_ACCENTS.get(hint, _deslug(hint)) | |
| 214 | + | |
| 215 | + | |
| 216 | +def _from_slug(slug: str) -> tuple[str | None, str, str]: | |
| 217 | + """« appartement-a-louer-3762-rue-st-dominique-montreal-le-plateau-mont-royal » | |
| 218 | + -> (type, adresse, ville). Best-effort : l'adresse exacte vient de la fiche. | |
| 219 | + Type None = pas un logement (espace commercial, bureau…).""" | |
| 220 | + # quelques slugs de location portent le libellé anglais, voire « for-sale » | |
| 221 | + # par coquille : typeInscription=2 (sitemap) fait foi, pas le slug. | |
| 222 | + m = re.search(r"^(.*?)-(?:a-louer|for-rent|a-vendre|for-sale)-(.+)$", slug, re.I) | |
| 223 | + if not m: | |
| 224 | + return "", _deslug(slug), "" | |
| 225 | + if _EXCLUDE_TYPE_RE.search(m.group(1)): | |
| 226 | + return None, "", "" | |
| 227 | + ptype = _deslug(m.group(1)) | |
| 228 | + rest = m.group(2).lower() | |
| 229 | + city = "" | |
| 230 | + for hint in _CITY_HINTS: | |
| 231 | + if rest.endswith("-" + hint) or rest == hint: | |
| 232 | + city = _hint_city(hint) | |
| 233 | + rest = rest[: -len(hint)].rstrip("-") | |
| 234 | + break | |
| 235 | + else: | |
| 236 | + # sinon : dernier token comme ville (approx ; corrigé par la fiche détail) | |
| 237 | + parts = rest.rsplit("-", 1) | |
| 238 | + if len(parts) == 2 and not parts[1].isdigit(): | |
| 239 | + city, rest = _hint_city(parts[1]), parts[0] | |
| 240 | + return ptype, _deslug(rest), city | |
| 241 | + | |
| 242 | + | |
| 243 | +# régions administratives : parfois entre parenthèses dans le H1 après le | |
| 244 | +# secteur — « Longueuil (Le Vieux-Longueuil) ( Montérégie ) » — à distinguer | |
| 245 | +_REGIONS = { | |
| 246 | + "monteregie", "estrie", "laurentides", "lanaudiere", "outaouais", | |
| 247 | + "mauricie", "bas-saint-laurent", "gaspesie", "gaspesie--iles-de-la-madeleine", | |
| 248 | + "abitibi-temiscamingue", "cote-nord", "saguenay--lac-saint-jean", | |
| 249 | + "centre-du-quebec", "chaudiere-appalaches", "capitale-nationale", | |
| 250 | + "nord-du-quebec", | |
| 251 | +} | |
| 252 | +_APT_RE = re.compile(r"(?i)^(?:app|apt|suite|local|unit[ée]|#)") | |
| 253 | + | |
| 254 | + | |
| 255 | +def _split_h1(full: str) -> tuple[str, str, str, str]: | |
| 256 | + """« 4781 Rue St-Ambroise, app. 201, Montréal (Le Sud-Ouest), H4C2E8 » | |
| 257 | + -> (adresse, ville, secteur, région).""" | |
| 258 | + parts = [p.strip() for p in full.split(",") if p.strip()] | |
| 259 | + if parts and _POSTAL_RE.fullmatch(parts[-1].replace(" ", "")): | |
| 260 | + parts = parts[:-1] | |
| 261 | + address = parts[0] if parts else "" | |
| 262 | + rest = parts[1:] | |
| 263 | + # « app. 201 » collé après la rue : fait partie de l'adresse | |
| 264 | + while rest and _APT_RE.match(rest[0]): | |
| 265 | + address += ", " + rest.pop(0) | |
| 266 | + city = sector = region = "" | |
| 267 | + if rest: | |
| 268 | + muni = rest[0] | |
| 269 | + city = _PAREN_RE.sub("", muni).strip() | |
| 270 | + for p in (p.strip() for p in _PAREN_RE.findall(muni)): | |
| 271 | + if strip_accents(p.lower()).replace(" ", "-") in _REGIONS: | |
| 272 | + region = p | |
| 273 | + elif p: | |
| 274 | + sector = p | |
| 275 | + return address, city, sector, region | |
| 276 | + | |
| 277 | + | |
| 278 | +def parse_sutton_detail(html: str) -> dict: | |
| 279 | + """Fiche Sutton location : loyer, galerie complète, description, chambres, | |
| 280 | + pièces (dimensions), inclusions, caractéristiques, agence, adresse exacte.""" | |
| 281 | + out: dict = {} | |
| 282 | + details: dict = {} | |
| 283 | + | |
| 284 | + # photos : normaliser toute taille -> pic450, dédoublonner par id-index | |
| 285 | + seen, imgs = set(), [] | |
| 286 | + for m in _DET_IMG_RE.finditer(html): | |
| 287 | + key = m.group(1) | |
| 288 | + if key not in seen: | |
| 289 | + seen.add(key) | |
| 290 | + imgs.append(f"https://immo.vrtx.co/pic450/{key}.jpg") | |
| 291 | + if imgs: | |
| 292 | + out["images"] = imgs | |
| 293 | + | |
| 294 | + # adresse exacte (H1 : « 3762 Rue St-Dominique, Montréal (…), H2W0A2 ») | |
| 295 | + mh = _H1_RE.search(html) | |
| 296 | + if mh: | |
| 297 | + h1 = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", mh.group(1)))).strip() | |
| 298 | + address, city, sector, region = _split_h1(h1) | |
| 299 | + if address and re.match(r"\d", address): | |
| 300 | + details["__adresse"] = address | |
| 301 | + if city: | |
| 302 | + details["__ville"] = city | |
| 303 | + if sector: | |
| 304 | + details["__secteur"] = sector | |
| 305 | + if region: | |
| 306 | + details["Région"] = region | |
| 307 | + | |
| 308 | + text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html))) | |
| 309 | + | |
| 310 | + # loyer : premier « N NNN $ / mois » plausible (100–20 000 $) | |
| 311 | + for m in _RENT_RE.finditer(text): | |
| 312 | + try: | |
| 313 | + val = int(re.sub(r"\s", "", m.group(1))) | |
| 314 | + except ValueError: | |
| 315 | + continue | |
| 316 | + if 100 <= val <= 20000: | |
| 317 | + out["price"] = float(val) | |
| 318 | + out["price_label"] = f"{val:,.0f} $/mois".replace(",", " ") | |
| 319 | + break | |
| 320 | + | |
| 321 | + mb = re.search(r"(\d+)\s*chambres?\b(?!\s*[àa]\s*coucher)", text, re.I) | |
| 322 | + if mb: | |
| 323 | + details["Chambres"] = mb.group(1) | |
| 324 | + ms = re.search(r"(\d+)\s*salles?\s*de\s*bain", text, re.I) | |
| 325 | + if ms: | |
| 326 | + details["Salles de bain"] = ms.group(1) | |
| 327 | + | |
| 328 | + md = _DESC_RE.search(text) | |
| 329 | + if md: | |
| 330 | + desc = md.group(1).strip() | |
| 331 | + if len(desc) > 30: | |
| 332 | + out["description"] = desc[:4000] | |
| 333 | + | |
| 334 | + # aire habitable : « 76.9 MC (828 pi²) » -> pi² | |
| 335 | + ma = _AREA_RE.search(text) | |
| 336 | + if ma: | |
| 337 | + unit = ma.group(2).lower() | |
| 338 | + raw = f"{ma.group(1)} {'m²' if unit in ('mc', 'm2', 'm²') else 'pi²'}" | |
| 339 | + area = parse_area_sqft(raw) | |
| 340 | + if area: | |
| 341 | + out["area_sqft"] = area | |
| 342 | + | |
| 343 | + # pièces : « Nom DimxDim P Revêtement Niveau » | |
| 344 | + rooms = [] | |
| 345 | + for m in _ROOM_ROW_RE.finditer(text): | |
| 346 | + nom = m.group(1).strip(" -") | |
| 347 | + # retirer l'en-tête de tableau qui peut coller au 1er nom | |
| 348 | + nom = re.sub(r"^.*(?:Niveau|Rev[êe]tement|Dimensions?)\s+", "", nom).strip(" -") | |
| 349 | + if not nom or nom.lower() in ("pièce", "piece"): | |
| 350 | + continue | |
| 351 | + rooms.append({"nom": nom[:40], "dimensions": m.group(2).replace(" ", ""), | |
| 352 | + "revetement": m.group(3).strip(), "niveau": m.group(4)}) | |
| 353 | + if rooms: | |
| 354 | + details["pieces"] = rooms[:20] | |
| 355 | + | |
| 356 | + # inclusions -> commodités affichables | |
| 357 | + mi = re.search(r"Inclusions?\s*:?(.*?)(?:Exclusions|Addenda|Financ|$)", text, re.I | re.S) | |
| 358 | + if mi: | |
| 359 | + inc = mi.group(1).strip() | |
| 360 | + feats = [s.strip() for s in re.split(r"[,;•]", inc) if 3 <= len(s.strip()) <= 90] | |
| 361 | + if feats: | |
| 362 | + out["amenities"] = feats[:20] | |
| 363 | + | |
| 364 | + # caractéristiques : repli valeur|libellé (du), écrasé par libellé|valeur | |
| 365 | + flat = du.flatten(html) | |
| 366 | + details.update(du.centris_details(flat)) | |
| 367 | + details.update(_labels_first(flat, _RENT_LABELS)) | |
| 368 | + _drop_section_values(details) | |
| 369 | + # « Cuisine » happé depuis le tableau des pièces (dimensions) : retirer | |
| 370 | + if re.search(r"\d\s*[xX]\s*\d", str(details.get("Cuisine", ""))): | |
| 371 | + details.pop("Cuisine", None) | |
| 372 | + # « 76.9 MC (828 pi » : l'exposant ² coupe la valeur à l'aplatissement | |
| 373 | + if str(details.get("Aire habitable", "")).endswith("pi"): | |
| 374 | + details["Aire habitable"] += "²)" | |
| 375 | + | |
| 376 | + # date d'emménagement / déménagement -> disponibilité | |
| 377 | + for k in ("Date d'emménagement", "Déménagement", "Disponibilité"): | |
| 378 | + if details.get(k): | |
| 379 | + out["availability"] = details[k] | |
| 380 | + break | |
| 381 | + | |
| 382 | + # chambres -> type d'unité (rempli si la fiche liste n'en avait pas) | |
| 383 | + if details.get("Chambres"): | |
| 384 | + try: | |
| 385 | + n = int(details["Chambres"]) | |
| 386 | + out["unit_type"] = "Studio" if n == 0 else f"{n} chambres" | |
| 387 | + except ValueError: | |
| 388 | + pass | |
| 389 | + | |
| 390 | + # courtier + sous-agence (bureau) : carte « divResume courtier » | |
| 391 | + mc = _BROKER_RE.search(html) | |
| 392 | + if mc: | |
| 393 | + details["Courtier"] = _html.unescape(mc.group(1)).strip() | |
| 394 | + mp = _PHONE_RE.search(html[mc.start():mc.start() + 1500]) | |
| 395 | + if mp: | |
| 396 | + details["Téléphone"] = mp.group(0) | |
| 397 | + mo = _AGENCY_RE.search(html) | |
| 398 | + if mo: | |
| 399 | + details["Agence"] = _deslug(mo.group(1)) | |
| 400 | + | |
| 401 | + if details: | |
| 402 | + out["details"] = details | |
| 403 | + return out | |
| 404 | + | |
| 405 | + | |
| 406 | +# une « valeur » qui est en fait un titre de section = extraction décalée | |
| 407 | +_SECTION_VAL_RE = re.compile( | |
| 408 | + r"^(?:Particularit[ée]s|Caract[ée]ristiques|D[ée]tails des|Inclusions" | |
| 409 | + r"|Exclusions|Addenda|Publicit[ée])", re.I) | |
| 410 | + | |
| 411 | + | |
| 412 | +def _labels_first(text: str, labels: list[str]) -> dict: | |
| 413 | + """Extraction « libellé | valeur » (ordre des fiches location Sutton).""" | |
| 414 | + out: dict = {} | |
| 415 | + for label in labels: | |
| 416 | + boundary = r"\b" if label[-1].isalnum() else "" | |
| 417 | + m = re.search(re.escape(label) + boundary + r"\s*\|\s*([^|]{1,55})", text) | |
| 418 | + if m: | |
| 419 | + val = m.group(1).strip(" |") | |
| 420 | + if (val and 1 <= len(val) <= 55 and val.lower() != label.lower() | |
| 421 | + and val not in labels and not _SECTION_VAL_RE.match(val)): | |
| 422 | + out[label] = val | |
| 423 | + return out | |
| 424 | + | |
| 425 | + | |
| 426 | +def _drop_section_values(details: dict) -> None: | |
| 427 | + """Purge les valeurs qui sont des titres de section (repli valeur|libellé | |
| 428 | + de du.centris_details décalé sur les fiches en ordre libellé|valeur).""" | |
| 429 | + for k in [k for k, v in details.items() | |
| 430 | + if isinstance(v, str) and _SECTION_VAL_RE.match(v)]: | |
| 431 | + del details[k] | |
added
louka/connectors/ubee.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/ubee.py : Ubee (ubee.com) — plateforme immobilière québécoise | |
| 5 | +# API publique anonyme : POST api.ubee.ca/api/anonymous/Search/SearchProperties | |
| 6 | +# (pageIndex=N, 24 résultats/page, JSON riche : adresse, GPS, loyer, pièces, | |
| 7 | +# superficies m², année, galerie Cloudinary complète). Volet LOCATION : | |
| 8 | +# listingType=Renter + isResidential (~250 annonces) ; `askPrice` est le | |
| 9 | +# loyer MENSUEL ($) pour ce volet (vérifié en live : 300–5 300 $/mois). | |
| 10 | +# Les fiches location vivent sous /a-louer/{ville}/{slug} (canonical du | |
| 11 | +# site). Les terrains et le volet commercial sont écartés — pas des | |
| 12 | +# logements. Adapté du connecteur « à vendre » d'Immo-Ka | |
| 13 | +# (agent-courtage/immoka). | |
| 14 | +# ----------------------------------------------------------------------------- | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +from ..schema import Listing | |
| 18 | +from .base import BaseConnector | |
| 19 | + | |
| 20 | +API = "https://api.ubee.ca/api/anonymous/Search/SearchProperties" | |
| 21 | +SITE = "https://ubee.com" | |
| 22 | +M2_TO_SQFT = 10.7639 | |
| 23 | + | |
| 24 | +# type d'inscription -> vocabulaire brut (finalize() normalise) ; | |
| 25 | +# Terrain/Commercial ne sont pas des logements -> exclus au filtrage | |
| 26 | +_TYPES = { | |
| 27 | + "Unifamiliale": "Maison", "Condo": "Condo", | |
| 28 | + "Plex": "Logement", "Chalet": "Chalet", "Fermette": "Fermette", | |
| 29 | +} | |
| 30 | +_EXCLUDE_TYPES = {"Terrain", "Commercial"} | |
| 31 | + | |
| 32 | + | |
| 33 | +class UbeeConnector(BaseConnector): | |
| 34 | + source_id = "ubee" | |
| 35 | + request_delay = 0.5 | |
| 36 | + | |
| 37 | + def _search(self, body: dict) -> list[dict]: | |
| 38 | + out, page = [], 0 | |
| 39 | + while True: | |
| 40 | + r = self.post(f"{API}?pageIndex={page}", json=body).json() | |
| 41 | + results = r.get("results") or [] | |
| 42 | + out.extend(results) | |
| 43 | + if len(out) >= (r.get("totalCount") or 0) or not results: | |
| 44 | + break | |
| 45 | + page += 1 | |
| 46 | + if page > 200: # garde-fou | |
| 47 | + break | |
| 48 | + return out | |
| 49 | + | |
| 50 | + def _to_listing(self, it: dict) -> Listing | None: | |
| 51 | + lid = str(it.get("id") or "") | |
| 52 | + slug = it.get("slugFr") or it.get("slugEn") or "" | |
| 53 | + if not lid or not slug: | |
| 54 | + return None | |
| 55 | + itype = (it.get("inscriptionType") or "").strip() | |
| 56 | + if itype in _EXCLUDE_TYPES: # terrain/local : pas un logement | |
| 57 | + return None | |
| 58 | + city = (it.get("city") or "").strip() | |
| 59 | + url = f"{SITE}/a-louer/{it.get('citySlug') or ''}/{slug}" | |
| 60 | + images = [im["publicUrls"]["default_size"] | |
| 61 | + for im in it.get("images") or [] | |
| 62 | + if (im.get("publicUrls") or {}).get("default_size")] | |
| 63 | + living = it.get("livingSurfaceInMeters") | |
| 64 | + ptype = _TYPES.get(itype, itype or "Logement") | |
| 65 | + | |
| 66 | + # askPrice = loyer mensuel sur le volet Renter (vérifié en live) | |
| 67 | + price = it.get("askPrice") | |
| 68 | + if not isinstance(price, (int, float)) or price <= 0: | |
| 69 | + price = None | |
| 70 | + | |
| 71 | + # type d'unité : nb de chambres (normalisé n+2 ½ par finalize()) | |
| 72 | + beds = it.get("nbBedrooms") | |
| 73 | + unit_type = "" | |
| 74 | + if isinstance(beds, int): | |
| 75 | + unit_type = "Studio" if beds == 0 else f"{beds} chambres" | |
| 76 | + | |
| 77 | + details: dict = {"Courtier": "Ubee", "Type": ptype} | |
| 78 | + if isinstance(it.get("nbBathrooms"), int) and it["nbBathrooms"] > 0: | |
| 79 | + details["Salles de bain"] = str(it["nbBathrooms"]) | |
| 80 | + if isinstance(it.get("nbHalfBaths"), int) and it["nbHalfBaths"] > 0: | |
| 81 | + details["Salles d'eau"] = str(it["nbHalfBaths"]) | |
| 82 | + for k, lab in (("propertyType", "Sous-type"), | |
| 83 | + ("buildingType", "Type de bâtiment"), | |
| 84 | + ("yearBuilt", "Année de construction")): | |
| 85 | + if it.get(k): | |
| 86 | + details[lab] = str(it[k]) | |
| 87 | + | |
| 88 | + lst = Listing( | |
| 89 | + source=self.source_id, | |
| 90 | + external_id=lid, | |
| 91 | + url=url, | |
| 92 | + title=f"{ptype} à louer — {city}" if city else f"{ptype} à louer", | |
| 93 | + address=it.get("address") or "", | |
| 94 | + city=city, | |
| 95 | + unit_type=unit_type, | |
| 96 | + price=price, | |
| 97 | + price_label=(f"{price:,.0f} $/mois".replace(",", " ") | |
| 98 | + if price else ""), | |
| 99 | + area_sqft=round(living * M2_TO_SQFT) if living else None, | |
| 100 | + amenities=[f for f in ( | |
| 101 | + "Neuf / à construire" if it.get("toBuild") else "", | |
| 102 | + ) if f], | |
| 103 | + details=details, | |
| 104 | + images=images, | |
| 105 | + lat=it.get("latitude"), | |
| 106 | + lng=it.get("longitude"), | |
| 107 | + ) | |
| 108 | + return lst | |
| 109 | + | |
| 110 | + def fetch(self) -> list[Listing]: | |
| 111 | + # une seule passe : le volet isCommercial=Renter ne renvoie que des | |
| 112 | + # locaux commerciaux (vérifié en live) — pas des logements | |
| 113 | + out: dict[str, Listing] = {} | |
| 114 | + body = {"sortBy": "DateDescending", "listingType": "Renter", | |
| 115 | + "isResidential": True} | |
| 116 | + for it in self._search(body): | |
| 117 | + # Québec seulement (l'API est QC par nature, on double-vérifie) | |
| 118 | + if (it.get("province") or "QC") != "QC": | |
| 119 | + continue | |
| 120 | + lst = self._to_listing(it) | |
| 121 | + if lst is not None: | |
| 122 | + out.setdefault(lst.uid, lst) | |
| 123 | + return list(out.values()) | |
added
louka/connectors/via_capitale.py
+230 −0
@@ -0,0 +1,230 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/via_capitale.py : Via Capitale (viacapitalevendu.com) — LOCATIONS | |
| 5 | +# Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET) | |
| 6 | +# mais protégé par Cloudflare : on passe par Firecrawl (get_rendered) qui | |
| 7 | +# franchit le challenge — testé : Scrapfly ASP passe Cloudflare mais ne rend | |
| 8 | +# pas les cartes. La recherche accepte un blob base64 `criteresJson` ; | |
| 9 | +# « AVendre »: false y bascule le même moteur en mode LOCATION (prix | |
| 10 | +# « X $ / mois », ~34 cartes/page). Adapté du connecteur « à vendre » | |
| 11 | +# d'Immo-Ka (agent-courtage/immoka). | |
| 12 | +# ----------------------------------------------------------------------------- | |
| 13 | +from __future__ import annotations | |
| 14 | + | |
| 15 | +import base64 | |
| 16 | +import html as _htmlmod | |
| 17 | +import json | |
| 18 | +import os | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from .base import BaseConnector | |
| 22 | +from . import _detailutil as du | |
| 23 | +from ..schema import Listing, parse_price | |
| 24 | + | |
| 25 | +DETAIL_LIMIT = int(os.environ.get("LOUKA_VC_DETAIL_LIMIT", "150")) | |
| 26 | +_FICHE_IMG_RE = re.compile( | |
| 27 | + r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"\'?)\s]+)', re.I) | |
| 28 | + | |
| 29 | +SITE = "https://www.viacapitalevendu.com" | |
| 30 | +SEARCH = f"{SITE}/recherche/residentiel/" | |
| 31 | +MAX_PAGES = int(os.environ.get("LOUKA_VC_MAX_PAGES", "40")) | |
| 32 | + | |
| 33 | +# La pagination du portail n'est active QUE si `criteresJson` est présent. | |
| 34 | +# Blob = critères vides + « AVendre »: false (= locations seulement) — | |
| 35 | +# construit au chargement pour rester lisible/modifiable. | |
| 36 | +_CRITERES = { | |
| 37 | + "Caracteristiques": [], "AutresCritere": [], "TypeDePropriete": [], | |
| 38 | + "TypeDeBatiment": None, "PeriodesAffichage": None, "AnneeDeConstruction": None, | |
| 39 | + "Region": None, "NomRegion": None, "NombreDeChambre": 0, "NombreDeBain": 0, | |
| 40 | + "PrixMinimum": "0", "PrixMaximum": "0", | |
| 41 | + "PrixLocationMinimum": "0", "PrixLocationMaximum": "0", | |
| 42 | + "OrderBy": None, "Type": 0, "AVendre": False, | |
| 43 | + "FromAfficherToutesPropriete": False, | |
| 44 | + "SuperficieMinimum": "", "SuperficieMaximum": "", "UniteMesure": "PC", | |
| 45 | + "MotsCles": None, "Zonage": None, "NombreUnites": 1, | |
| 46 | + "SuccursaleCode": None, "AgenceCode": None, "MembreCode": None, | |
| 47 | + "EquipeId": 0, "SuccursaleName": None, "AgenceName": None, | |
| 48 | + "MembreName": None, "EquipeName": None, "inputRegions": None, | |
| 49 | + "ReturnUrl": None, "Latitude": None, "Longitude": None, | |
| 50 | + "NoInscriptionNonDispo": None, "EnRecherche": False, | |
| 51 | + "PlusDeCriteres": "false", "Specialite": None, | |
| 52 | + "NomPlanEau": None, "NomPlanEauMobile": None, | |
| 53 | + "IdPlanEau": None, "IdPlanEauMobile": 0, | |
| 54 | + "GenrePropreteFrUrl": None, "GenreProprieteFrUrl": None, | |
| 55 | + "GenreProprieteEnUrl": None, "selecPAMobile": None, | |
| 56 | + "selecCaracMobile": [None] * 10, "selecACMobile": [None] * 10, | |
| 57 | + "selecTBMobile": [None] * 10, | |
| 58 | +} | |
| 59 | +CRITERES_JSON = base64.b64encode( | |
| 60 | + json.dumps(_CRITERES, separators=(",", ":")).encode()).decode() | |
| 61 | +IMG_HOST = "https://images.viacapitale.info" | |
| 62 | + | |
| 63 | +# Un bloc-carte commence à un lien vers une fiche d'inscription horodatée | |
| 64 | +CARD_SPLIT = re.compile(r'<a href="(https://www\.viacapitalevendu\.com/[^"]*?-(\d{6,}))"') | |
| 65 | +PRICE_RE = re.compile(r'class="price">\s*([\d ]+\$\s*/\s*mois)', re.I) | |
| 66 | +ADDR_RE = re.compile(r'addressListe[^>]*>\s*<a[^>]*title="([^"]+)"', re.I) | |
| 67 | +IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I) | |
| 68 | +TYPE_RE = re.compile( | |
| 69 | + r'(Appartement|Condo[\w\s]*|Maison[\w\s\-àâéèêëîïôûùç]*|Duplex|Triplex|' | |
| 70 | + r'Quadruplex|Loft|Studio|Maison de ville|Jumelé|Chalet)', re.I) | |
| 71 | +_PAREN = re.compile(r"\(([^)]*)\)") | |
| 72 | + | |
| 73 | + | |
| 74 | +def _unescape(s: str) -> str: | |
| 75 | + return _htmlmod.unescape(s).replace("\xa0", " ").replace(" ", " ").strip() | |
| 76 | + | |
| 77 | + | |
| 78 | +def _split_addr(full: str) -> tuple[str, str, str]: | |
| 79 | + """« 1455 Rue des Cèdres, Lévis (Les Chutes-de-la-Chaudière-Ouest) » | |
| 80 | + -> (adresse, ville, secteur).""" | |
| 81 | + if not full: | |
| 82 | + return "", "", "" | |
| 83 | + parts = [p.strip() for p in full.split(",", 1)] | |
| 84 | + address = parts[0] | |
| 85 | + city = sector = "" | |
| 86 | + if len(parts) > 1: | |
| 87 | + muni = parts[1] | |
| 88 | + parens = _PAREN.findall(muni) | |
| 89 | + city = _PAREN.sub("", muni).strip() | |
| 90 | + if parens: | |
| 91 | + sector = parens[-1].strip() | |
| 92 | + return address, city, sector | |
| 93 | + | |
| 94 | + | |
| 95 | +# slug ex. « saguenay-lac-saint-jean-sainte-hedwidge-ch-de-la-lievre-… » | |
| 96 | +_REGIONS = ( | |
| 97 | + "bas-saint-laurent", "saguenay-lac-saint-jean", "capitale-nationale", | |
| 98 | + "mauricie", "estrie", "montreal", "outaouais", "abitibi-temiscamingue", | |
| 99 | + "cote-nord", "nord-du-quebec", "gaspesie-iles-de-la-madeleine", | |
| 100 | + "chaudiere-appalaches", "laval", "lanaudiere", "laurentides", | |
| 101 | + "monteregie", "centre-du-quebec", | |
| 102 | +) | |
| 103 | + | |
| 104 | + | |
| 105 | +def _from_slug(url: str) -> tuple[str, str]: | |
| 106 | + tail = url.rstrip("/").rsplit("/", 1)[-1] | |
| 107 | + for reg in _REGIONS: | |
| 108 | + if tail.startswith(reg): | |
| 109 | + rest = tail[len(reg) + 1:] | |
| 110 | + city = rest.split("-")[0].replace("-", " ").title() if rest else "" | |
| 111 | + return reg.replace("-", " ").title(), city | |
| 112 | + return "", "" | |
| 113 | + | |
| 114 | + | |
| 115 | +class ViaCapitaleConnector(BaseConnector): | |
| 116 | + source_id = "via_capitale" | |
| 117 | + | |
| 118 | + def fetch(self) -> list[Listing]: | |
| 119 | + by_id: dict[str, Listing] = {} | |
| 120 | + empty_streak = 0 | |
| 121 | + for page in range(1, MAX_PAGES + 1): | |
| 122 | + url = f"{SEARCH}?page={page}&criteresJson={CRITERES_JSON}" | |
| 123 | + # Cloudflare renvoie parfois un challenge/vide par intermittence : | |
| 124 | + # on retente la page une fois avant de la considérer vraiment vide. | |
| 125 | + cards = [] | |
| 126 | + for _attempt in range(2): | |
| 127 | + try: | |
| 128 | + html = self.get_rendered(url) | |
| 129 | + except Exception: | |
| 130 | + html = "" | |
| 131 | + cards = self._parse_cards(html) | |
| 132 | + if cards: | |
| 133 | + break | |
| 134 | + new = 0 | |
| 135 | + for lst in cards: | |
| 136 | + if lst.uid not in by_id: | |
| 137 | + by_id[lst.uid] = lst | |
| 138 | + new += 1 | |
| 139 | + if not cards or new == 0: | |
| 140 | + empty_streak += 1 | |
| 141 | + if empty_streak >= 3: | |
| 142 | + break | |
| 143 | + else: | |
| 144 | + empty_streak = 0 | |
| 145 | + listings = list(by_id.values()) | |
| 146 | + # fiche détail (requête simple, hors Cloudflare) : galerie + description | |
| 147 | + du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v1") | |
| 148 | + return listings | |
| 149 | + | |
| 150 | + def _parse_cards(self, html: str) -> list[Listing]: | |
| 151 | + # découpe la page en segments, un par carte (lien fiche + id) | |
| 152 | + matches = list(CARD_SPLIT.finditer(html)) | |
| 153 | + out = [] | |
| 154 | + for i, m in enumerate(matches): | |
| 155 | + url, code = m.group(1), m.group(2) | |
| 156 | + seg = html[m.start(): matches[i + 1].start() if i + 1 < len(matches) | |
| 157 | + else m.start() + 2500] | |
| 158 | + out.append(self._to_listing(url, code, seg)) | |
| 159 | + return [x for x in out if x] | |
| 160 | + | |
| 161 | + def _to_listing(self, url: str, code: str, seg: str) -> Listing | None: | |
| 162 | + price_m = PRICE_RE.search(seg) | |
| 163 | + if not price_m: | |
| 164 | + return None # pas de « $ / mois » = pas une location rendue | |
| 165 | + price_label = _unescape(price_m.group(1)) | |
| 166 | + addr_m = ADDR_RE.search(seg) | |
| 167 | + address_full = _unescape(addr_m.group(1).strip()) if addr_m else "" | |
| 168 | + type_m = TYPE_RE.search(re.sub(r"<[^>]+>", " ", seg)) | |
| 169 | + prop_type = (re.split(r"\s{2,}|\n", type_m.group(1))[0].strip() | |
| 170 | + if type_m else "") | |
| 171 | + | |
| 172 | + address, city, sector = _split_addr(address_full) | |
| 173 | + | |
| 174 | + images = [] | |
| 175 | + for im in IMG_RE.finditer(seg): | |
| 176 | + u = f"{IMG_HOST}/images/inscriptions/{im.group(1)}/{im.group(2)}" | |
| 177 | + if u not in images: | |
| 178 | + images.append(u) | |
| 179 | + | |
| 180 | + region, city_slug = _from_slug(url) | |
| 181 | + details = {"Courtier": "Via Capitale"} | |
| 182 | + if region: | |
| 183 | + details["Région"] = region | |
| 184 | + if prop_type: | |
| 185 | + details["Type"] = prop_type | |
| 186 | + unit_type = prop_type if prop_type.lower() in ("studio", "loft") else "" | |
| 187 | + return Listing( | |
| 188 | + source=self.source_id, | |
| 189 | + external_id=code, | |
| 190 | + url=f"{SITE}/inscription/fichedescriptive/?code={code}", | |
| 191 | + title=f"{prop_type} — {address}".strip(" —") or address_full, | |
| 192 | + address=address, | |
| 193 | + city=city or city_slug, | |
| 194 | + sector=sector, | |
| 195 | + unit_type=unit_type, | |
| 196 | + price=parse_price(price_label), | |
| 197 | + price_label=price_label, | |
| 198 | + details=details, | |
| 199 | + images=images, | |
| 200 | + ) | |
| 201 | + | |
| 202 | + | |
| 203 | +def parse_vc_detail(html: str) -> dict: | |
| 204 | + """Fiche Via Capitale : galerie complète + description (plus long bloc).""" | |
| 205 | + out: dict = {} | |
| 206 | + seen, imgs = set(), [] | |
| 207 | + for m in _FICHE_IMG_RE.finditer(html): | |
| 208 | + u = f"{IMG_HOST}/images/inscriptions/{m.group(1)}/{m.group(2)}" | |
| 209 | + if u not in seen: | |
| 210 | + seen.add(u) | |
| 211 | + imgs.append(u) | |
| 212 | + if imgs: | |
| 213 | + out["images"] = imgs | |
| 214 | + # description = plus long bloc de texte visible (marketing de la propriété) | |
| 215 | + best = "" | |
| 216 | + for b in re.findall(r"<div[^>]*>(.*?)</div>", html, re.S): | |
| 217 | + txt = re.sub(r"\s+", " ", _htmlmod.unescape(re.sub(r"<[^>]+>", " ", b))).strip() | |
| 218 | + if len(txt) > len(best) and "window" not in txt and "function" not in txt \ | |
| 219 | + and "{" not in txt: | |
| 220 | + best = txt | |
| 221 | + if len(best) > 120: | |
| 222 | + out["description"] = best[:4000] | |
| 223 | + det = du.centris_details(du.flatten(html)) | |
| 224 | + if det: | |
| 225 | + out["details"] = det | |
| 226 | + # « Nombre de pièces : 4 pièces » → unité normalisable (4½ etc.) | |
| 227 | + pieces = det.get("Nombre de pièces") | |
| 228 | + if pieces: | |
| 229 | + out["unit_type"] = pieces | |
| 230 | + return out | |
| 231 | ||