# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lameer.py : connecteur Gestion Lameer (lameer.ca, 2300+ unités) # Site Webflow rendu serveur : /properties liste les immeubles (cartes # a.properties_item-link), chaque fiche /properties/ expose un tableau # « overview » (Neighbourhood, Postal Code, Bedrooms « 2.5 to 4.5 », # Starting Price, Appliances/Laundry/Heating/Hydro/Parking) + description et # galerie (cdn.prod.website-files.com). Immeubles résidentiels seulement # (Building Type = Residential). Une annonce par immeuble, prix « à partir # de » ; external_id = slug de la fiche. Fiches via le cache détail (clé = # hash du texte de la carte). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.lameer.ca" LIST_URL = f"{BASE}/properties" BEDS_RANGE_RE = re.compile(r"([\d.]+)\s*to\s*([\d.]+)") IMG_BAD_RE = re.compile(r"logo|chevron|icon|favicon|\.svg", re.I) # Neighbourhood (site anglophone) -> (secteur, ville) _HOODS = { "montreal": ("", "Montréal"), "lachine": ("Lachine", "Montréal"), "verdun": ("Verdun", "Montréal"), "lasalle": ("LaSalle", "Montréal"), "cote saint-luc": ("", "Côte Saint-Luc"), "pointe-claire": ("", "Pointe-Claire"), "mount royal": ("", "Mont-Royal"), } # lignes du tableau overview reprises comme commodités (valeur informative) _AMENITY_ROWS = ("Appliances", "Laundry", "Heating / Hot Water", "Hydro", "Parking") _AMENITY_FR = { "Appliances": "Électroménagers", "Laundry": "Buanderie", "Heating / Hot Water": "Chauffage / eau chaude", "Hydro": "Électricité", "Parking": "Stationnement", } _VALUE_FR = {"included": "incluse(s)", "in-suite": "dans l'unité", "available": "disponible", "not included": "non incluse(s)", "on-site": "sur place"} class LameerConnector(BaseConnector): source_id = "lameer" request_delay = 0.7 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: list[Listing] = [] for card in soup.select("a.properties_item-link[href]"): try: href = card["href"] slug = href.rstrip("/").rsplit("/", 1)[-1] card_text = card.get_text(" | ", strip=True) if not card_text.startswith("Residential"): continue # immeubles commerciaux exclus # photo de couverture de la carte : repli si la fiche n'a # pas de galerie (certaines n'ont que logo/chevrons) cover = "" img = card.find("img") if img: u = img.get("src") or img.get("data-src") or "" if u.startswith("http") and not IMG_BAD_RE.search(u): cover = u key = hashlib.sha1(card_text.encode("utf-8")).hexdigest() d = self.detail(slug, key, lambda h=href: self._property(f"{BASE}{h}")) if cover and not d.get("images"): d = dict(d, images=[cover]) lst = self._listing(slug, f"{BASE}{href}", d) if lst: listings.append(lst) except Exception: continue return listings def _property(self, url: str) -> dict: """Scrape la fiche immeuble : tableau overview, description, photos.""" out: dict = {"title": "", "rows": {}, "desc": "", "images": []} page = self.get(url).text soup = BeautifulSoup(page, "html.parser") if soup.h1: out["title"] = soup.h1.get_text(" ", strip=True) for row in soup.select(".overview-table_row"): cells = [c.get_text(" ", strip=True) for c in row.find_all("div", recursive=False)] if len(cells) >= 2 and cells[0]: out["rows"][cells[0]] = cells[1] for p in soup.find_all("p"): t = p.get_text(" ", strip=True) # premier paragraphe substantiel = description de l'immeuble # (on écarte le boilerplate « management services » du pied de page) if len(t) > 80 and "management services" not in t: out["desc"] = t[:800] break images = [] for img in soup.find_all("img"): u = img.get("src") or "" if u.startswith("https://cdn.prod.website-files.com") \ and not IMG_BAD_RE.search(u) and u not in images: images.append(u) out["images"] = images[:15] return out def _listing(self, slug: str, url: str, d: dict) -> Listing | None: rows = d.get("rows", {}) title = d.get("title") or slug.replace("-", " ").title() hood = (rows.get("Neighbourhood") or "").strip() sector, city = _HOODS.get(strip_accents(hood.lower()), (hood, "Montréal")) postal = (rows.get("Postal Code") or "").strip() address = title + (f", {city}" if city else "") if postal: address += f", QC {postal}" price = None price_label = "" sp = (rows.get("Starting Price") or "").strip() if sp: price_label = f"À partir de {sp} $" price = parse_price(price_label) # « 2.5 to 4.5 » : gamme de types ; type = borne basse unit_type = "" beds = (rows.get("Bedrooms") or "").strip() amenities: list[str] = [] m = BEDS_RANGE_RE.search(beds) if m: lo, hi = m.group(1), m.group(2) unit_type = normalize_unit_type(lo.replace(".5", " 1/2")) try: borne_ok = float(hi) >= float(lo) except ValueError: borne_ok = False if borne_ok: # coquilles source (« 2.5 to .5 ») amenities.append(f"Unités de {lo.replace('.5', '½')} " f"à {hi.replace('.5', '½')}") else: amenities.append(f"Unités de {lo.replace('.5', '½')} et plus") elif beds: unit_type = normalize_unit_type(beds.replace(".5", " 1/2")) for k in _AMENITY_ROWS: v = (rows.get(k) or "").strip() if v: amenities.append( f"{_AMENITY_FR.get(k, k)} : {_VALUE_FR.get(v.lower(), v)}") return Listing( source=self.source_id, external_id=slug, url=url, title=title, address=address, sector=sector, city=city, unit_type=unit_type, price=price, price_label=price_label, availability="", description=d.get("desc", ""), amenities=amenities, images=d.get("images", []), )