SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.1 KB · 160 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/logicom.py : connecteur Les Immeubles Logicom5#   (immeubleslogicom.com — Sainte-Foy, Sillery, Lévis…). WordPress rendu6#   serveur : /appartements-a-louer/ liste les immeubles (/immeuble/<slug>/),7#   et chaque page immeuble expose ses unités disponibles en cartes8#   `.appartement` (no d'unité dans le titre, disponibilité, type n ½,9#   superficie, balcon, prix « À partir de … $ », plan PDF/PNG) + un marqueur10#   carte data-lat/data-lng. Granularité : unité.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, infer_city, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "https://immeubleslogicom.com"23LIST_URL = f"{BASE}/appartements-a-louer/"2425UNIT_NO_RE = re.compile(r"(\d{1,4}[A-Z]?)\s*$")26TYPE_OK_RE = re.compile(r"studio|loft|\d\s*(?:½|1/2)", re.I)27SQFT_RE = re.compile(r"([\d\s]+)\s*pi²", re.I)282930class LogicomConnector(BaseConnector):31    source_id = "logicom"32    request_delay = 0.633    max_buildings = 15       # garde-fou de crawl3435    def fetch(self) -> list[Listing]:36        listings: list[Listing] = []37        try:38            html = self.get(LIST_URL).text39        except Exception:40            return listings4142        slugs = list(dict.fromkeys(43            re.findall(r"/immeuble/([a-z0-9-]+)/", html)))[:self.max_buildings]44        seen: set[str] = set()45        for slug in slugs:46            url = f"{BASE}/immeuble/{slug}/"47            try:48                page = self.get(url).text49            except Exception:50                continue51            for lst in self._parse_building(slug, url, page):52                # certaines unités apparaissent en double sur la page53                if lst.external_id in seen:54                    continue55                seen.add(lst.external_id)56                listings.append(lst)57        return listings5859    def _parse_building(self, slug: str, url: str, page: str) -> list[Listing]:60        out: list[Listing] = []61        soup = BeautifulSoup(page, "html.parser")6263        h1 = soup.select_one("h1")64        building = h1.get_text(" ", strip=True) if h1 else slug6566        # secteur : bloc « Localisation » du survol (hero-info)67        sector = ""68        for blk in soup.select(".hero-info span"):69            if "localisation" in blk.get_text(strip=True).lower():70                p = blk.find_next("p")71                if p:72                    sector = p.get_text(" ", strip=True)73                break74        city = infer_city(sector)7576        lat = lng = None77        marker = soup.select_one(".marker[data-lat]")78        if marker:79            try:80                lat = float(marker["data-lat"])81                lng = float(marker["data-lng"])82            except (KeyError, ValueError):83                lat = lng = None8485        # photos de l'immeuble (carrousel du projet)86        photos = [img.get("src", "") for img in87                  soup.select(".slider-container img")88                  if img.get("src", "").startswith("http")][:15]8990        for card in soup.select("div.appartement"):91            try:92                title_el = card.select_one("[data-title]")93                if not title_el:94                    continue95                title = title_el.get_text(" ", strip=True)96                m = UNIT_NO_RE.search(title)97                unit_no = m.group(1) if m else ""9899                type_el = card.select_one(".type p")100                raw_type = type_el.get_text(" ", strip=True) if type_el else \101                    card.get("data-type", "")102                # résidentiel seulement (Studio / Loft / n ½)103                if not TYPE_OK_RE.search(raw_type):104                    continue105106                avail_el = title_el.find_next("p")107                availability = avail_el.get_text(" ", strip=True) \108                    if avail_el else ""109110                sizes = [p.get_text(" ", strip=True)111                         for p in card.select(".taille p")]112                area = None113                for s in sizes:114                    sm = SQFT_RE.search(s)115                    if sm and "$" not in s:116                        area = float(sm.group(1).replace(" ", "")117                                     .replace(" ", ""))118                        break119                price_label = next((re.sub(r"\s+", " ", s) for s in sizes120                                    if "$" in s), "")121122                plan = card.select_one('a[href$=".pdf"]')123                plan_img = card.select_one("img")124                images = list(photos)125                if plan_img and plan_img.get("src", "").startswith("http"):126                    images = [plan_img["src"]] + photos127128                if not unit_no:129                    unit_no = hashlib.sha1(130                        f"{title}|{raw_type}|{area}".encode()).hexdigest()[:8]131                extras = [f"Balcon : {sizes[1]}"] \132                    if len(sizes) >= 3 and "pi²" in sizes[1] else []133                details: dict = {}134                if plan:135                    details["floor_plan"] = plan.get("href", "")136137                out.append(Listing(138                    source=self.source_id,139                    external_id=f"{slug}-{unit_no}",140                    url=url,141                    title=title,142                    sector=sector,143                    city=city,144                    unit_type=normalize_unit_type(raw_type),145                    price=parse_price(price_label),146                    price_label=price_label,147                    availability=availability,148                    area_sqft=area,149                    description=f"Unité {unit_no} — {building} "150                                f"({sector}).",151                    amenities=extras,152                    details=details,153                    images=images,154                    lat=lat,155                    lng=lng,156                ))157            except Exception:158                continue159        return out160