SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
9.1 KB · 212 lines python
Raw Blame History
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