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/morin.py : connecteur Constructions Morin (constructionsmorin.com)5# Constructeur-locateur : apparts neufs tout inclus à Sherbrooke6# (St-Élie/Rock-Forest, Fleurimont), Ascot Corner, East Angus, Windsor et7# Lac-Mégantic. WordPress + Avada : la page /appartements-a-louer/ est8# rendue serveur — un bloc .appartement_list_block par TYPE d'unité9# disponible dans un immeuble (adresse h3, ville/secteur, projet, badge de10# type « 4 ½ », « N disponibles », « À partir de … $ », date « Disponible11# dès le … », chambres/sdb/stationnement). Granularité = type d'unité par12# immeuble (les unités individuelles de la fiche n'ont ni prix ni dispo13# propres). Les fiches (via self.detail, cache BD) ajoutent la description14# (inclusions) et la galerie. external_id = slug projet/unité (stable).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://constructionsmorin.com"27LIST_URL = f"{BASE}/appartements-a-louer/"2829_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)30_KNOWN_CITIES = ["Sherbrooke", "Ascot Corner", "East Angus", "Windsor",31 "Lac-Mégantic"]323334class MorinConnector(BaseConnector):35 source_id = "morin"36 request_delay = 0.637 max_details = 25 # garde-fou fiches détail (vraies requêtes)3839 def fetch(self) -> list[Listing]:40 html = self.get(LIST_URL).text41 soup = BeautifulSoup(html, "html.parser")4243 listings: dict[str, Listing] = {}44 for block in soup.select(".appartement_list_block"):45 try:46 lst = self._parse_block(block)47 except Exception:48 continue49 if lst and lst.external_id not in listings:50 listings[lst.external_id] = lst5152 # fiches (cache BD) : description (inclusions) + galerie du projet53 self._fetched = 054 for lst in listings.values():55 key = hashlib.sha1(56 f"{lst.title}|{lst.price_label}|{lst.availability}"57 .encode("utf-8")).hexdigest()58 try:59 payload = self.detail(lst.external_id, key,60 lambda u=lst.url: self._fetch_detail(u))61 except Exception:62 continue63 if payload.get("description"):64 lst.description = payload["description"]65 if payload.get("images"):66 lst.images = list(dict.fromkeys(payload["images"] + lst.images))[:20]67 if payload.get("area_label") and lst.area_sqft is None:68 # « Superficie: 860-1140 pi² » -> borne basse (plage réelle69 # conservée en commodité)70 m = re.match(r"([\d\s]+)", payload["area_label"])71 if m:72 try:73 val = float(m.group(1).replace(" ", ""))74 if 80 <= val <= 20000:75 lst.area_sqft = val76 except ValueError:77 pass78 lst.amenities = list(dict.fromkeys(79 lst.amenities + [f"Superficie : {payload['area_label']}"]))80 return list(listings.values())8182 # -- un bloc = un type d'unité disponible dans un immeuble ------------------------83 def _parse_block(self, block) -> Listing | None:84 link = block.select_one("a[href*='/appartements-a-louer/']")85 if not link:86 return None87 url = link["href"]88 m = re.search(r"/appartements-a-louer/([^?#]+?)/?$", url)89 if not m:90 return None91 slug = m.group(1).strip("/") # « horizon/5565-…-4-1-2 »9293 h3 = block.select_one("h3")94 address = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip() if h3 else ""9596 # ville + secteur : « Ascot Corner », « Sherbrooke Secteur St-Élie/… »97 sec_el = block.select_one(".appartement_list_information_secteur")98 sec_txt = re.sub(r"\s+", " ",99 sec_el.get_text(" ", strip=True)).strip() if sec_el else ""100 city, sector = "", ""101 for c in _KNOWN_CITIES:102 if sec_txt.lower().startswith(c.lower()):103 city = c104 sector = re.sub(r"^Secteur\s+", "", sec_txt[len(c):].strip())105 break106 if not city:107 city = sec_txt108109 project_el = sec_el.find_next_sibling("div") if sec_el else None110 project = re.sub(r"\s+", " ", project_el.get_text(" ", strip=True)).strip() \111 if project_el else ""112113 # type d'unité : badge « 4 ½ »114 badge = block.select_one(".appartement_list_badge_libre")115 unit_type = normalize_unit_type(116 badge.get_text(" ", strip=True) if badge else "")117 if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison",118 unit_type or ""):119 unit_type = ""120121 # « Disponible dès le 1er février 2027! » (bandeau de la carte)122 tag = block.select_one(".appartement_list_tag")123 availability = re.sub(r"\s+", " ",124 tag.get_text(" ", strip=True)).strip() if tag else ""125126 # « À partir de | 1395$ »127 prix_el = block.select_one(".appartement_list_information_prix_montant")128 price_label = ""129 price = None130 if prix_el:131 amount = prix_el.get_text(" ", strip=True)132 price_label = f"À partir de {amount}"133 price = parse_price(re.sub(r"(\d)\s(\d{3})", r"\1\2", amount))134135 # commodités structurées de la carte : dispo/nb unités, chambres, sdb,136 # stationnement, superficie137 amenities: list[str] = []138 libre = block.select_one(".appartement_list_libre_txt")139 if libre:140 amenities.append(re.sub(r"\s+", " ",141 libre.get_text(" ", strip=True)).strip())142 nb = block.select_one(".appartement_list_nb_appartement")143 if nb:144 amenities.append(re.sub(r"\s+", " ",145 nb.get_text(" ", strip=True)).strip())146 for unit in block.select(".appartement_list_info_block > div"):147 val = unit.select_one(".appartement_list_info_block_unit span")148 lab = unit.select_one(".appartement_list_info_block_unit_title")149 if val and lab:150 amenities.append(f"{val.get_text(strip=True)} "151 f"{lab.get_text(strip=True)}")152 area = None153 m2 = re.search(r"Superficie\s*:\s*([\d\s]+)(?:-|–|à)?([\d\s]*)pi",154 block.get_text(" ", strip=True))155 if m2:156 try:157 area = float(m2.group(1).replace(" ", ""))158 except ValueError:159 pass160161 # visuel de la carte (background-image du bloc)162 images: list[str] = []163 bg = block.select_one(".appartement_list_image_bg")164 if bg:165 mi = re.search(r"url\('([^']+)'\)", bg.get("style") or "")166 if mi and mi.group(1).startswith("http"):167 images.append(_VARIANT_IMG.sub("", mi.group(1)))168169 title = f"{unit_type} — {address}" if unit_type else address170 if project:171 title += f" ({project})"172173 return Listing(174 source=self.source_id,175 external_id=slug,176 url=url,177 title=title,178 address=address,179 sector=sector,180 city=city,181 unit_type=unit_type,182 price=price,183 price_label=price_label,184 availability=availability,185 area_sqft=area,186 amenities=amenities,187 details={"project": project} if project else {},188 images=images,189 )190191 # -- fiche type d'unité -------------------------------------------------------192 def _fetch_detail(self, url: str) -> dict:193 if self._fetched >= self.max_details:194 raise RuntimeError("budget de fiches détail atteint")195 self._fetched += 1196 html = self.get(url).text197 soup = BeautifulSoup(html, "html.parser")198 out: dict = {}199200 # « Votre appartement luxueux comprend : » + liste d'inclusions201 head = soup.find(string=re.compile(r"appartement.*comprend", re.I))202 if head:203 ul = head.parent.find_next("ul")204 if ul:205 items = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip(" ;")206 for li in ul.select("li")]207 out["description"] = "Votre appartement comprend : " + \208 " ; ".join(t for t in items if t)[:1400]209210 # « Superficie: 860-1140 pi² » (fiche du type d'unité)211 ma = re.search(r"Superficie\s*:\s*([\d\s]+(?:[-–à]\s*[\d\s]+)?)\s*pi",212 soup.get_text(" ", strip=True))213 if ma:214 out["area_label"] = re.sub(r"\s+", " ", ma.group(1)).strip() + " pi²"215216 images: list[str] = []217 for img in soup.select("img[src*='/wp-content/uploads/']"):218 src = _VARIANT_IMG.sub("", str(img.get("src") or ""))219 if src.startswith("http") and src not in images \220 and not re.search(r"logo|icon|favicon|Projet-", src):221 images.append(src)222 out["images"] = images[:15]223 return out224