SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
8.9 KB · 212 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/minto.py : connecteur Minto Apartments (mintoapartments.com)5#   Page « projects » Montréal rendue serveur : Rockhill, Haddon Hall, Le 4300,6#   Le Hill-Park. Chaque fiche propriété (main.html) liste ses types de suites7#   dans des cartes .projects-apartamets-unit-card : nom (h4), disponibilité8#   (« Available now » / « Available September 17 » / « Not available »),9#   prix ($X - $Y), pi² (span icon-svg-column), sdb, galerie photo par type10#   (tableaux JS `lightboxImages…`). La section Contact donne l'adresse11#   complète, le quartier (« Neighbourhood: ») et le téléphone/courriel ;12#   les sections Features listent les commodités (items à coche).13#   Une annonce par type de suite disponible.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price, strip_accents22from .base import BaseConnector2324BASE = "https://www.mintoapartments.com"25LIST_URL = f"{BASE}/montreal/apartment-rentals/projects.html"2627PROJECT_RE = re.compile(28    r'https?://www\.mintoapartments\.com/montreal/apartment-rentals/'29    r'([A-Za-z0-9\-]+)/main\.html')30LIGHTBOX_RE = re.compile(r'var\s+lightboxImages(\d+)\s*=\s*\[(.*?)\];', re.S)31IMG_SRC_RE = re.compile(r"src:\s*'([^']+)'")32_PHONE_RE = re.compile(r"tel:([\d\-() .]{7,20})")33_EMAIL_RE = re.compile(r"mailto:([\w.+-]+@[\w-]+\.[\w.]+)")3435# Secteurs connus recherchés dans le <title> (repli si pas de36# « Neighbourhood: ») ; certains sont des villes distinctes de l'île.37_KNOWN_SECTORS = [38    "Côte-des-Neiges", "Westmount", "Côte-Saint-Luc", "Mont-Royal",39    "Notre-Dame-de-Grâce", "NDG", "Griffintown", "Plateau", "Ville-Marie",40    "Downtown",41]42_CITY_SECTORS = {"westmount": "Westmount", "mont-royal": "Mont-Royal",43                 "cote-saint-luc": "Côte-Saint-Luc"}444546class MintoConnector(BaseConnector):47    source_id = "minto"48    request_delay = 0.649    max_projects = 12        # garde-fou50    max_images = 255152    def fetch(self) -> list[Listing]:53        html = self.get(LIST_URL).text54        slugs = list(dict.fromkeys(PROJECT_RE.findall(html)))5556        listings: list[Listing] = []57        for slug in slugs[: self.max_projects]:58            try:59                listings.extend(self._project_listings(slug))60            except Exception:61                continue62        return listings6364    def _project_listings(self, slug: str) -> list[Listing]:65        url = f"{BASE}/montreal/apartment-rentals/{slug}/main.html"66        html = self.get(url).text67        soup = BeautifulSoup(html, "html.parser")6869        name = slug.replace("-", " ").strip()70        h1 = soup.find("h1")71        if h1 and h1.get_text(strip=True):72            name = h1.get_text(" ", strip=True)73        t = soup.find("title")74        title_text = t.get_text(strip=True) if t else ""7576        # Quartier : « Neighbourhood: Shaughnessy Village » (section Contact),77        # sinon premier quartier connu mentionné dans le <title>78        sector = ""79        nm = re.search(r"Neighbourhood:\s*([^<\n]{2,50})", html)80        if nm:81            sector = nm.group(1).strip()82        else:83            for s in _KNOWN_SECTORS:84                if s.lower() in title_text.lower():85                    sector = "Centre-ville" if s == "Downtown" else s86                    break87        city = _CITY_SECTORS.get(strip_accents(sector.lower()), "Montréal")88        if sector == city:89            sector = ""9091        # Adresse complète (section Contact : rue + ville + code postal)92        address = ""93        am = re.search(94            r'font-weight:\s*normal">\s*([^<]{8,140})</p>', html)95        if am:96            address = re.sub(r"\s*\n\s*", ", ", am.group(1).strip())97            address = re.sub(r"\s+", " ", address)98        if not address:99            am = re.search(100                r'\d{2,5},?\s+(?:chemin|chem\.|avenue|rue|boulevard|c[ôo]te)'101                r'[^<>"{}]{3,60}', html, re.I)102            if am:103                address = re.sub(r"\s+", " ", am.group(0)).strip().rstrip(",")104105        # Contact location (téléphone / courriel) -> details.contact106        details: dict = {}107        contact: dict = {}108        pm = _PHONE_RE.search(html)109        if pm:110            contact["phone"] = pm.group(1).strip()111        em = _EMAIL_RE.search(html)112        if em:113            contact["email"] = em.group(1)114        if contact:115            details["contact"] = contact116117        # Photos de la propriété (carrousel d'entête, repli des galeries)118        hero = [u for u in re.findall(119            r'https://media\.minto\.com/(?:dev/)?slideshows/[^"\'\s]+'120            r'\.(?:jpg|jpeg|png|webp)', html)]121        hero = list(dict.fromkeys(hero))[:8]122123        # Commodités : items à coche des sections « Building features » /124        # « Suite features » (l'astérisque « * » = dans certaines suites)125        amenities: list[str] = []126        for span in soup.select('li span[class*="icon-svg-check-mark"]'):127            li = span.find_parent("li")128            txt = li.get_text(" ", strip=True) if li else ""129            if txt and txt not in amenities and len(txt) < 60:130                amenities.append(txt)131        amenities = amenities[:30]132133        og = soup.find("meta", attrs={"name": "description"})134        desc = (og.get("content", "").strip()[:600] if og else "")135136        listings: list[Listing] = []137        for card in soup.select(".projects-apartamets-unit-card"):138            try:139                h4 = card.select_one("h4.h-h3-minto")140                if not h4:141                    continue142                suite_name = h4.get_text(" ", strip=True)143                if not suite_name or len(suite_name) > 70:144                    continue145146                # Disponibilité par type de suite (« Available now »,147                # « Available September 17 », « Not available »)148                av_el = card.select_one('[class*="btn-availa"]')149                availability = (av_el.get_text(" ", strip=True)150                                if av_el else "")151                if re.match(r"^not\s+available$", availability, re.I):152                    continue    # type de suite non offert actuellement153154                # Prix « $1,215 - $1,305 »155                ph5 = card.select_one("h5.h-h3-minto")156                price_txt = ph5.get_text(" ", strip=True) if ph5 else ""157                pmatch = re.search(r'\$[\d,]+(?:\s*-\s*\$[\d,]+)?', price_txt)158                if not pmatch:159                    continue    # carte non tarifée160                price_label = re.sub(r"\s+", " ", pmatch.group(0))161                price = parse_price(162                    price_label.split("-")[0].replace("$", "")163                    .replace(",", "") + " $")164165                # Superficie structurée « 425 - 560 » SQ FT (min de la plage)166                area_sqft = None167                sq_icon = card.select_one("span.icon-svg-column")168                if sq_icon:169                    sib = sq_icon.find_next_sibling("span")170                    if sib:171                        nums = [float(n.replace(",", "")) for n in172                                re.findall(r"[\d,]+", sib.get_text())]173                        nums = [n for n in nums if 80 <= n <= 20000]174                        if nums:175                            area_sqft = min(nums)176177                bm = re.search(r'([\d.]+)\s*Bathroom',178                               card.get_text(" ", strip=True))179180                # Galerie du type de suite (tableau lightbox de la carte)181                imgs: list[str] = []182                gm = LIGHTBOX_RE.search(str(card))183                if gm:184                    imgs = IMG_SRC_RE.findall(gm.group(2))185                imgs = list(dict.fromkeys(imgs))[: self.max_images] or hero186187                suite_slug = re.sub(r"[^a-z0-9]+", "-",188                                    strip_accents(suite_name.lower())).strip("-")189                bits = [f"{bm.group(1)} sdb" if bm else ""]190                listings.append(Listing(191                    source=self.source_id,192                    external_id=f"{slug}-{suite_slug}",193                    url=url,194                    title=f"{name}{suite_name}",195                    address=address,196                    sector=sector,197                    city=city,198                    unit_type=normalize_unit_type(suite_name),199                    price=price,200                    price_label=f"{price_label} /mois",201                    availability=availability,202                    area_sqft=area_sqft,203                    description=" — ".join(204                        x for x in [desc] + bits if x)[:600],205                    amenities=amenities,206                    details=dict(details),207                    images=imgs,208                ))209            except Exception:210                continue211        return listings212