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/viridi.py : connecteur Le Viridi (condosleviridi.ca)5# Immeuble de 89 unités dans l'Écoquartier Pointe-aux-Lièvres (Québec).6# Le site présente 16 modèles d'unités (types A à O) avec prix7# « à partir de » par catégorie (Studio/Loft/3½/4½/5½/6½, maison de ville).8# Une annonce par modèle (pas de liste d'unités individuelles).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import hashlib13import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, normalize_unit_type, parse_price18from .base import BaseConnector1920BASE = "https://condosleviridi.ca"21LIST_URL = f"{BASE}/condos-a-louer-quebec/"22CONTACT_URL = f"{BASE}/nous-joindre/"23SECTOR = "Pointe-aux-Lièvres (Saint-Roch)"24CITY = "Québec"25FALLBACK_ADDRESS = "Écoquartier Pointe-aux-Lièvres, Québec"2627_SKIP_IMG = re.compile(r"logo|favicon|icon|affichez", re.I)282930class ViridiConnector(BaseConnector):31 source_id = "viridi"32 request_delay = 0.633 max_detail_requests = 60 # plafond de vraies requêtes « fiche modèle »3435 # ------------------------------------------------------------------36 def _site_info(self) -> tuple[str, dict]:37 """Adresse civique + contact depuis la page Nous joindre.3839 « Adresse du projet : 40, rue de la Pointe-aux-Lièvres, Québec,40 QC G1K 0J7 » + liens tel:/mailto: (structurés dans la page).41 """42 address, contact = "", {}43 try:44 html = self.get(CONTACT_URL).text45 except Exception:46 return address, contact47 m = re.search(48 r"(\d{1,5},?\s(?:rue|avenue|boulevard)[^<>&]{0,50}"49 r"Pointe-aux-Li[eè]vres,?)"50 r"(?:<[^>]*>\s*)*"51 r"(Qu[ée]bec[^<>]{0,30})?", html)52 if m:53 address = m.group(1).strip()54 if m.group(2):55 address += " " + m.group(2).strip()56 m = re.search(r'href="tel:([\d\-. ]{10,})"', html)57 if m:58 digits = re.sub(r"\D", "", m.group(1))[-10:]59 if len(digits) == 10:60 contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"61 m = re.search(r'href="mailto:([^"?]+)"', html)62 if m:63 contact["email"] = m.group(1).strip().lower()64 return address, contact6566 # ------------------------------------------------------------------67 def _fetch_model(self, url: str) -> dict:68 """Fiche d'un modèle : description, caractéristiques à icônes, photos."""69 dhtml = self.get(url).text70 dsoup = BeautifulSoup(dhtml, "html.parser")7172 # premier paragraphe descriptif (« Cette unité... », « Ce condo... »)73 description = ""74 el = dsoup.find(string=re.compile(r"Cette unité|Ce (?:condo|loft|modèle)"))75 if el:76 p = el.find_parent("p")77 if p:78 description = p.get_text(" ", strip=True)[:600]7980 # caractéristiques : chaque icône VC est suivie d'un bloc texte81 # (chambres, salles de bain, superficie, inclusions, animaux...)82 amenities: list[str] = []83 for icon in dsoup.select(".vc_icon_element"):84 txt = icon.find_next_sibling("div", class_="wpb_text_column")85 if not txt:86 continue87 t = re.sub(r"\s+", " ", txt.get_text(" ", strip=True))88 if t and len(t) < 120 and t not in amenities:89 amenities.append(t)9091 imgs = re.findall(92 rf'src="({re.escape(BASE)}/wp-content/uploads/[^"]+'93 rf'\.(?:jpg|jpeg|png|webp))"', dhtml, re.I)94 images = [u for u in dict.fromkeys(imgs)95 if not _SKIP_IMG.search(u)][:10]96 return {"description": description, "amenities": amenities,97 "images": images}9899 # ------------------------------------------------------------------100 def _price_map(self, text: str) -> dict[tuple[str, bool], tuple[float | None, str]]:101 """Construit {(catégorie, maison_de_ville): (prix, libellé)}.102103 Le bloc de prix de la page liste, dans l'ordre :104 Loft 1285$, Studio 1300$, 3½ 1400$, 4½ 1995$,105 4½ MV 2450$, 5½ MV 2800$, 6½ MV 3300$.106 """107 out: dict[tuple[str, bool], tuple[float | None, str]] = {}108 # découpage en lignes propres109 lines = [l.strip() for l in text.split("\n") if l.strip()]110 current: str | None = None111 mv = False112 for line in lines:113 low = line.lower()114 if re.fullmatch(r"(loft|studio|\d\s*½|\d\s*1/2)", low):115 current = ("loft" if low == "loft" else116 "studio" if low == "studio" else117 re.search(r"\d", low).group(0))118 mv = False119 elif "maison de ville" in low and current:120 mv = True121 elif current and "à partir de" in low:122 price = parse_price(line)123 label = re.sub(r"\s+", " ", line)124 if mv:125 label += " (maison de ville)"126 out[(current, mv)] = (price, label)127 current = None128 mv = False129 return out130131 # ------------------------------------------------------------------132 def fetch(self) -> list[Listing]:133 self._detail_requests = 0134 html = self.get(LIST_URL).text135 soup = BeautifulSoup(html, "html.parser")136 prices = self._price_map(soup.get_text("\n", strip=True))137 address, contact = self._site_info()138 if not address:139 address = FALLBACK_ADDRESS140141 # Cartes de modèles : image plan + titre h3 + bouton "Plus d'infos"142 cards: list[tuple[str, str, str]] = [] # (titre, url, img)143 for a in soup.select('a[title="Lien vers le modèle"][href]'):144 href = a["href"].strip()145 if href.startswith("http:"):146 href = "https:" + href[5:]147 wrapper = a.find_parent("div", class_="wpb_wrapper")148 title = img = ""149 if wrapper:150 h3 = wrapper.select_one("h3")151 if h3:152 title = h3.get_text(" ", strip=True)153 im = wrapper.select_one("img[src]")154 if im:155 img = im["src"]156 if title and (title, href, img) not in cards:157 cards.append((title, href, img))158159 listings: list[Listing] = []160 for title, url, thumb in cards:161 if not re.search(r"\(type\s", title, re.I):162 continue # carte non-modèle (ex. bouton de contact)163 try:164 mv = "maison de ville" in title.lower()165 cat = None166 if re.search(r"studio", title, re.I):167 cat = "studio"168 elif re.search(r"loft", title, re.I):169 cat = "loft"170 else:171 d = re.search(r"(\d)\s*½", title)172 if d:173 cat = d.group(1)174 price, price_label = prices.get((cat, mv), (None, ""))175 if price is None and cat and not mv:176 # tolérance si la carte MV/condo ne matche pas exactement177 price, price_label = prices.get((cat, True), (None, ""))178179 unit_type = normalize_unit_type(title)180 if cat == "studio":181 unit_type = "Studio"182 elif cat == "loft":183 unit_type = "Loft"184185 # Fiche du modèle (description, caractéristiques à icônes,186 # photos) via le cache détail : 1 vraie requête par modèle187 # et par changement de carte liste.188 ext_id = url.rstrip("/").split("/")[-1]189 key = hashlib.sha1(190 f"{title}|{url}|{thumb}|{price_label}".encode("utf-8")191 ).hexdigest()192193 def _fetch(url=url) -> dict:194 if self._detail_requests >= self.max_detail_requests:195 return {}196 self._detail_requests += 1197 return self._fetch_model(url)198199 try:200 payload = self.detail(ext_id, key, _fetch) or {}201 except Exception:202 payload = {}203 description = payload.get("description", "")204 amenities = list(payload.get("amenities") or [])205 images = list(payload.get("images") or [])206207 # « Animaux de compagnie acceptés » : caractéristique à icône208 # explicite de la fiche -> pets structuré209 pets = None210 if any(re.search(r"animaux de compagnie accept",211 a, re.I) for a in amenities):212 pets = "oui"213214 if thumb and thumb not in images and not _SKIP_IMG.search(thumb):215 # version pleine grandeur de la vignette216 full = re.sub(r"-\d+x\d+(\.(?:jpg|jpeg|png|webp))$",217 r"\1", thumb)218 images.insert(0, full)219 images = list(dict.fromkeys(images))220221 listings.append(Listing(222 source=self.source_id,223 external_id=ext_id,224 url=url,225 title=f"Le Viridi — {title}",226 address=address,227 sector=SECTOR,228 city=CITY,229 unit_type=unit_type,230 price=price,231 price_label=price_label,232 availability="",233 pets=pets,234 description=description,235 amenities=amenities,236 details={"contact": dict(contact)} if contact else {},237 images=images,238 ))239 except Exception:240 continue241242 return listings243