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.9 KB · 196 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/m_immobilier.py : M Immobilier (mimmobilier.com) — LOCATIONS5#   Agence indépendante de prestige (Grand Montréal), inscriptions Centris.6#   La liste /properties est rendue serveur et paginée ; le formulaire expose7#   un filtre statut « à louer » (radio name="CODE_STATUT" value="AL") qu'on8#   rejoue en query string : /properties?CODE_STATUT=AL&page=N. Chaque carte9#   « cardProperty » porte l'URL (avec no Centris), le badge « à louer », la10#   ville, l'adresse, chambres, salles de bains, superficie (PC) et le loyer11#   « X $ / M ». Adapté du connecteur « à vendre » d'Immo-Ka12#   (agent-courtage/immoka), qui excluait justement ces cartes-là.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import html as _html17import os18import re1920from ..schema import Listing21from .base import BaseConnector2223from . import _detailutil as du2425BASE = "https://www.mimmobilier.com"26LISTING_URL = f"{BASE}/properties?CODE_STATUT=AL"      # AL = à louer (serveur)27CARD_SPLIT = "cardProperty col-span-12"28HREF_RE = re.compile(r'href="(/properties/[^"]+/(\d+))"')29IMG_RE = re.compile(r'(/images/centris-slideshow/\d+-\d+-\d+\.(?:jpg|jpeg|png|webp))', re.I)30# loyer « 1 500.0 $ / M » (M = mois) — le montant précède le $31_RENT_RE = re.compile(r"([\d\s ,]+(?:\.\d+)?)\s*\$\s*/\s*M", re.I)32_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)33# l'agence loue aussi des locaux commerciaux au mois (bureaux, entrepôts…) sans34# ligne « chambres » : on ne garde ces cartes-là que si l'extrait de description35# contient un marqueur clairement résidentiel (studio, bachelor, logement…)36_RESIDENTIAL_RE = re.compile(37    r"\b(studios?|bachelor|logements?|appartements?|condos?|chambres?|"38    r"r[ée]sidentiel(?:le)?s?|unit[ée]s?|laveuse|maison|complexe)\b", re.I)39MAX_PAGES = int(os.environ.get("LOUKA_MIMMO_MAX_PAGES", "10"))40DETAIL_LIMIT = int(os.environ.get("LOUKA_MIMMO_DETAIL_LIMIT", "100"))414243class MImmobilierConnector(BaseConnector):44    source_id = "m_immobilier"45    request_delay = 0.64647    def fetch(self) -> list[Listing]:48        out: dict[str, Listing] = {}49        for page in range(1, MAX_PAGES + 1):50            url = LISTING_URL if page == 1 else f"{LISTING_URL}&page={page}"51            try:52                html = self.get(url).text53            except Exception:54                break55            cards = html.split(CARD_SPLIT)[1:]56            fresh = 057            for card in cards:58                lst = self._parse_card(CARD_SPLIT + card[:6000])59                if lst and lst.uid not in out:60                    out[lst.uid] = lst61                    fresh += 162            # dernière page atteinte (vide ou uniquement des doublons)63            if not cards or fresh == 0:64                break65        listings = list(out.values())66        # fiche détail : galerie Centris complète + description + adresse pleine67        du.enrich(self, listings, DETAIL_LIMIT, parse_m_detail, key="v1")68        return listings6970    def _parse_card(self, card: str) -> Listing | None:71        m = HREF_RE.search(card)72        if not m:73            return None74        url = BASE + m.group(1)75        external_id = m.group(2)7677        images = []78        for im in IMG_RE.findall(card):79            full = BASE + im80            if full not in images:81                images.append(full)8283        # texte du carton, ligne par ligne : badge statut, ville, adresse,84        # extrait de description, puis paires libellé/valeur85        text = _html.unescape(re.sub(r"<[^>]+>", "\n", card))86        lines = [l.strip() for l in text.splitlines() if l.strip()]87        try:88            k = next(i for i, l in enumerate(lines)89                     if l.lower().startswith(("à louer", "a louer")))90        except StopIteration:91            return None                     # vente/vendu/loué : pas une location active92        city_raw = lines[k + 1] if k + 1 < len(lines) else ""93        address = lines[k + 2] if k + 2 < len(lines) else ""94        snippet = lines[k + 3] if k + 3 < len(lines) else ""9596        price_label = _after(lines, "prix")97        if "/ m" not in price_label.lower():98            return None                     # garde-fou : loyer mensuel attendu99        price = None100        pm = _RENT_RE.search(price_label)101        if pm:102            try:103                price = float(pm.group(1).replace(" ", "").replace(" ", "")104                              .replace(",", ""))105            except ValueError:106                price = None107108        beds = _int(_after(lines, "chambres"))          # « 3 + 1 » -> 4109        baths = _after(lines, "salles de bains")110        sqft = _int(_after(lines, "pc"))                # PC = pieds carrés111112        # sans chambres, c'est souvent un local commercial loué au mois :113        # on exige un marqueur résidentiel dans l'extrait (studio, bachelor…)114        unit_type = f"{beds} chambres" if beds else ""115        if beds is None:116            if not _RESIDENTIAL_RE.search(snippet):117                return None118            if re.search(r"\b(studios?|bachelor)\b", snippet, re.I):119                unit_type = "Studio"120121        # ville « Montréal (Le Plateau-Mont-Royal) » -> ville + secteur ;122        # les cartes tronquent à ~30 caractères (« Notr... ») -> on nettoie123        city, sector = city_raw, ""124        cm = re.match(r"^(.*?)\s*\(([^)]*)\)?\s*$", city_raw)125        if cm and cm.group(2):126            city, sector = cm.group(1).strip(), cm.group(2).strip()127        city = city.rstrip(".").rstrip()128        sector = re.sub(r"\.{2,}$", "", sector).rstrip("/ -")129130        # adresse parfois tronquée (« 9017 Rue Jean-Baptiste-Gauthie... ») :131        # on la laisse vide et le H1 de la fiche détail la complète132        truncated = address.endswith("...")133        title = f"{re.sub(r'[.]{3,}$', '', address)}, {city}".strip(", ")134135        lst = Listing(136            source=self.source_id,137            external_id=external_id,138            url=url,139            title=title,140            address="" if truncated else address,141            sector=sector,142            city=city,143            unit_type=unit_type,144            price=price,145            price_label=price_label,146            description=re.sub(r"[.]{3,}$", "", snippet),147            details={"Agence": "M Immobilier", "No Centris": external_id},148            images=images,149        )150        if baths:151            lst.details["Salles de bain"] = baths.replace(" + ", "+")152        if sqft:153            lst.area_sqft = float(sqft)154        return lst155156157def parse_m_detail(html: str) -> dict:158    """Galerie Centris complète + description (JSON-LD) + adresse pleine (H1)."""159    out: dict = {}160    imgs = []161    seen = set()162    for im in IMG_RE.findall(html):163        full = BASE + im164        if full not in seen:165            seen.add(full)166            imgs.append(full)167    if imgs:168        out["images"] = imgs169    desc = du.ld_description(html)170    if desc:171        out["description"] = desc172    m = _H1_RE.search(html)173    if m:174        h1 = _html.unescape(re.sub(r"<[^>]+>", " ", m.group(1))).strip()175        if h1 and re.match(r"\d", h1):176            out["address"] = h1             # complète les adresses tronquées177    # pas de table de caractéristiques exploitable sur la fiche (seule la table178    # des pièces existe, qui produirait du bruit) : galerie + description suffisent179    return out180181182def _after(lines: list[str], label: str) -> str:183    lab = label.lower()184    for i, l in enumerate(lines):185        if l.lower() == lab and i + 1 < len(lines):186            return lines[i + 1]187    return ""188189190def _int(s: str):191    if not s:192        return None193    # « 3 + 1 » -> 4 (chambres principales + sous-sol)194    nums = [int(x) for x in re.findall(r"\d+", s)]195    return sum(nums) if nums else None196