Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/cinq_etoiles.py : connecteur Gestion Cinq Étoiles5# (appartementslocatifs.ca) — 5 immeubles locatifs neufs à Blainville :6# L'Émeraude (842 Curé-Labelle), Le Cassiopée (832 Curé-Labelle), L'Azur7# (33 Gaëtan-Mailhiot), Le Célesta (11 Gaëtan-Mailhiot), L'Élysée8# (40 Gaëtan-Mailhiot). Site GoDaddy Website Builder rendu serveur : la9# page d'accueil liste les immeubles (nav), chaque page immeuble affiche10# « <NOM> à partir de N$/mois », l'adresse civique, la ligne des11# typologies (« Logements 3 1/2 et 3 1/2 + den et 4 1/2 ») et les12# inclusions. Photos sur le CDN img1.wsimg.com (variantes /:/rs=…13# dédupliquées). Granularité TYPOLOGIE par immeuble ; le prix « à partir14# de » n'est associé qu'à la plus petite typologie (jamais inventé).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing23from .base import BaseConnector2425BASE = "https://appartementslocatifs.ca"26CITY = "Blainville"2728# « L'AZUR, à partir de 1575$ » / « LE CASSIOPÉE À PARTIR DE 1510$/ MOIS »29NAME_PRICE_RE = re.compile(30 r"^(.{2,40}?),?\s*à partir de\s*([\d ]{3,6})\s*\$", re.I)31# ligne composée uniquement de typologies : « Logements 3 1/2 + den et 4 1/2 »32TYPES_LINE_RE = re.compile(33 r"^(?:Logements?\s+)?((?:\d\s*(?:1/2|½)(?:\s*\+\s*den)?)"34 r"(?:\s*(?:et|,)\s*\d\s*(?:1/2|½)(?:\s*\+\s*den)?)*)\s*$", re.I)35TYPE_TOKEN_RE = re.compile(r"(\d)\s*(?:1/2|½)(\s*\+\s*den)?", re.I)36# « 33 Gaëtan-Mailhiot, Blainville, QC , J7C 0W9 » / « Situé au 842, boul. … »37ADDR_RE = re.compile(38 r"^(?:Situé au\s+)?(\d{1,5}[^\n]{3,60}?),?\s*Blainville\b", re.I)39IMG_RE = re.compile(40 r"(?:https?:)?//img1\.wsimg\.com/isteam/ip/[^\"'\s\\,)]+?\.(?:jpe?g|png|webp)",41 re.I)4243# l'adresse de L'Élysée n'apparaît que sur la page d'accueil44FALLBACK_ADDR = {"l%C3%A9lys%C3%A9e": "40 Gaëtan-Mailhiot"}45EXCLUDED_PATHS = {"/", "/service"}464748class CinqEtoilesConnector(BaseConnector):49 source_id = "cinq_etoiles"50 request_delay = 0.85152 def fetch(self) -> list[Listing]:53 listings: list[Listing] = []54 try:55 home = self.get(BASE + "/").text56 except Exception:57 return listings58 soup = BeautifulSoup(home, "html.parser")59 paths = []60 for a in soup.select("a[href]"):61 href = a.get("href") or ""62 if (href.startswith("/") and href not in EXCLUDED_PATHS63 and href not in paths and "#" not in href):64 paths.append(href)65 for path in paths:66 try:67 listings.extend(self._building(path))68 except Exception:69 continue # une page cassée ne bloque pas70 return listings7172 # -- page immeuble : nom, prix « à partir de », adresse, typologies --------73 def _building(self, path: str) -> list[Listing]:74 url = BASE + path75 html = self.get(url).text76 soup = BeautifulSoup(html, "html.parser")77 for tag in soup.select("title, script, style"):78 tag.decompose() # le <title> contient des typologies79 lines = [ln for ln in soup.get_text("\n", strip=True).split("\n") if ln]8081 name, price, price_label = "", None, ""82 address = FALLBACK_ADDR.get(path.lstrip("/"), "")83 types: list[str] = []84 amenities: list[str] = []85 for i, line in enumerate(lines):86 m = NAME_PRICE_RE.match(line)87 if m and not name:88 name = m.group(1).strip(" ,–-").title()89 price_label = line.strip()90 try:91 price = float(re.sub(r"[ ]", "", m.group(2)))92 except ValueError:93 price = None94 m = TYPES_LINE_RE.match(line)95 if m and not types:96 types = [f"{t[0]}½" + (" + DEN" if t[1] else "")97 for t in TYPE_TOKEN_RE.findall(m.group(1))]98 m = ADDR_RE.match(line)99 if m and not FALLBACK_ADDR.get(path.lstrip("/")):100 address = m.group(1).strip(" ,")101 if line.lower().startswith("inclusions"):102 rest = line.split(":", 1)[-1].strip() \103 if ":" in line else line[len("Inclusions"):].strip()104 if len(rest) > 3:105 amenities.append(rest)106 for nxt in lines[i + 1:i + 4]:107 if re.match(r"Copyright|Powered|This website", nxt, re.I):108 break109 if len(nxt) > 3:110 amenities.append(nxt)111 if not name or not types:112 return []113114 # photos : dédupliquées sur le chemin de base (avant les variantes /:/)115 images: list[str] = []116 for u in IMG_RE.findall(html):117 full = ("https:" + u) if u.startswith("//") else u118 if full not in images and not re.search(r"logo|favicon", full, re.I):119 images.append(full)120121 # une annonce par typologie ; « à partir de » = plus petite typologie122 out: list[Listing] = []123 for i, ut in enumerate(types):124 first = (i == 0)125 slug = re.sub(r"[^a-z0-9]+", "-",126 name.lower().replace("é", "e").replace("è", "e")127 .replace("'", "")).strip("-")128 out.append(Listing(129 source=self.source_id,130 external_id=f"{slug}-{ut.replace('½', '.5').replace(' + DEN', 'den').replace(' ', '')}",131 url=url,132 title=f"{name} — {ut}",133 address=address,134 city=CITY,135 unit_type=ut,136 price=price if first else None,137 price_label=(price_label if first else ""),138 availability="Disponible",139 amenities=list(amenities),140 details=({"price_from": True, "building": name}141 if first and price else {"building": name}),142 images=images[:15],143 ))144 return out145