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/medway.py : connecteur Groupe Medway (condosmedway.ca)5# Condos locatifs neufs multi-villes : Rivière-du-Loup, Lévis (Saint-Nicolas,6# Lauzon, Rucher), Québec (boul. Laurier, Charest, Wilfrid-Hamel), Pont-Rouge,7# La Malbaie, Saint-Raphaël. WordPress (thème custom « turbutheme »), tout8# rendu serveur. Répertoire /condos-a-louer/ : cartes projet (nom, adresse9# civique avec ville, nombre d'unités). Chaque page projet /projet/<slug>/10# embarque les unités sous DEUX gabarits selon le projet :11# 1. span JSON `condosParEtageJson` (Rive, Edmond, Nico, Anastasie…) —12# unités par étage : id, prix, type « 3 ½ / 4 ½ bureau », superficie,13# disponibilité oui/non + « Prochainement » avec date, fiche /condo/… ;14# 2. tableau JS inline `units = [[…]]` (sélecteur d'étages : Kali, Wil,15# Alto) — number, state (Available/Reserved/Sold), price, typology16# (« 4etdemiB », « StudioA »), superficies, date, plan.17# On ne retient que les unités disponibles (maintenant ou prochainement ;18# Reserved/Sold exclues) — le prix de chaque unité vient de ces données (les19# fiches /condo/ n'affichent pas le prix). robots.txt WP standard.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as htmllib24import json25import re2627from bs4 import BeautifulSoup2829from ..schema import Listing, normalize_unit_type30from .base import BaseConnector3132BASE = "https://condosmedway.ca"33LIST_URL = f"{BASE}/condos-a-louer/"3435# « 35 rue Saint-Louis, Rivière-du-Loup, QC • 154 unités » -> adresse + ville36_UNITS_SUFFIX = re.compile(r"[••]\s*\d+\s*unités?", re.I)37_JSON_RE = re.compile(38 r'id="condosParEtageJson"[^>]*>\s*(\{.*?\})\s*</span>', re.S)39# gabarit « sélecteur d'étages » : tableau JS inline (une liste par étage)40_UNITS_RE = re.compile(r"units\s*=\s*(\[\[.*?\]\])\s*;?\n", re.S)41_TYPOLOGY_RE = re.compile(r"(\d)\s*etdemi", re.I)424344class MedwayConnector(BaseConnector):45 source_id = "medway"46 request_delay = 0.64748 def fetch(self) -> list[Listing]:49 html = self.get(LIST_URL).text50 soup = BeautifulSoup(html, "html.parser")5152 listings: dict[str, Listing] = {}53 seen_projects: set[str] = set()54 for card in soup.select("div.condo-item"):55 try:56 self._parse_project(card, seen_projects, listings)57 except Exception:58 continue59 return list(listings.values())6061 # -- carte projet (répertoire /condos-a-louer/) ---------------------------------62 def _parse_project(self, card, seen: set[str],63 listings: dict[str, Listing]) -> None:64 link = card.select_one('a[href*="/projet/"]')65 name_el = card.select_one("h2")66 if not link or not name_el:67 return68 m = re.search(r"/projet/([^/?#]+)", link["href"])69 if not m or m.group(1) in seen:70 return71 slug = m.group(1)72 seen.add(slug)73 project = name_el.get_text(strip=True)7475 # adresse de la carte : « 35 rue Saint-Louis, Rivière-du-Loup, QC • 154 unités »76 addr_el = name_el.find_next(77 "div", class_=lambda c: c and "flex-col" in c)78 raw = addr_el.get_text(" ", strip=True) if addr_el else ""79 raw = _UNITS_SUFFIX.sub("", htmllib.unescape(raw)).strip(" ••")80 address = re.sub(r"\s+", " ", raw).strip().rstrip(",")81 # ville = dernier segment non vide une fois la province « QC » retirée82 # (« …, Québec, QC » -> Québec ; « …, La Malbaie QC » -> La Malbaie)83 city = ""84 parts = [p.strip() for p in address.split(",") if p.strip()]85 for p in reversed(parts[1:]):86 cand = re.sub(r"\bQC\s*$", "", p).strip().rstrip(",")87 if cand and not re.match(r"^G\d[A-Z]", cand):88 city = cand89 break9091 # page projet : JSON des unités par étage (?t=r = volet résidentiel)92 page = self.get(f"{BASE}/projet/{slug}/?t=r").text93 page_url = f"{BASE}/projet/{slug}/?t=r"94 mj = _JSON_RE.search(page)95 if mj:96 data = json.loads(mj.group(1))97 for floor in data.values():98 for unit_slug, u in (floor.get("condos") or {}).items():99 if not isinstance(u, dict) or unit_slug in listings:100 continue101 dispo = (u.get("disponibilite") or "").lower() == "oui"102 prochaine = (u.get("disponibilite_prochaine") or "").lower() == "oui"103 if not (dispo or prochaine):104 continue105 self._add_unit(listings, unit_slug, u, project, address, city)106 return107108 # gabarit « sélecteur d'étages » (Kali, Wil, Alto) : units = [[…]]109 mu = _UNITS_RE.search(page)110 if not mu:111 return # page marketing sans sélecteur (ex. 3000 Laurier) : rien112 for floor in json.loads(mu.group(1)):113 for u in floor:114 if not isinstance(u, dict) or u.get("state") != "Available":115 continue # Reserved / Sold : non disponibles116 self._add_selector_unit(listings, u, slug, page_url,117 project, address, city)118119 # -- unité (entrée du JSON condosParEtageJson) -----------------------------------120 def _add_unit(self, listings: dict[str, Listing], unit_slug: str, u: dict,121 project: str, address: str, city: str) -> None:122 title = (u.get("nom-unite") or "").strip() or unit_slug123 url = (u.get("single_projet_url") or "").replace("\\/", "/") \124 or f"{BASE}/condo/{unit_slug}/"125126 # disponibilité brute : « Maintenant » / « Prochainement » (+ date libre)127 availability = (u.get("disponibilite_libelle") or "").strip()128 date_txt = (u.get("disponibilite_date") or "").strip()129 if date_txt:130 availability = f"{availability} — {date_txt}".strip(" —")131132 price = None133 raw_price = str(u.get("prix") or "").strip()134 if re.fullmatch(r"\d+(?:[.,]\d+)?", raw_price):135 price = float(raw_price.replace(",", "."))136137 area = None138 raw_area = str(u.get("superficie_pieds") or "").strip()139 if re.fullmatch(r"\d+(?:[.,]\d+)?", raw_area):140 area = float(raw_area.replace(",", "."))141142 images: list[str] = []143 plan = (u.get("plan_url") or "").replace("\\/", "/")144 if plan.startswith("http"):145 images.append(plan)146147 amenities: list[str] = []148 finition = (u.get("finition") or "").strip()149 if finition and not finition.lower().startswith("non-attribu"):150 amenities.append(f"Finition : {finition}")151152 listings[unit_slug] = Listing(153 source=self.source_id,154 external_id=unit_slug,155 url=url,156 title=title,157 address=address,158 sector=project,159 city=city,160 unit_type=normalize_unit_type((u.get("type") or "").strip()),161 price=price,162 price_label=f"{raw_price} $" if price is not None else "",163 availability=availability,164 area_sqft=area,165 amenities=amenities,166 images=images,167 )168169 # -- unité (gabarit sélecteur d'étages : Kali, Wil, Alto) ---------------------------170 def _add_selector_unit(self, listings: dict[str, Listing], u: dict,171 project_slug: str, page_url: str, project: str,172 address: str, city: str) -> None:173 uid = str(u.get("id") or "").strip()174 number = (u.get("number") or "").strip() # « Unité 410 »175 if not uid:176 return177 ext_id = f"{project_slug}-{uid}" # id BD WordPress : stable178 if ext_id in listings:179 return180181 # type structuré : typology « 4etdemiB » / « StudioA » -> 4½ / Studio182 typology = (u.get("typology") or "").strip()183 unit_type = ""184 m = _TYPOLOGY_RE.search(typology)185 if m:186 unit_type = normalize_unit_type(f"{m.group(1)} ½")187 elif typology.lower().startswith("studio"):188 unit_type = "Studio"189190 # disponibilité affichée : légende « Disponible » (+ date si annoncée)191 availability = "Disponible"192 date_txt = (u.get("available_date") or "").strip()193 if date_txt:194 availability += f" — {date_txt}"195196 price = None197 raw = u.get("price")198 if isinstance(raw, (int, float)) and raw > 0:199 price = float(raw)200201 area = None202 ia = u.get("indoor_area")203 if isinstance(ia, (int, float)) and ia > 0:204 area = float(ia)205206 amenities: list[str] = []207 ba = u.get("balcony_area")208 if isinstance(ba, (int, float)) and ba > 0:209 amenities.append(f"Balcon {ba:g} pi²")210211 images: list[str] = []212 plan = (u.get("plan_img_src") or "").replace("\\/", "/")213 if plan.startswith("http"):214 images.append(plan)215216 listings[ext_id] = Listing(217 source=self.source_id,218 external_id=ext_id,219 url=page_url, # pas de fiche par unité sur ce gabarit220 title=f"{project} — {number}".strip(" —") or ext_id,221 address=address,222 sector=project,223 city=city,224 unit_type=unit_type,225 price=price,226 price_label=f"{price:g} $" if price is not None else "",227 availability=availability,228 area_sqft=area,229 amenities=amenities,230 images=images,231 )232