# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/viridi.py : connecteur Le Viridi (condosleviridi.ca) # Immeuble de 89 unités dans l'Écoquartier Pointe-aux-Lièvres (Québec). # Le site présente 16 modèles d'unités (types A à O) avec prix # « à partir de » par catégorie (Studio/Loft/3½/4½/5½/6½, maison de ville). # Une annonce par modèle (pas de liste d'unités individuelles). # ----------------------------------------------------------------------------- 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://condosleviridi.ca" LIST_URL = f"{BASE}/condos-a-louer-quebec/" CONTACT_URL = f"{BASE}/nous-joindre/" SECTOR = "Pointe-aux-Lièvres (Saint-Roch)" CITY = "Québec" FALLBACK_ADDRESS = "Écoquartier Pointe-aux-Lièvres, Québec" _SKIP_IMG = re.compile(r"logo|favicon|icon|affichez", re.I) class ViridiConnector(BaseConnector): source_id = "viridi" request_delay = 0.6 max_detail_requests = 60 # plafond de vraies requêtes « fiche modèle » # ------------------------------------------------------------------ def _site_info(self) -> tuple[str, dict]: """Adresse civique + contact depuis la page Nous joindre. « Adresse du projet : 40, rue de la Pointe-aux-Lièvres, Québec, QC G1K 0J7 » + liens tel:/mailto: (structurés dans la page). """ address, contact = "", {} try: html = self.get(CONTACT_URL).text except Exception: return address, contact m = re.search( r"(\d{1,5},?\s(?:rue|avenue|boulevard)[^<>&]{0,50}" r"Pointe-aux-Li[eè]vres,?)" r"(?:<[^>]*>\s*)*" r"(Qu[ée]bec[^<>]{0,30})?", html) if m: address = m.group(1).strip() if m.group(2): address += " " + m.group(2).strip() m = re.search(r'href="tel:([\d\-. ]{10,})"', html) if m: digits = re.sub(r"\D", "", m.group(1))[-10:] if len(digits) == 10: contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" m = re.search(r'href="mailto:([^"?]+)"', html) if m: contact["email"] = m.group(1).strip().lower() return address, contact # ------------------------------------------------------------------ def _fetch_model(self, url: str) -> dict: """Fiche d'un modèle : description, caractéristiques à icônes, photos.""" dhtml = self.get(url).text dsoup = BeautifulSoup(dhtml, "html.parser") # premier paragraphe descriptif (« Cette unité... », « Ce condo... ») description = "" el = dsoup.find(string=re.compile(r"Cette unité|Ce (?:condo|loft|modèle)")) if el: p = el.find_parent("p") if p: description = p.get_text(" ", strip=True)[:600] # caractéristiques : chaque icône VC est suivie d'un bloc texte # (chambres, salles de bain, superficie, inclusions, animaux...) amenities: list[str] = [] for icon in dsoup.select(".vc_icon_element"): txt = icon.find_next_sibling("div", class_="wpb_text_column") if not txt: continue t = re.sub(r"\s+", " ", txt.get_text(" ", strip=True)) if t and len(t) < 120 and t not in amenities: amenities.append(t) imgs = re.findall( rf'src="({re.escape(BASE)}/wp-content/uploads/[^"]+' rf'\.(?:jpg|jpeg|png|webp))"', dhtml, re.I) images = [u for u in dict.fromkeys(imgs) if not _SKIP_IMG.search(u)][:10] return {"description": description, "amenities": amenities, "images": images} # ------------------------------------------------------------------ def _price_map(self, text: str) -> dict[tuple[str, bool], tuple[float | None, str]]: """Construit {(catégorie, maison_de_ville): (prix, libellé)}. Le bloc de prix de la page liste, dans l'ordre : Loft 1285$, Studio 1300$, 3½ 1400$, 4½ 1995$, 4½ MV 2450$, 5½ MV 2800$, 6½ MV 3300$. """ out: dict[tuple[str, bool], tuple[float | None, str]] = {} # découpage en lignes propres lines = [l.strip() for l in text.split("\n") if l.strip()] current: str | None = None mv = False for line in lines: low = line.lower() if re.fullmatch(r"(loft|studio|\d\s*½|\d\s*1/2)", low): current = ("loft" if low == "loft" else "studio" if low == "studio" else re.search(r"\d", low).group(0)) mv = False elif "maison de ville" in low and current: mv = True elif current and "à partir de" in low: price = parse_price(line) label = re.sub(r"\s+", " ", line) if mv: label += " (maison de ville)" out[(current, mv)] = (price, label) current = None mv = False return out # ------------------------------------------------------------------ def fetch(self) -> list[Listing]: self._detail_requests = 0 html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") prices = self._price_map(soup.get_text("\n", strip=True)) address, contact = self._site_info() if not address: address = FALLBACK_ADDRESS # Cartes de modèles : image plan + titre h3 + bouton "Plus d'infos" cards: list[tuple[str, str, str]] = [] # (titre, url, img) for a in soup.select('a[title="Lien vers le modèle"][href]'): href = a["href"].strip() if href.startswith("http:"): href = "https:" + href[5:] wrapper = a.find_parent("div", class_="wpb_wrapper") title = img = "" if wrapper: h3 = wrapper.select_one("h3") if h3: title = h3.get_text(" ", strip=True) im = wrapper.select_one("img[src]") if im: img = im["src"] if title and (title, href, img) not in cards: cards.append((title, href, img)) listings: list[Listing] = [] for title, url, thumb in cards: if not re.search(r"\(type\s", title, re.I): continue # carte non-modèle (ex. bouton de contact) try: mv = "maison de ville" in title.lower() cat = None if re.search(r"studio", title, re.I): cat = "studio" elif re.search(r"loft", title, re.I): cat = "loft" else: d = re.search(r"(\d)\s*½", title) if d: cat = d.group(1) price, price_label = prices.get((cat, mv), (None, "")) if price is None and cat and not mv: # tolérance si la carte MV/condo ne matche pas exactement price, price_label = prices.get((cat, True), (None, "")) unit_type = normalize_unit_type(title) if cat == "studio": unit_type = "Studio" elif cat == "loft": unit_type = "Loft" # Fiche du modèle (description, caractéristiques à icônes, # photos) via le cache détail : 1 vraie requête par modèle # et par changement de carte liste. ext_id = url.rstrip("/").split("/")[-1] key = hashlib.sha1( f"{title}|{url}|{thumb}|{price_label}".encode("utf-8") ).hexdigest() def _fetch(url=url) -> dict: if self._detail_requests >= self.max_detail_requests: return {} self._detail_requests += 1 return self._fetch_model(url) try: payload = self.detail(ext_id, key, _fetch) or {} except Exception: payload = {} description = payload.get("description", "") amenities = list(payload.get("amenities") or []) images = list(payload.get("images") or []) # « Animaux de compagnie acceptés » : caractéristique à icône # explicite de la fiche -> pets structuré pets = None if any(re.search(r"animaux de compagnie accept", a, re.I) for a in amenities): pets = "oui" if thumb and thumb not in images and not _SKIP_IMG.search(thumb): # version pleine grandeur de la vignette full = re.sub(r"-\d+x\d+(\.(?:jpg|jpeg|png|webp))$", r"\1", thumb) images.insert(0, full) images = list(dict.fromkeys(images)) listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=f"Le Viridi — {title}", address=address, sector=SECTOR, city=CITY, unit_type=unit_type, price=price, price_label=price_label, availability="", pets=pets, description=description, amenities=amenities, details={"contact": dict(contact)} if contact else {}, images=images, )) except Exception: continue return listings