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%
7.2 KB · 178 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/lameer.py : connecteur Gestion Lameer (lameer.ca, 2300+ unités)5#   Site Webflow rendu serveur : /properties liste les immeubles (cartes6#   a.properties_item-link), chaque fiche /properties/<slug> expose un tableau7#   « overview » (Neighbourhood, Postal Code, Bedrooms « 2.5 to 4.5 »,8#   Starting Price, Appliances/Laundry/Heating/Hydro/Parking) + description et9#   galerie (cdn.prod.website-files.com). Immeubles résidentiels seulement10#   (Building Type = Residential). Une annonce par immeuble, prix « à partir11#   de » ; external_id = slug de la fiche. Fiches via le cache détail (clé =12#   hash du texte de la carte).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price, strip_accents22from .base import BaseConnector2324BASE = "https://www.lameer.ca"25LIST_URL = f"{BASE}/properties"2627BEDS_RANGE_RE = re.compile(r"([\d.]+)\s*to\s*([\d.]+)")28IMG_BAD_RE = re.compile(r"logo|chevron|icon|favicon|\.svg", re.I)2930# Neighbourhood (site anglophone) -> (secteur, ville)31_HOODS = {32    "montreal": ("", "Montréal"),33    "lachine": ("Lachine", "Montréal"),34    "verdun": ("Verdun", "Montréal"),35    "lasalle": ("LaSalle", "Montréal"),36    "cote saint-luc": ("", "Côte Saint-Luc"),37    "pointe-claire": ("", "Pointe-Claire"),38    "mount royal": ("", "Mont-Royal"),39}4041# lignes du tableau overview reprises comme commodités (valeur informative)42_AMENITY_ROWS = ("Appliances", "Laundry", "Heating / Hot Water", "Hydro",43                 "Parking")44_AMENITY_FR = {45    "Appliances": "Électroménagers",46    "Laundry": "Buanderie",47    "Heating / Hot Water": "Chauffage / eau chaude",48    "Hydro": "Électricité",49    "Parking": "Stationnement",50}51_VALUE_FR = {"included": "incluse(s)", "in-suite": "dans l'unité",52             "available": "disponible", "not included": "non incluse(s)",53             "on-site": "sur place"}545556class LameerConnector(BaseConnector):57    source_id = "lameer"58    request_delay = 0.75960    def fetch(self) -> list[Listing]:61        html = self.get(LIST_URL).text62        soup = BeautifulSoup(html, "html.parser")63        listings: list[Listing] = []64        for card in soup.select("a.properties_item-link[href]"):65            try:66                href = card["href"]67                slug = href.rstrip("/").rsplit("/", 1)[-1]68                card_text = card.get_text(" | ", strip=True)69                if not card_text.startswith("Residential"):70                    continue                 # immeubles commerciaux exclus71                # photo de couverture de la carte : repli si la fiche n'a72                # pas de galerie (certaines n'ont que logo/chevrons)73                cover = ""74                img = card.find("img")75                if img:76                    u = img.get("src") or img.get("data-src") or ""77                    if u.startswith("http") and not IMG_BAD_RE.search(u):78                        cover = u79                key = hashlib.sha1(card_text.encode("utf-8")).hexdigest()80                d = self.detail(slug, key,81                                lambda h=href: self._property(f"{BASE}{h}"))82                if cover and not d.get("images"):83                    d = dict(d, images=[cover])84                lst = self._listing(slug, f"{BASE}{href}", d)85                if lst:86                    listings.append(lst)87            except Exception:88                continue89        return listings9091    def _property(self, url: str) -> dict:92        """Scrape la fiche immeuble : tableau overview, description, photos."""93        out: dict = {"title": "", "rows": {}, "desc": "", "images": []}94        page = self.get(url).text95        soup = BeautifulSoup(page, "html.parser")96        if soup.h1:97            out["title"] = soup.h1.get_text(" ", strip=True)98        for row in soup.select(".overview-table_row"):99            cells = [c.get_text(" ", strip=True)100                     for c in row.find_all("div", recursive=False)]101            if len(cells) >= 2 and cells[0]:102                out["rows"][cells[0]] = cells[1]103        for p in soup.find_all("p"):104            t = p.get_text(" ", strip=True)105            # premier paragraphe substantiel = description de l'immeuble106            # (on écarte le boilerplate « management services » du pied de page)107            if len(t) > 80 and "management services" not in t:108                out["desc"] = t[:800]109                break110        images = []111        for img in soup.find_all("img"):112            u = img.get("src") or ""113            if u.startswith("https://cdn.prod.website-files.com") \114                    and not IMG_BAD_RE.search(u) and u not in images:115                images.append(u)116        out["images"] = images[:15]117        return out118119    def _listing(self, slug: str, url: str, d: dict) -> Listing | None:120        rows = d.get("rows", {})121        title = d.get("title") or slug.replace("-", " ").title()122        hood = (rows.get("Neighbourhood") or "").strip()123        sector, city = _HOODS.get(strip_accents(hood.lower()),124                                  (hood, "Montréal"))125        postal = (rows.get("Postal Code") or "").strip()126        address = title + (f", {city}" if city else "")127        if postal:128            address += f", QC {postal}"129130        price = None131        price_label = ""132        sp = (rows.get("Starting Price") or "").strip()133        if sp:134            price_label = f"À partir de {sp} $"135            price = parse_price(price_label)136137        # « 2.5 to 4.5 » : gamme de types ; type = borne basse138        unit_type = ""139        beds = (rows.get("Bedrooms") or "").strip()140        amenities: list[str] = []141        m = BEDS_RANGE_RE.search(beds)142        if m:143            lo, hi = m.group(1), m.group(2)144            unit_type = normalize_unit_type(lo.replace(".5", " 1/2"))145            try:146                borne_ok = float(hi) >= float(lo)147            except ValueError:148                borne_ok = False149            if borne_ok:                     # coquilles source (« 2.5 to .5 »)150                amenities.append(f"Unités de {lo.replace('.5', '½')} "151                                 f"à {hi.replace('.5', '½')}")152            else:153                amenities.append(f"Unités de {lo.replace('.5', '½')} et plus")154        elif beds:155            unit_type = normalize_unit_type(beds.replace(".5", " 1/2"))156        for k in _AMENITY_ROWS:157            v = (rows.get(k) or "").strip()158            if v:159                amenities.append(160                    f"{_AMENITY_FR.get(k, k)} : {_VALUE_FR.get(v.lower(), v)}")161162        return Listing(163            source=self.source_id,164            external_id=slug,165            url=url,166            title=title,167            address=address,168            sector=sector,169            city=city,170            unit_type=unit_type,171            price=price,172            price_label=price_label,173            availability="",174            description=d.get("desc", ""),175            amenities=amenities,176            images=d.get("images", []),177        )178