Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/inspire_laval.py : connecteur Groupe Inspire (groupeinspire.ca) —5# gestionnaire (siège à Terrebonne) d'immeubles résidentiels à Laval6# (2525 Havre-des-Îles), Montréal (Randall, Trent, Bois-de-Boulogne,7# Hurteau, LaSalle…). WordPress custom rendu serveur : la page8# /espaces_disponibles/ affiche des cartes div.location (nom, secteur9# Résidentiel/Commercial/Industriel, typologies « 3 ½, 4 ½ et 5 ½ »,10# « à partir de … $ », points forts). La page détail /locations/<slug>/11# fournit l'adresse civique complète et les photos (via cache detail()).12# Seules les cartes RÉSIDENTIELLES sont retenues (commercial/industriel13# exclus). Granularité TYPOLOGIE par immeuble ; le prix « à partir de »14# n'est associé qu'à la plus petite typologie (jamais de prix inventé).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing23from .base import BaseConnector2425BASE = "https://groupeinspire.ca"26LIST_URL = f"{BASE}/espaces_disponibles/"2728TYPES_RE = re.compile(r"([2-6])\s*½")29PRICE_RE = re.compile(r"à partir de\s*([\d ]{3,9})\s*\$", re.I)30# adresse détail : « 2525 Avenue du Havre des Îles, Laval, QC, Canada »31# ou ordre anglais : « 5555 Trent Avenue, Côte Saint-Luc, QC, Canada »32ADDR_RE = re.compile(33 r"(\d{2,5}(?:-\d{2,5})?\s+"34 r"(?:(?:Avenue|Rue|Boulevard|Chemin|Montée|Place)[^,\n]{2,60}"35 r"|[^,\n]{2,60}?(?:Avenue|Street|Boulevard|Road)))"36 r",\s*([\wÀ-ÿ' .-]{3,40}),\s*(?:QC|Québec)", re.I)37IMG_RE = re.compile(r"https://groupeinspire\.ca/wp-content/uploads/"38 r"[^\"'\s?]+\.(?:jpe?g|png|webp)", re.I)394041class InspireLavalConnector(BaseConnector):42 source_id = "inspire_laval"43 request_delay = 0.84445 def fetch(self) -> list[Listing]:46 listings: list[Listing] = []47 try:48 html = self.get(LIST_URL).text49 except Exception:50 return listings51 soup = BeautifulSoup(html, "html.parser")52 for card in soup.select("div.location"):53 cats = [p.get_text(strip=True)54 for p in card.select(".infos-cats p")]55 if "Résidentiel" not in cats:56 continue # industriel/commercial exclus57 link = card.select_one('a[href*="/locations/"]')58 name_el = card.select_one("h4")59 if link is None or name_el is None:60 continue61 url = link.get("href") or LIST_URL62 slug = url.rstrip("/").rsplit("/", 1)[-1]63 name = name_el.get_text(" ", strip=True)6465 specs = [p.get_text(" ", strip=True)66 for p in card.select(".infos-specs p")]67 types_line = next((s for s in specs if TYPES_RE.search(s)), "")68 types = [f"{n}½" for n in TYPES_RE.findall(types_line)]69 price_label = next((s for s in specs if PRICE_RE.search(s)), "")70 price = None71 pm = PRICE_RE.search(price_label)72 if pm:73 try:74 price = float(re.sub(r"[ ]", "", pm.group(1)))75 except ValueError:76 pass77 highlights = [p.get_text(" ", strip=True)78 for p in card.select(".infos-specs p.highlight")]79 highlights = [h for h in highlights80 if not h.lower().startswith("parler avec")]8182 detail = self.detail(slug, f"{types_line}|{price_label}",83 lambda u=url: self._detail(u))84 address = detail.get("address", "")85 city = detail.get("city", "")86 images = detail.get("images") or []87 description = detail.get("description", "")88 if not city: # repli : ville dans le nom89 cm = re.search(r",\s*([\wÀ-ÿ' -]+)$", name)90 city = (cm.group(1).strip() if cm else "")91 city = {"Montreal": "Montréal", "Lasalle": "Montréal"}.get(city, city)9293 # une annonce par typologie ; « à partir de » = plus petite typologie94 for i, ut in enumerate(types or [""]):95 first = (i == 0)96 listings.append(Listing(97 source=self.source_id,98 external_id=f"{slug}-{ut.replace('½', '.5') or 'res'}",99 url=url,100 title=f"{name} — {ut}" if ut else name,101 address=address,102 city=city,103 unit_type=ut,104 price=price if first else None,105 price_label=(price_label if first else ""),106 availability="Disponible",107 description=description[:2000],108 amenities=list(highlights),109 details=({"price_from": True, "building": name}110 if first and price else {"building": name}),111 images=images,112 ))113 return listings114115 # -- page détail /locations/<slug>/ : adresse, photos, descriptif ----------116 def _detail(self, url: str) -> dict:117 payload: dict = {}118 try:119 html = self.get(url).text120 except Exception:121 return payload122 soup = BeautifulSoup(html, "html.parser")123 # adresse : bloc infos de la fiche seulement (le pied de page et les124 # carrousels « autres propriétés » contiennent d'autres adresses)125 scope = (soup.select_one(".top-content") or soup.select_one(".infos")126 or soup)127 text = scope.get_text("\n", strip=True)128 m = ADDR_RE.search(text)129 if m:130 payload["address"] = m.group(1).strip()131 payload["city"] = m.group(2).strip()132 imgs = [u for u in dict.fromkeys(IMG_RE.findall(html))133 if not re.search(r"logo|icon|-\d+x\d+\.", u, re.I)]134 payload["images"] = imgs[:15]135 # premier paragraphe descriptif substantiel136 for p in soup.select(".content-locations p, .top-content p"):137 t = p.get_text(" ", strip=True)138 if len(t) > 80:139 payload["description"] = t140 break141 return payload142