SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.4 KB · 142 lines python
Raw Blame History
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