# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/inspire_laval.py : connecteur Groupe Inspire (groupeinspire.ca) — # gestionnaire (siège à Terrebonne) d'immeubles résidentiels à Laval # (2525 Havre-des-Îles), Montréal (Randall, Trent, Bois-de-Boulogne, # Hurteau, LaSalle…). WordPress custom rendu serveur : la page # /espaces_disponibles/ affiche des cartes div.location (nom, secteur # Résidentiel/Commercial/Industriel, typologies « 3 ½, 4 ½ et 5 ½ », # « à partir de … $ », points forts). La page détail /locations// # fournit l'adresse civique complète et les photos (via cache detail()). # Seules les cartes RÉSIDENTIELLES sont retenues (commercial/industriel # exclus). Granularité TYPOLOGIE par immeuble ; le prix « à partir de » # n'est associé qu'à la plus petite typologie (jamais de prix inventé). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://groupeinspire.ca" LIST_URL = f"{BASE}/espaces_disponibles/" TYPES_RE = re.compile(r"([2-6])\s*½") PRICE_RE = re.compile(r"à partir de\s*([\d ]{3,9})\s*\$", re.I) # adresse détail : « 2525 Avenue du Havre des Îles, Laval, QC, Canada » # ou ordre anglais : « 5555 Trent Avenue, Côte Saint-Luc, QC, Canada » ADDR_RE = re.compile( r"(\d{2,5}(?:-\d{2,5})?\s+" r"(?:(?:Avenue|Rue|Boulevard|Chemin|Montée|Place)[^,\n]{2,60}" r"|[^,\n]{2,60}?(?:Avenue|Street|Boulevard|Road)))" r",\s*([\wÀ-ÿ' .-]{3,40}),\s*(?:QC|Québec)", re.I) IMG_RE = re.compile(r"https://groupeinspire\.ca/wp-content/uploads/" r"[^\"'\s?]+\.(?:jpe?g|png|webp)", re.I) class InspireLavalConnector(BaseConnector): source_id = "inspire_laval" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") for card in soup.select("div.location"): cats = [p.get_text(strip=True) for p in card.select(".infos-cats p")] if "Résidentiel" not in cats: continue # industriel/commercial exclus link = card.select_one('a[href*="/locations/"]') name_el = card.select_one("h4") if link is None or name_el is None: continue url = link.get("href") or LIST_URL slug = url.rstrip("/").rsplit("/", 1)[-1] name = name_el.get_text(" ", strip=True) specs = [p.get_text(" ", strip=True) for p in card.select(".infos-specs p")] types_line = next((s for s in specs if TYPES_RE.search(s)), "") types = [f"{n}½" for n in TYPES_RE.findall(types_line)] price_label = next((s for s in specs if PRICE_RE.search(s)), "") price = None pm = PRICE_RE.search(price_label) if pm: try: price = float(re.sub(r"[ ]", "", pm.group(1))) except ValueError: pass highlights = [p.get_text(" ", strip=True) for p in card.select(".infos-specs p.highlight")] highlights = [h for h in highlights if not h.lower().startswith("parler avec")] detail = self.detail(slug, f"{types_line}|{price_label}", lambda u=url: self._detail(u)) address = detail.get("address", "") city = detail.get("city", "") images = detail.get("images") or [] description = detail.get("description", "") if not city: # repli : ville dans le nom cm = re.search(r",\s*([\wÀ-ÿ' -]+)$", name) city = (cm.group(1).strip() if cm else "") city = {"Montreal": "Montréal", "Lasalle": "Montréal"}.get(city, city) # une annonce par typologie ; « à partir de » = plus petite typologie for i, ut in enumerate(types or [""]): first = (i == 0) listings.append(Listing( source=self.source_id, external_id=f"{slug}-{ut.replace('½', '.5') or 'res'}", url=url, title=f"{name} — {ut}" if ut else name, address=address, city=city, unit_type=ut, price=price if first else None, price_label=(price_label if first else ""), availability="Disponible", description=description[:2000], amenities=list(highlights), details=({"price_from": True, "building": name} if first and price else {"building": name}), images=images, )) return listings # -- page détail /locations// : adresse, photos, descriptif ---------- def _detail(self, url: str) -> dict: payload: dict = {} try: html = self.get(url).text except Exception: return payload soup = BeautifulSoup(html, "html.parser") # adresse : bloc infos de la fiche seulement (le pied de page et les # carrousels « autres propriétés » contiennent d'autres adresses) scope = (soup.select_one(".top-content") or soup.select_one(".infos") or soup) text = scope.get_text("\n", strip=True) m = ADDR_RE.search(text) if m: payload["address"] = m.group(1).strip() payload["city"] = m.group(2).strip() imgs = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|-\d+x\d+\.", u, re.I)] payload["images"] = imgs[:15] # premier paragraphe descriptif substantiel for p in soup.select(".content-locations p, .top-content p"): t = p.get_text(" ", strip=True) if len(t) > 80: payload["description"] = t break return payload