Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/solis_levis.py : Solis Lévis (solislevis.com) — RPA 55+ autonomes5# Immeuble unique de 7 étages sur la rue Weyman à Lévis (Studio, 3½, 4½,6# dès ~1 231 $/mois, plusieurs inclusions : eau chaude, électricité,7# chauffage, climatisation). WordPress rendu serveur : la page8# /appartements-retraites-autonomes/ embarque le sélecteur d'unités complet9# dans un <script> « units = [[…]] » — un tableau par étage, chaque unité10# avec numéro, état (Available/Reserved/Sold), typologie (0=Studio, 1=3½,11# 2=4½), type de plan, prix mensuel, superficies (logement + balcon) et12# image du plan. Une annonce Lou-Ka par unité DISPONIBLE, prix exact.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing22from .base import BaseConnector2324PAGE_URL = "https://solislevis.com/appartements-retraites-autonomes/"25ADDRESS = "Rue Weyman, Lévis"2627# typologie du sélecteur (data-typology des filtres de la page)28_TYPOLOGY = {0: "Studio", 1: "3½", 2: "4½"}2930_UNITS_RE = re.compile(r"units\s*=\s*(\[\[[\s\S]*?\]\])\s*;")31_MAP_SOLIS_RE = re.compile(32 r'\{"nom":"Solis","adresse":"[^"]*","latitude":"([\d.\-]+)",'33 r'"longitude":"([\d.\-]+)"')34_IMG_RE = re.compile(35 r"https://solislevis\.com/app/uploads/[^\"\s\\']+\.(?:jpe?g|webp|png)", re.I)363738class SolisLevisConnector(BaseConnector):39 source_id = "solis_levis"40 request_delay = 0.641 max_images = 124243 # -- inclusions de l'immeuble (listes sous « Tous nos appartements44 # offrent » et « Le prix mensuel inclut ») --------------------------------45 @staticmethod46 def _inclusions(soup: BeautifulSoup) -> list[str]:47 feats: list[str] = []48 # « Tous nos appartements offrent : » — liste <li> qui suit le titre49 el = soup.find(string=re.compile("appartements offrent", re.I))50 if el is not None:51 ul = el.parent.find_next("ul")52 if ul is not None:53 for li in ul.find_all("li")[:8]:54 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()55 if 3 <= len(t) <= 80 and t not in feats:56 feats.append(t)57 # « Le prix mensuel inclut : » — grille d'icônes (p.icon-descrpition,58 # coquille du thème incluse)59 for p in soup.select("p.icon-descrpition")[:8]:60 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()61 if 2 <= len(t) <= 80 and t not in feats:62 feats.append(t)63 return feats6465 def fetch(self) -> list[Listing]:66 listings: list[Listing] = []67 try:68 html = self.get(PAGE_URL).text69 except Exception:70 return listings7172 m = _UNITS_RE.search(html)73 if not m:74 return listings75 try:76 floors = json.loads(m.group(1))77 except ValueError:78 return listings7980 soup = BeautifulSoup(html, "html.parser")8182 # coordonnées : point « Solis » de la carte du quartier (repli : centre83 # du secteur Weyman/Lévis relevé sur la page)84 lat, lng = 46.79696, -71.1587485 gm = _MAP_SOLIS_RE.search(html)86 if gm:87 try:88 lat, lng = float(gm.group(1)), float(gm.group(2))89 except ValueError:90 pass9192 # description : intro de la page + promotion en cours le cas échéant93 paras = []94 for p in soup.find_all("p"):95 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()96 if len(t) >= 100:97 paras.append(t)98 break99 promo = ""100 pm = re.search(r"Promotion en cours\s*:\s*([^<]{5,120})", html)101 if pm:102 promo = "Promotion en cours : " + pm.group(1).strip()103 desc = "\n\n".join(x for x in (paras[0] if paras else "", promo) if x)104105 inclusions = self._inclusions(soup)106107 # photos de l'immeuble (héro/galerie de la page ; les plans d'unités108 # viennent en plus, annonce par annonce)109 building_imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))110 if not re.search(r"logo|icon|favicon|plan|spinner|"111 r"\d{3}(?:@\d{3})?\.png", u, re.I)]112113 for floor in floors:114 for unit in floor:115 if (unit.get("state") or "") != "Available":116 continue117 number = unit.get("number")118 price = unit.get("price")119 if number is None or not price:120 continue121 typology = _TYPOLOGY.get(unit.get("typology"), "")122 if not typology:123 continue124 level = int(number) // 100125 floor_label = "RDC" if level == 1 else f"{level}e étage"126 details = {127 "Résidence pour aînés": "oui",128 "Étage": floor_label,129 "Type de plan": unit.get("type") or "",130 }131 area = unit.get("indoor_area")132 balcony = unit.get("balcony_area")133 if balcony:134 details["Balcon"] = f"{balcony} pi²"135 images: list[str] = []136 if unit.get("plan_img_src"):137 images.append(unit["plan_img_src"])138 images += building_imgs139 listings.append(Listing(140 source=self.source_id,141 external_id=f"solis-{number}",142 url=PAGE_URL,143 title=f"{typology} — Solis Lévis, unité {number}",144 address=ADDRESS,145 city="Lévis",146 unit_type=typology,147 price=float(price),148 availability="Disponible",149 area_sqft=float(area) if area else None,150 description=desc,151 amenities=list(inclusions),152 details=details,153 images=images[: self.max_images],154 lat=lat,155 lng=lng,156 ))157 return listings158