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%
5.7 KB · 144 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/tours_st_martin.py : connecteur Les Tours Saint-Martin5#   (lestourssaintmartin.ca) — deux tours locatives de 12 étages au6#   2976, boulevard Saint-Martin Ouest, Laval (Chomedey). Même thème WordPress7#   maison que Le L Laval (l_laval.py) : pages /plans/ (phase 1) et8#   /plans-phase-2/ avec une ligne <tr id="rowUnitNNN"> par unité — classe9#   « not-available » = louée ; onclick=loadPage(unité, prix, pi², étage, état,10#   modèle, typologie). Les prix par unité ne sont pas publiés : on récupère les11#   « à partir de » affichés sur la page d'accueil (ex. « Grands 4½ à partir de12#   1972$/mois ») comme price_label par typologie. Granularité : unité.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from ..schema import Listing, normalize_unit_type19from .base import BaseConnector2021BASE = "https://lestourssaintmartin.ca"22PHASES = [("1", f"{BASE}/plans/"), ("2", f"{BASE}/plans-phase-2/")]2324ADDRESS = "2976, boulevard Saint-Martin Ouest, Laval"25CITY = "Laval"26SECTOR = "Chomedey"2728ROW_RE = re.compile(29    r'<tr\s+id="rowUnit(\w+)"[^>]*class="([^"]*)"[^>]*'30    r"onclick='loadPage\(([^)]*)\)'", re.I)31ARG_RE = re.compile(r'"([^"]*)"')32# « Grands 4½ à partir de 1972$/mois » (page d'accueil)33FROM_RE = re.compile(34    r"(\d\s*(?:½|1/2|&frac12;)|[Ss]tudios?)[^<$]{0,40}?"35    r"à\s+partir\s+de\s+(\d[\d\s,]*)\s*\$", re.I)36IMG_RE = re.compile(37    r"https://lestourssaintmartin\.ca/wp-content/uploads/"38    r"[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I)39SKIP_IMG_RE = re.compile(r"logo|favicon|icon|plan|-\d{2,4}x\d{2,4}\.", re.I)404142class ToursStMartinConnector(BaseConnector):43    source_id = "tours_st_martin"44    request_delay = 0.645    max_images = 104647    def _from_prices(self) -> dict[str, str]:48        """{'4½': 'à partir de 1972$/mois'} depuis la page d'accueil."""49        prices: dict[str, str] = {}50        try:51            html = self.get(f"{BASE}/").text52        except Exception:53            return prices54        for typo, amount in FROM_RE.findall(html):55            key = normalize_unit_type(56                typo.replace("&frac12;", "½").replace("1/2", "½"))57            amount = amount.strip().replace(" ", "")58            if key and key not in prices:59                prices[key] = f"à partir de {amount}$/mois"60        return prices6162    def _site_images(self) -> list[str]:63        try:64            html = self.get(f"{BASE}/appartements-laval/").text65        except Exception:66            return []67        return [u for u in dict.fromkeys(IMG_RE.findall(html))68                if not SKIP_IMG_RE.search(u)][: self.max_images]6970    def fetch(self) -> list[Listing]:71        from_prices = self._from_prices()72        images = self._site_images()7374        listings: list[Listing] = []75        for phase, url in PHASES:76            try:77                html = self.get(url).text78            except Exception:79                continue80            seen: set[str] = set()81            for unit, classes, raw_args in ROW_RE.findall(html):82                if "not-available" in classes or unit in seen:83                    continue84                seen.add(unit)85                # loadPage(unité, prix, pi², étage, état, modèle, typologie)86                args = ARG_RE.findall(raw_args)87                if len(args) < 5:88                    continue89                _, price_raw, sqft_raw, floor, etat = args[:5]90                if etat.strip() not in ("", "0"):91                    continue92                model = args[5] if len(args) > 5 else ""93                typo = args[6] if len(args) > 6 else ""94                typo = typo.replace("&frac12;", "½").replace("1/2", "½")95                unit_type = normalize_unit_type(typo)9697                price = None98                price_label = ""99                m = re.search(r"(\d[\d\s,]*)\s*\$", price_raw)100                if m:101                    try:102                        val = float(m.group(1).replace(" ", "")103                                    .replace(",", ""))104                        if 300 <= val <= 20000:105                            price = val106                            price_label = price_raw.strip()107                    except ValueError:108                        pass109                if price is None:110                    price_label = from_prices.get(unit_type,111                                                  "Prix sur demande")112113                area = None114                try:115                    v = float(sqft_raw)116                    if 100 <= v <= 10000:117                        area = v118                except ValueError:119                    pass120121                desc_bits = [122                    f"Phase {phase}",123                    f"Unité {unit}, {floor}e étage" if floor else "",124                    f"Modèle {model}" if model else "",125                ]126                listings.append(Listing(127                    source=self.source_id,128                    external_id=f"p{phase}-{unit}",129                    url=f"{url}#unite-{unit}",130                    title=f"Les Tours Saint-Martin (phase {phase})"131                          f" — Unité {unit}",132                    address=ADDRESS,133                    sector=SECTOR,134                    city=CITY,135                    unit_type=unit_type,136                    price=price,137                    price_label=price_label,138                    availability="Disponible",139                    area_sqft=area,140                    description=" — ".join(b for b in desc_bits if b),141                    images=list(images),142                ))143        return listings144