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.6 KB · 142 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/trams.py : connecteur T.R.A.M.S Property Management (tramsmgmt.com)5#   Gestionnaire montréalais : 3 immeubles locatifs résidentiels au Québec —6#   Chateau Ste-Marie (Kirkland), L'Ambiance Ouest (Dollard-des-Ormeaux),7#   Palacio D'Anjou (Anjou). WordPress rendu serveur : la page8#   /residential-properties-for-rent/ liste les cartes (les propriétés de9#   Floride sont EXCLUES) ; chaque page /property/<slug>/ donne l'adresse,10#   la description, les caractéristiques (ul) et le contact. AUCUN prix ni11#   inventaire par unité publié (le lien securecafe/Yardi n'est que le12#   portail des résidents) → une annonce par immeuble, comme brochu.py.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing22from .base import BaseConnector2324BASE = "https://tramsmgmt.com"25LIST_URL = f"{BASE}/residential-properties-for-rent/"2627QC_RE = re.compile(r",\s*(?:QC|Qu[ée]bec)\b", re.I)28CITY_RE = re.compile(r"([A-Za-zÀ-ÿ'’.\- ]{3,40}),\s*(?:QC|Qu[ée]bec)\b")29IMG_RE = re.compile(r"https://tramsmgmt\.com/wp-content/uploads/"30                    r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)313233class TramsConnector(BaseConnector):34    source_id = "trams"35    request_delay = 0.73637    def fetch(self) -> list[Listing]:38        listings: list[Listing] = []39        try:40            html = self.get(LIST_URL).text41        except Exception:42            return listings43        soup = BeautifulSoup(html, "html.parser")4445        seen: set[str] = set()46        for a in soup.select('a[href*="/property/"]'):47            url = (a.get("href") or "").split("?")[0]48            slug = url.rstrip("/").split("/")[-1]49            if not slug or slug in seen:50                continue51            # carte englobante : ne garder que les immeubles québécois52            card = a53            for _ in range(5):54                if card.parent is None:55                    break56                card = card.parent57                if len(card.select('a[href*="/property/"]')) > 3:58                    card = None59                    break60                text = card.get_text(" ", strip=True)61                if QC_RE.search(text):62                    break63            else:64                continue65            if card is None or not QC_RE.search(66                    card.get_text(" ", strip=True)):67                continue68            seen.add(slug)69            card_key = hashlib.sha1(70                card.get_text(" ", strip=True).encode("utf-8")).hexdigest()71            try:72                d = self.detail(slug, card_key,73                                lambda url=url: self._fetch_detail(url))74            except Exception:75                continue76            if not d.get("title"):77                continue78            city = d.get("city", "")79            listings.append(Listing(80                source=self.source_id,81                external_id=slug,82                url=url,83                title=f"{d['title']} — appartements à louer"84                      f"{' (' + city + ')' if city else ''}",85                address=d.get("address", ""),86                city=city,87                availability="Prix et visites : communiquer avec l'immeuble",88                description=d.get("description", ""),89                amenities=d.get("amenities") or [],90                details={"contact": d["contact"]} if d.get("contact") else {},91                images=d.get("images") or [],92            ))93        return listings9495    def _fetch_detail(self, url: str) -> dict:96        out: dict = {}97        html = self.get(url).text98        soup = BeautifulSoup(html, "html.parser")99        article = soup.find("article") or soup100101        h1 = article.find("h1")102        if h1:103            out["title"] = h1.get_text(" ", strip=True)104105        # bloc adresse sous le titre (rue / ville, QC / code postal)106        addr_div = article.select_one("div.text-center.pb-1")107        if addr_div:108            addr = re.sub(r"\s+", " ",109                          addr_div.get_text(", ", strip=True))110            out["address"] = re.sub(r"\s*,\s*(,\s*)+", ", ", addr).strip(" ,")111            m = CITY_RE.search(out["address"])112            if m:113                out["city"] = m.group(1).strip()114115        # description : paragraphes substantiels ; caractéristiques : 1re liste116        paras = [p.get_text(" ", strip=True) for p in article.find_all("p")117                 if len(p.get_text(strip=True)) > 60118                 and "call" not in p.get_text().lower()[:40]]119        out["description"] = re.sub(r"\s+", " ",120                                    " ".join(paras[:3])).strip()[:1200]121        amenities: list[str] = []122        for li in article.select("ul li"):123            t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).rstrip(".")124            if 0 < len(t) <= 80 and t not in amenities:125                amenities.append(t)126        out["amenities"] = amenities[:15]127128        contact: dict = {}129        m = re.search(r'href="tel:([\d\- ()]{7,})"', html)130        if m:131            contact["phone"] = m.group(1).strip()132        m = re.search(r'href="mailto:([^"?]+)"', html)133        if m:134            contact["email"] = m.group(1).strip().lower()135        out["contact"] = contact136137        out["images"] = [138            u for u in dict.fromkeys(IMG_RE.findall(html))139            if not re.search(r"logo|icon|favicon|cropped-|-\d{2,3}x\d{2,3}\.",140                             u, re.I)][:20]141        return out142