# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/oklouer.py : connecteur OK Louer (oklouer.com) # Appartements meublés en location temporaire à Québec (Haute-Ville, # Centre-Ville, Limoilou, Charlesbourg/Beauport). Pages rendues serveur. # Les pages de secteurs exposent des cartes `div.unit` structurées : # data-lat/data-lng, adresse (h3), icônes lits/chambres/salles de bain, # animaux permis (ico-paws). La fiche ajoute le prix, la galerie et la # climatisation (ico-clim). Le secteur Lac-Beauport est exclu. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.oklouer.com" # secteurs conservés (lac-beauport exclu : hors Québec/Lévis) SECTORS = { "haute-ville": "Haute-Ville (Montcalm, St-Jean-Baptiste)", "centre-ville": "Centre-Ville (St-Roch)", "limoilou": "Limoilou", "charlesbourg-beauport": "Charlesbourg / Beauport", } # une fiche d'unité se termine par un code postal, ex. # /2-1-2-limoilou-1905a-de-la-bastille-quebec-qc-g1l-4b9 DETAIL_RE = re.compile(r"-qc-g\d[a-z]-?\d[a-z]\d$", re.I) # icônes des cartes/fiches -> libellé de commodité (le compte vient du span) _ICON_LABELS = { "ico-bed": ("lit", "lits"), "ico-bedroom": ("chambre", "chambres"), "ico-bathroom": ("salle de bain", "salles de bain"), } def _parse_icon_list(ul) -> tuple[str, list[str]]: """Extrait (type d'unité brut, commodités) d'un `ul.icon-list`. Icônes à compteur (lits, chambres, salles de bain) -> "2 lits" ; icônes booléennes (ico-paws, ico-clim) -> texte du tooltip `title` (ex. "Animaux permis", "Avec air climatisé sur demande"). """ unit_raw = "" amenities: list[str] = [] for li in ul.find_all("li"): classes = li.get("class") or [] if "nb-pieces" in classes: unit_raw = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) continue ico = li.find("i") if not ico: continue ico_cls = " ".join(ico.get("class") or []) count_el = li.select_one("span.count") count = count_el.get_text(strip=True) if count_el else "" for key, (sing, plur) in _ICON_LABELS.items(): if key in ico_cls and count.isdigit(): n = int(count) amenities.append(f"{n} {plur if n > 1 else sing}") break else: if "ico-paws" in ico_cls or "ico-clim" in ico_cls: title = (ico.get("title") or "").strip() if title: amenities.append(title) return unit_raw, amenities class OkLouerConnector(BaseConnector): source_id = "oklouer" request_delay = 0.6 max_details = 80 # garde-fou def fetch(self) -> list[Listing]: # 1) Cartes structurées des pages de secteurs : lien, adresse, # lat/lng, type d'unité, lits/chambres/salles de bain, animaux cards: dict[str, dict] = {} # path -> infos de carte for slug, sector in SECTORS.items(): try: html = self.get(f"{BASE}/logements/{slug}").text except Exception: continue soup = BeautifulSoup(html, "html.parser") for unit in soup.select("div.logements div.unit"): a = unit.find("a", href=True) path = a["href"] if a else "" if not DETAIL_RE.search(path) or path in cards: continue info: dict = {"sector": sector} try: info["lat"] = float(unit.get("data-lat")) info["lng"] = float(unit.get("data-lng")) except (TypeError, ValueError): pass h3 = unit.select_one("div.unit-infos h3") if h3: info["address"] = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)) ul = unit.select_one("ul.icon-list") if ul: info["unit_raw"], info["amenities"] = _parse_icon_list(ul) cards[path] = info # 2) Fiche détaillée de chaque unité : prix, galerie, adresse propre, # climatisation (l'icône ico-clim n'apparaît que sur la fiche) listings: list[Listing] = [] for i, (path, card) in enumerate(sorted(cards.items())): if i >= self.max_details: break try: html = self.get(BASE + path).text except Exception: continue try: soup = BeautifulSoup(html, "html.parser") # Adresse : entête de fiche, sinon carte de la page secteur address = card.get("address", "") h3 = soup.select_one("h3.unit-address") if h3: address = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)) # Secteur plus précis si présent dans le slug de la fiche sector = card.get("sector", "") for key, name in (("centre-ville", SECTORS["centre-ville"]), ("haute-ville", SECTORS["haute-ville"]), ("limoilou", SECTORS["limoilou"]), ("charlesbourg", SECTORS["charlesbourg-beauport"])): if key in path: sector = name break # Prix mensuel : "* À partir de 75$/jour ou 1100$/mois" price = None price_label = "" price_el = soup.select_one("div.price") price_text = (re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) if price_el else soup.get_text(" ", strip=True)) pm = re.search(r"([\d\s ]{2,})\$\s*/\s*mois", price_text) if pm: price = parse_price(pm.group(1).strip() + "$") lm = re.search(r"À partir de[^(\n]*", price_text) if lm: price_label = re.sub(r"\s+", " ", lm.group(0)).strip(" *") if "mois" not in price_label and pm: price_label += f" ou {pm.group(1).strip()}$/mois" # Icônes de la fiche (lits, chambres, sdb, animaux, clim) ; # repli sur les icônes de la carte liste amenities = list(card.get("amenities") or []) unit_raw = card.get("unit_raw", "") ul = soup.select_one("ul.icon-list") if ul: u_raw, amen = _parse_icon_list(ul) unit_raw = u_raw or unit_raw for a_txt in amen: if a_txt not in amenities: amenities.append(a_txt) if "Meublé" not in amenities: amenities.append("Meublé") # Type d'unité : icônes, sinon slug unit_type = normalize_unit_type(unit_raw) tm = re.match(r"^/(\d)-1-2-", path) if not unit_type: if tm: unit_type = normalize_unit_type(f"{tm.group(1)} 1/2") elif path.startswith("/studios-lofts"): unit_type = "Loft" elif path.startswith("/studio"): unit_type = "Studio" elif path.startswith("/condo"): unit_type = "Condo" # Images (galerie /uploads/m/) imgs = re.findall(r'(?:src|href)="(/uploads/m/[^"]+' r'\.(?:jpg|jpeg|png|webp))"', html, re.I) images = [BASE + u for u in dict.fromkeys(imgs)][:25] title = address or path.strip("/").replace("-", " ").title() listings.append(Listing( source=self.source_id, external_id=path.strip("/"), url=BASE + path, title=f"{unit_type + ' — ' if unit_type else ''}{title}" " (meublé, location temporaire)", address=address, sector=sector, city=infer_city(sector, default="Québec"), unit_type=unit_type, price=price, price_label=price_label, availability="", description="Appartement meublé en location temporaire " "(court, moyen ou long terme).", amenities=amenities, images=images, lat=card.get("lat"), lng=card.get("lng"), )) except Exception: continue return listings