spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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