# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/medway.py : connecteur Groupe Medway (condosmedway.ca) # Condos locatifs neufs multi-villes : Rivière-du-Loup, Lévis (Saint-Nicolas, # Lauzon, Rucher), Québec (boul. Laurier, Charest, Wilfrid-Hamel), Pont-Rouge, # La Malbaie, Saint-Raphaël. WordPress (thème custom « turbutheme »), tout # rendu serveur. Répertoire /condos-a-louer/ : cartes projet (nom, adresse # civique avec ville, nombre d'unités). Chaque page projet /projet// # embarque les unités sous DEUX gabarits selon le projet : # 1. span JSON `condosParEtageJson` (Rive, Edmond, Nico, Anastasie…) — # unités par étage : id, prix, type « 3 ½ / 4 ½ bureau », superficie, # disponibilité oui/non + « Prochainement » avec date, fiche /condo/… ; # 2. tableau JS inline `units = [[…]]` (sélecteur d'étages : Kali, Wil, # Alto) — number, state (Available/Reserved/Sold), price, typology # (« 4etdemiB », « StudioA »), superficies, date, plan. # On ne retient que les unités disponibles (maintenant ou prochainement ; # Reserved/Sold exclues) — le prix de chaque unité vient de ces données (les # fiches /condo/ n'affichent pas le prix). robots.txt WP standard. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import json import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://condosmedway.ca" LIST_URL = f"{BASE}/condos-a-louer/" # « 35 rue Saint-Louis, Rivière-du-Loup, QC • 154 unités » -> adresse + ville _UNITS_SUFFIX = re.compile(r"[••]\s*\d+\s*unités?", re.I) _JSON_RE = re.compile( r'id="condosParEtageJson"[^>]*>\s*(\{.*?\})\s*', re.S) # gabarit « sélecteur d'étages » : tableau JS inline (une liste par étage) _UNITS_RE = re.compile(r"units\s*=\s*(\[\[.*?\]\])\s*;?\n", re.S) _TYPOLOGY_RE = re.compile(r"(\d)\s*etdemi", re.I) class MedwayConnector(BaseConnector): source_id = "medway" request_delay = 0.6 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} seen_projects: set[str] = set() for card in soup.select("div.condo-item"): try: self._parse_project(card, seen_projects, listings) except Exception: continue return list(listings.values()) # -- carte projet (répertoire /condos-a-louer/) --------------------------------- def _parse_project(self, card, seen: set[str], listings: dict[str, Listing]) -> None: link = card.select_one('a[href*="/projet/"]') name_el = card.select_one("h2") if not link or not name_el: return m = re.search(r"/projet/([^/?#]+)", link["href"]) if not m or m.group(1) in seen: return slug = m.group(1) seen.add(slug) project = name_el.get_text(strip=True) # adresse de la carte : « 35 rue Saint-Louis, Rivière-du-Loup, QC • 154 unités » addr_el = name_el.find_next( "div", class_=lambda c: c and "flex-col" in c) raw = addr_el.get_text(" ", strip=True) if addr_el else "" raw = _UNITS_SUFFIX.sub("", htmllib.unescape(raw)).strip(" ••") address = re.sub(r"\s+", " ", raw).strip().rstrip(",") # ville = dernier segment non vide une fois la province « QC » retirée # (« …, Québec, QC » -> Québec ; « …, La Malbaie QC » -> La Malbaie) city = "" parts = [p.strip() for p in address.split(",") if p.strip()] for p in reversed(parts[1:]): cand = re.sub(r"\bQC\s*$", "", p).strip().rstrip(",") if cand and not re.match(r"^G\d[A-Z]", cand): city = cand break # page projet : JSON des unités par étage (?t=r = volet résidentiel) page = self.get(f"{BASE}/projet/{slug}/?t=r").text page_url = f"{BASE}/projet/{slug}/?t=r" mj = _JSON_RE.search(page) if mj: data = json.loads(mj.group(1)) for floor in data.values(): for unit_slug, u in (floor.get("condos") or {}).items(): if not isinstance(u, dict) or unit_slug in listings: continue dispo = (u.get("disponibilite") or "").lower() == "oui" prochaine = (u.get("disponibilite_prochaine") or "").lower() == "oui" if not (dispo or prochaine): continue self._add_unit(listings, unit_slug, u, project, address, city) return # gabarit « sélecteur d'étages » (Kali, Wil, Alto) : units = [[…]] mu = _UNITS_RE.search(page) if not mu: return # page marketing sans sélecteur (ex. 3000 Laurier) : rien for floor in json.loads(mu.group(1)): for u in floor: if not isinstance(u, dict) or u.get("state") != "Available": continue # Reserved / Sold : non disponibles self._add_selector_unit(listings, u, slug, page_url, project, address, city) # -- unité (entrée du JSON condosParEtageJson) ----------------------------------- def _add_unit(self, listings: dict[str, Listing], unit_slug: str, u: dict, project: str, address: str, city: str) -> None: title = (u.get("nom-unite") or "").strip() or unit_slug url = (u.get("single_projet_url") or "").replace("\\/", "/") \ or f"{BASE}/condo/{unit_slug}/" # disponibilité brute : « Maintenant » / « Prochainement » (+ date libre) availability = (u.get("disponibilite_libelle") or "").strip() date_txt = (u.get("disponibilite_date") or "").strip() if date_txt: availability = f"{availability} — {date_txt}".strip(" —") price = None raw_price = str(u.get("prix") or "").strip() if re.fullmatch(r"\d+(?:[.,]\d+)?", raw_price): price = float(raw_price.replace(",", ".")) area = None raw_area = str(u.get("superficie_pieds") or "").strip() if re.fullmatch(r"\d+(?:[.,]\d+)?", raw_area): area = float(raw_area.replace(",", ".")) images: list[str] = [] plan = (u.get("plan_url") or "").replace("\\/", "/") if plan.startswith("http"): images.append(plan) amenities: list[str] = [] finition = (u.get("finition") or "").strip() if finition and not finition.lower().startswith("non-attribu"): amenities.append(f"Finition : {finition}") listings[unit_slug] = Listing( source=self.source_id, external_id=unit_slug, url=url, title=title, address=address, sector=project, city=city, unit_type=normalize_unit_type((u.get("type") or "").strip()), price=price, price_label=f"{raw_price} $" if price is not None else "", availability=availability, area_sqft=area, amenities=amenities, images=images, ) # -- unité (gabarit sélecteur d'étages : Kali, Wil, Alto) --------------------------- def _add_selector_unit(self, listings: dict[str, Listing], u: dict, project_slug: str, page_url: str, project: str, address: str, city: str) -> None: uid = str(u.get("id") or "").strip() number = (u.get("number") or "").strip() # « Unité 410 » if not uid: return ext_id = f"{project_slug}-{uid}" # id BD WordPress : stable if ext_id in listings: return # type structuré : typology « 4etdemiB » / « StudioA » -> 4½ / Studio typology = (u.get("typology") or "").strip() unit_type = "" m = _TYPOLOGY_RE.search(typology) if m: unit_type = normalize_unit_type(f"{m.group(1)} ½") elif typology.lower().startswith("studio"): unit_type = "Studio" # disponibilité affichée : légende « Disponible » (+ date si annoncée) availability = "Disponible" date_txt = (u.get("available_date") or "").strip() if date_txt: availability += f" — {date_txt}" price = None raw = u.get("price") if isinstance(raw, (int, float)) and raw > 0: price = float(raw) area = None ia = u.get("indoor_area") if isinstance(ia, (int, float)) and ia > 0: area = float(ia) amenities: list[str] = [] ba = u.get("balcony_area") if isinstance(ba, (int, float)) and ba > 0: amenities.append(f"Balcon {ba:g} pi²") images: list[str] = [] plan = (u.get("plan_img_src") or "").replace("\\/", "/") if plan.startswith("http"): images.append(plan) listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=page_url, # pas de fiche par unité sur ce gabarit title=f"{project} — {number}".strip(" —") or ext_id, address=address, sector=project, city=city, unit_type=unit_type, price=price, price_label=f"{price:g} $" if price is not None else "", availability=availability, area_sqft=area, amenities=amenities, images=images, )