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/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