# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/morin.py : connecteur Constructions Morin (constructionsmorin.com) # Constructeur-locateur : apparts neufs tout inclus à Sherbrooke # (St-Élie/Rock-Forest, Fleurimont), Ascot Corner, East Angus, Windsor et # Lac-Mégantic. WordPress + Avada : la page /appartements-a-louer/ est # rendue serveur — un bloc .appartement_list_block par TYPE d'unité # disponible dans un immeuble (adresse h3, ville/secteur, projet, badge de # type « 4 ½ », « N disponibles », « À partir de … $ », date « Disponible # dès le … », chambres/sdb/stationnement). Granularité = type d'unité par # immeuble (les unités individuelles de la fiche n'ont ni prix ni dispo # propres). Les fiches (via self.detail, cache BD) ajoutent la description # (inclusions) et la galerie. external_id = slug projet/unité (stable). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://constructionsmorin.com" LIST_URL = f"{BASE}/appartements-a-louer/" _VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) _KNOWN_CITIES = ["Sherbrooke", "Ascot Corner", "East Angus", "Windsor", "Lac-Mégantic"] class MorinConnector(BaseConnector): source_id = "morin" request_delay = 0.6 max_details = 25 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for block in soup.select(".appartement_list_block"): try: lst = self._parse_block(block) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # fiches (cache BD) : description (inclusions) + galerie du projet self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue if payload.get("description"): lst.description = payload["description"] if payload.get("images"): lst.images = list(dict.fromkeys(payload["images"] + lst.images))[:20] if payload.get("area_label") and lst.area_sqft is None: # « Superficie: 860-1140 pi² » -> borne basse (plage réelle # conservée en commodité) m = re.match(r"([\d\s]+)", payload["area_label"]) if m: try: val = float(m.group(1).replace(" ", "")) if 80 <= val <= 20000: lst.area_sqft = val except ValueError: pass lst.amenities = list(dict.fromkeys( lst.amenities + [f"Superficie : {payload['area_label']}"])) return list(listings.values()) # -- un bloc = un type d'unité disponible dans un immeuble ------------------------ def _parse_block(self, block) -> Listing | None: link = block.select_one("a[href*='/appartements-a-louer/']") if not link: return None url = link["href"] m = re.search(r"/appartements-a-louer/([^?#]+?)/?$", url) if not m: return None slug = m.group(1).strip("/") # « horizon/5565-…-4-1-2 » h3 = block.select_one("h3") address = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip() if h3 else "" # ville + secteur : « Ascot Corner », « Sherbrooke Secteur St-Élie/… » sec_el = block.select_one(".appartement_list_information_secteur") sec_txt = re.sub(r"\s+", " ", sec_el.get_text(" ", strip=True)).strip() if sec_el else "" city, sector = "", "" for c in _KNOWN_CITIES: if sec_txt.lower().startswith(c.lower()): city = c sector = re.sub(r"^Secteur\s+", "", sec_txt[len(c):].strip()) break if not city: city = sec_txt project_el = sec_el.find_next_sibling("div") if sec_el else None project = re.sub(r"\s+", " ", project_el.get_text(" ", strip=True)).strip() \ if project_el else "" # type d'unité : badge « 4 ½ » badge = block.select_one(".appartement_list_badge_libre") unit_type = normalize_unit_type( badge.get_text(" ", strip=True) if badge else "") if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", unit_type or ""): unit_type = "" # « Disponible dès le 1er février 2027! » (bandeau de la carte) tag = block.select_one(".appartement_list_tag") availability = re.sub(r"\s+", " ", tag.get_text(" ", strip=True)).strip() if tag else "" # « À partir de | 1395$ » prix_el = block.select_one(".appartement_list_information_prix_montant") price_label = "" price = None if prix_el: amount = prix_el.get_text(" ", strip=True) price_label = f"À partir de {amount}" price = parse_price(re.sub(r"(\d)\s(\d{3})", r"\1\2", amount)) # commodités structurées de la carte : dispo/nb unités, chambres, sdb, # stationnement, superficie amenities: list[str] = [] libre = block.select_one(".appartement_list_libre_txt") if libre: amenities.append(re.sub(r"\s+", " ", libre.get_text(" ", strip=True)).strip()) nb = block.select_one(".appartement_list_nb_appartement") if nb: amenities.append(re.sub(r"\s+", " ", nb.get_text(" ", strip=True)).strip()) for unit in block.select(".appartement_list_info_block > div"): val = unit.select_one(".appartement_list_info_block_unit span") lab = unit.select_one(".appartement_list_info_block_unit_title") if val and lab: amenities.append(f"{val.get_text(strip=True)} " f"{lab.get_text(strip=True)}") area = None m2 = re.search(r"Superficie\s*:\s*([\d\s]+)(?:-|–|à)?([\d\s]*)pi", block.get_text(" ", strip=True)) if m2: try: area = float(m2.group(1).replace(" ", "")) except ValueError: pass # visuel de la carte (background-image du bloc) images: list[str] = [] bg = block.select_one(".appartement_list_image_bg") if bg: mi = re.search(r"url\('([^']+)'\)", bg.get("style") or "") if mi and mi.group(1).startswith("http"): images.append(_VARIANT_IMG.sub("", mi.group(1))) title = f"{unit_type} — {address}" if unit_type else address if project: title += f" ({project})" return Listing( source=self.source_id, external_id=slug, url=url, title=title, address=address, sector=sector, city=city, unit_type=unit_type, price=price, price_label=price_label, availability=availability, area_sqft=area, amenities=amenities, details={"project": project} if project else {}, images=images, ) # -- fiche type d'unité ------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # « Votre appartement luxueux comprend : » + liste d'inclusions head = soup.find(string=re.compile(r"appartement.*comprend", re.I)) if head: ul = head.parent.find_next("ul") if ul: items = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip(" ;") for li in ul.select("li")] out["description"] = "Votre appartement comprend : " + \ " ; ".join(t for t in items if t)[:1400] # « Superficie: 860-1140 pi² » (fiche du type d'unité) ma = re.search(r"Superficie\s*:\s*([\d\s]+(?:[-–à]\s*[\d\s]+)?)\s*pi", soup.get_text(" ", strip=True)) if ma: out["area_label"] = re.sub(r"\s+", " ", ma.group(1)).strip() + " pi²" images: list[str] = [] for img in soup.select("img[src*='/wp-content/uploads/']"): src = _VARIANT_IMG.sub("", str(img.get("src") or "")) if src.startswith("http") and src not in images \ and not re.search(r"logo|icon|favicon|Projet-", src): images.append(src) out["images"] = images[:15] return out