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/oklouer.py : connecteur OK Louer (oklouer.com)5# Appartements meublés en location temporaire à Québec (Haute-Ville,6# Centre-Ville, Limoilou, Charlesbourg/Beauport). Pages rendues serveur.7# Les pages de secteurs exposent des cartes `div.unit` structurées :8# data-lat/data-lng, adresse (h3), icônes lits/chambres/salles de bain,9# animaux permis (ico-paws). La fiche ajoute le prix, la galerie et la10# climatisation (ico-clim). Le secteur Lac-Beauport est exclu.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, infer_city, normalize_unit_type, parse_price19from .base import BaseConnector2021BASE = "https://www.oklouer.com"2223# secteurs conservés (lac-beauport exclu : hors Québec/Lévis)24SECTORS = {25 "haute-ville": "Haute-Ville (Montcalm, St-Jean-Baptiste)",26 "centre-ville": "Centre-Ville (St-Roch)",27 "limoilou": "Limoilou",28 "charlesbourg-beauport": "Charlesbourg / Beauport",29}3031# une fiche d'unité se termine par un code postal, ex.32# /2-1-2-limoilou-1905a-de-la-bastille-quebec-qc-g1l-4b933DETAIL_RE = re.compile(r"-qc-g\d[a-z]-?\d[a-z]\d$", re.I)3435# icônes des cartes/fiches -> libellé de commodité (le compte vient du span)36_ICON_LABELS = {37 "ico-bed": ("lit", "lits"),38 "ico-bedroom": ("chambre", "chambres"),39 "ico-bathroom": ("salle de bain", "salles de bain"),40}414243def _parse_icon_list(ul) -> tuple[str, list[str]]:44 """Extrait (type d'unité brut, commodités) d'un `ul.icon-list`.4546 Icônes à compteur (lits, chambres, salles de bain) -> "2 lits" ;47 icônes booléennes (ico-paws, ico-clim) -> texte du tooltip `title`48 (ex. "Animaux permis", "Avec air climatisé sur demande").49 """50 unit_raw = ""51 amenities: list[str] = []52 for li in ul.find_all("li"):53 classes = li.get("class") or []54 if "nb-pieces" in classes:55 unit_raw = re.sub(r"\s+", " ", li.get_text(" ", strip=True))56 continue57 ico = li.find("i")58 if not ico:59 continue60 ico_cls = " ".join(ico.get("class") or [])61 count_el = li.select_one("span.count")62 count = count_el.get_text(strip=True) if count_el else ""63 for key, (sing, plur) in _ICON_LABELS.items():64 if key in ico_cls and count.isdigit():65 n = int(count)66 amenities.append(f"{n} {plur if n > 1 else sing}")67 break68 else:69 if "ico-paws" in ico_cls or "ico-clim" in ico_cls:70 title = (ico.get("title") or "").strip()71 if title:72 amenities.append(title)73 return unit_raw, amenities747576class OkLouerConnector(BaseConnector):77 source_id = "oklouer"78 request_delay = 0.679 max_details = 80 # garde-fou8081 def fetch(self) -> list[Listing]:82 # 1) Cartes structurées des pages de secteurs : lien, adresse,83 # lat/lng, type d'unité, lits/chambres/salles de bain, animaux84 cards: dict[str, dict] = {} # path -> infos de carte85 for slug, sector in SECTORS.items():86 try:87 html = self.get(f"{BASE}/logements/{slug}").text88 except Exception:89 continue90 soup = BeautifulSoup(html, "html.parser")91 for unit in soup.select("div.logements div.unit"):92 a = unit.find("a", href=True)93 path = a["href"] if a else ""94 if not DETAIL_RE.search(path) or path in cards:95 continue96 info: dict = {"sector": sector}97 try:98 info["lat"] = float(unit.get("data-lat"))99 info["lng"] = float(unit.get("data-lng"))100 except (TypeError, ValueError):101 pass102 h3 = unit.select_one("div.unit-infos h3")103 if h3:104 info["address"] = re.sub(r"\s+", " ",105 h3.get_text(" ", strip=True))106 ul = unit.select_one("ul.icon-list")107 if ul:108 info["unit_raw"], info["amenities"] = _parse_icon_list(ul)109 cards[path] = info110111 # 2) Fiche détaillée de chaque unité : prix, galerie, adresse propre,112 # climatisation (l'icône ico-clim n'apparaît que sur la fiche)113 listings: list[Listing] = []114 for i, (path, card) in enumerate(sorted(cards.items())):115 if i >= self.max_details:116 break117 try:118 html = self.get(BASE + path).text119 except Exception:120 continue121 try:122 soup = BeautifulSoup(html, "html.parser")123124 # Adresse : entête de fiche, sinon carte de la page secteur125 address = card.get("address", "")126 h3 = soup.select_one("h3.unit-address")127 if h3:128 address = re.sub(r"\s+", " ", h3.get_text(" ", strip=True))129130 # Secteur plus précis si présent dans le slug de la fiche131 sector = card.get("sector", "")132 for key, name in (("centre-ville", SECTORS["centre-ville"]),133 ("haute-ville", SECTORS["haute-ville"]),134 ("limoilou", SECTORS["limoilou"]),135 ("charlesbourg", SECTORS["charlesbourg-beauport"])):136 if key in path:137 sector = name138 break139140 # Prix mensuel : "* À partir de 75$/jour ou 1100$/mois"141 price = None142 price_label = ""143 price_el = soup.select_one("div.price")144 price_text = (re.sub(r"\s+", " ", price_el.get_text(" ", strip=True))145 if price_el else soup.get_text(" ", strip=True))146 pm = re.search(r"([\d\s ]{2,})\$\s*/\s*mois", price_text)147 if pm:148 price = parse_price(pm.group(1).strip() + "$")149 lm = re.search(r"À partir de[^(\n]*", price_text)150 if lm:151 price_label = re.sub(r"\s+", " ", lm.group(0)).strip(" *")152 if "mois" not in price_label and pm:153 price_label += f" ou {pm.group(1).strip()}$/mois"154155 # Icônes de la fiche (lits, chambres, sdb, animaux, clim) ;156 # repli sur les icônes de la carte liste157 amenities = list(card.get("amenities") or [])158 unit_raw = card.get("unit_raw", "")159 ul = soup.select_one("ul.icon-list")160 if ul:161 u_raw, amen = _parse_icon_list(ul)162 unit_raw = u_raw or unit_raw163 for a_txt in amen:164 if a_txt not in amenities:165 amenities.append(a_txt)166 if "Meublé" not in amenities:167 amenities.append("Meublé")168169 # Type d'unité : icônes, sinon slug170 unit_type = normalize_unit_type(unit_raw)171 tm = re.match(r"^/(\d)-1-2-", path)172 if not unit_type:173 if tm:174 unit_type = normalize_unit_type(f"{tm.group(1)} 1/2")175 elif path.startswith("/studios-lofts"):176 unit_type = "Loft"177 elif path.startswith("/studio"):178 unit_type = "Studio"179 elif path.startswith("/condo"):180 unit_type = "Condo"181182 # Images (galerie /uploads/m/)183 imgs = re.findall(r'(?:src|href)="(/uploads/m/[^"]+'184 r'\.(?:jpg|jpeg|png|webp))"', html, re.I)185 images = [BASE + u for u in dict.fromkeys(imgs)][:25]186187 title = address or path.strip("/").replace("-", " ").title()188 listings.append(Listing(189 source=self.source_id,190 external_id=path.strip("/"),191 url=BASE + path,192 title=f"{unit_type + ' — ' if unit_type else ''}{title}"193 " (meublé, location temporaire)",194 address=address,195 sector=sector,196 city=infer_city(sector, default="Québec"),197 unit_type=unit_type,198 price=price,199 price_label=price_label,200 availability="",201 description="Appartement meublé en location temporaire "202 "(court, moyen ou long terme).",203 amenities=amenities,204 images=images,205 lat=card.get("lat"),206 lng=card.get("lng"),207 ))208 except Exception:209 continue210211 return listings212