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
Large diff (191 lines) is not loaded by default to keep the page fast.Load diff
added
louka/connectors/sutton.py
+431 −0
Large diff (431 lines) is not loaded by default to keep the page fast.Load diff
added
louka/connectors/ubee.py
+123 −0
Large diff (123 lines) is not loaded by default to keep the page fast.Load diff
added
louka/connectors/via_capitale.py
+230 −0
Large diff (231 lines) is not loaded by default to keep the page fast.Load diff