SPB Git

spb/lou-ka Public

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

HTML 99.7%
14.0 KB · 330 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/logisco.py : connecteur Logisco (logisco.com)5#   Site rendu serveur. Crawl : page liste principale (+ pages villes) pour6#   découvrir les pages projets, puis extraction des unités disponibles7#   (cartes .unitCard) sur chaque page projet. Une annonce par unité.8#   Chaque page unité (mise en cache via self.detail, plafond 150 vraies9#   requêtes/sync) apporte : JSON-LD (adresse avec no d'unité + code postal,10#   étage, téléphone, plan de l'unité), superficie/étage/dispo structurés11#   (sommaire .unitHeader) et les 4 sections de commodités (Inclusions,12#   Particularités, Services offerts, Espaces communs).13#   Villes retenues : Québec, Lévis, Saint-Augustin-de-Desmaures14#   (Donnacona et autres villes hors région exclues).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import html as htmllib20import json21import re22from urllib.parse import urljoin2324from bs4 import BeautifulSoup2526from ..schema import Listing, infer_city, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://logisco.com"30LIST_URL = f"{BASE}/fr/appartements-a-louer"3132# villes de la région Québec/Lévis retenues (segment d'URL -> nom de ville)33CITY_MAP = {34    "quebec": "Québec",35    "levis": "Lévis",36    "saint-augustin-de-desmaures": "Saint-Augustin-de-Desmaures",37}3839PROJECT_HREF_RE = re.compile(40    r'href="(https://logisco\.com/fr/appartements-a-louer/([a-z0-9\-]+(?:/[a-z0-9\-]+)*))"'41)42STATIC_IMG_RE = re.compile(43    r'(?:src|data-src|href)="(https://logisco\.com/static/[^"]+\.(?:webp|jpe?g|png))"'44)45EXCLUDE_UNIT_RE = re.compile(r"stationnement|parking|rangement|commercial|garage", re.I)464748def _text(el, sep: str = " ") -> str:49    """Texte d'un élément, robuste aux <template> Vue du site.5051    bs4 classe les chaînes situées sous une balise <template> comme52    TemplateString et les exclut de get_text() par défaut ; or les cartes53    d'unités de Logisco se retrouvent imbriquées sous de telles balises.54    `types=None` force la prise en compte de toutes les chaînes.55    """56    if el is None:57        return ""58    return el.get_text(sep, strip=True, types=None)596061class _DetailBudget(Exception):62    """Plafond de vraies requêtes de pages unité atteint pour cette synchro."""636465class LogiscoConnector(BaseConnector):66    source_id = "logisco"67    request_delay = 0.668    max_projects = 40         # garde-fou : nb max de pages projets crawlées69    max_project_imgs = 15     # nb max de photos projet par annonce70    max_details = 150         # plafond de VRAIES requêtes de pages unité/sync7172    def __init__(self) -> None:73        super().__init__()74        self._detail_fetches = 07576    # -- découverte des pages projets ----------------------------------------77    def _discover_projects(self) -> dict[str, tuple[str, str]]:78        """Retourne {url_projet: (segment_ville, segment_secteur)}."""79        pages = []80        try:81            pages.append(self.get(LIST_URL).text)82        except Exception:83            pass84        for city in CITY_MAP:85            try:86                pages.append(self.get(f"{LIST_URL}/{city}").text)87            except Exception:88                continue8990        projects: dict[str, tuple[str, str]] = {}91        for html in pages:92            for url, path in PROJECT_HREF_RE.findall(html):93                parts = path.split("/")94                city = parts[0]95                if city not in CITY_MAP:96                    continue  # ex. Donnacona et autres villes hors région97                # quebec/levis : ville/secteur/projet (3 segments)98                # saint-augustin : ville/projet (2 segments)99                if city in ("quebec", "levis"):100                    if len(parts) != 3:101                        continue102                    sector = parts[1]103                elif len(parts) == 2:104                    sector = city105                else:106                    continue107                projects.setdefault(url, (city, sector))108        return projects109110    # -- extraction d'une page projet -----------------------------------------111    def _project_images(self, html: str) -> list[str]:112        """Photos du projet (sans logos ni plans d'unités), dédupliquées."""113        imgs = []114        for u in STATIC_IMG_RE.findall(html):115            name = u.rsplit("/", 1)[-1].lower()116            if name.startswith(("logo-", "plan-")) or "logo" in name.split("-")[:1]:117                continue118            imgs.append(u)119        return list(dict.fromkeys(imgs))[: self.max_project_imgs]120121    def fetch(self) -> list[Listing]:122        projects = self._discover_projects()123        listings: dict[str, Listing] = {}124125        for i, (proj_url, (city_seg, sector_seg)) in enumerate(projects.items()):126            if i >= self.max_projects:127                break128            try:129                html = self.get(proj_url).text130            except Exception:131                continue132            soup = BeautifulSoup(html, "html.parser")133134            project_name = ""135            h1 = soup.find("h1")136            if h1:137                # retirer le slogan SEO : « District GC • Appartements … »,138                # « L'AMALGAM - Grands appartements à louer … »139                project_name = re.split(r"\s+[•|–—]\s+|\s+-\s+", _text(h1))[0].strip()140            proj_imgs = self._project_images(html)141            cards = soup.select("li.unitCard, .unitCard")142            n_dispo = len(cards)143144            for card in cards:145                try:146                    unit_gtm_type = (card.get("data-gtm-unit-type") or "").upper()147                    if unit_gtm_type and unit_gtm_type != "APARTMENT":148                        continue149                    link = card.select_one("a.unitCard-link")150                    if not link or not link.get("href"):151                        continue152                    url = urljoin(BASE, link["href"])153154                    ext_id = card.get("data-gtm-unit-id") or ""155                    if not ext_id:156                        ext_id = url.rstrip("/").rsplit("/", 1)[-1]157158                    title = _text(card.select_one(".unitCard-title"))159                    if EXCLUDE_UNIT_RE.search(title):160                        continue161162                    address = _text(card.select_one(".unitCard-address"))163164                    # secteur : « Desjardins, Lévis » -> « Desjardins »165                    sector = ""166                    map_el = card.select_one(".unitCard-address--map")167                    if map_el:168                        sector = _text(map_el).split(",")[0].strip()169                    if not sector:170                        sector = sector_seg.replace("-", " ").title()171172                    sizes = [_text(s) for s in card.select(".unitCard-size")]173                    unit_type = ""174                    for s in sizes:175                        if re.match(r"^\d\s*(?:1/2|½)|^(studio|loft)", s, re.I):176                            unit_type = s177                            break178179                    price_label = _text(card.select_one(".unitCard-price"))180181                    availability = _text(card.select_one(".unitCard-dispo"))182183                    amenities = list(dict.fromkeys(184                        t for t in (_text(sr) for sr in185                                    card.select(".unitCard-services .sr-only"))186                        if t187                    ))188189                    images = []190                    img_el = card.select_one("img.unitCard-image")191                    if img_el and img_el.get("src"):192                        images.append(urljoin(BASE, img_el["src"]))193                    images += [u for u in proj_imgs if u not in images]194195                    extra = ", ".join(s for s in sizes if s != unit_type)196                    desc_bits = []197                    if project_name:198                        desc_bits.append(f"Projet {project_name}")199                    if extra:200                        desc_bits.append(extra)201                    desc_bits.append(f"{n_dispo} unité(s) disponible(s) dans le projet")202203                    default_city = CITY_MAP.get(city_seg, "Québec")204                    full_title = (f"{project_name}{title}"205                                  if project_name and title else title or project_name)206207                    lst = Listing(208                        source=self.source_id,209                        external_id=str(ext_id),210                        url=url,211                        title=full_title or f"Unité {ext_id}",212                        address=address,213                        sector=sector,214                        city=infer_city(sector, default=default_city),215                        unit_type=normalize_unit_type(unit_type),216                        price=parse_price(price_label),217                        price_label=price_label,218                        availability=availability,219                        description=" — ".join(desc_bits)[:600],220                        amenities=amenities,221                        images=images,222                    )223224                    # page unité (avec cache BD) : adresse complète, superficie,225                    # étage, téléphone, plan, sections de commodités226                    # préfixe v2 = version de l'extraction (invalide le cache)227                    key = "v2:" + hashlib.sha1(" | ".join(228                        [title, address, price_label, availability]229                        + sizes + amenities).encode("utf-8")).hexdigest()230                    try:231                        payload = self.detail(232                            str(ext_id), key, lambda u=url: self._fetch_unit(u))233                    except _DetailBudget:234                        payload = {}      # plafond atteint : complété au prochain sync235                    except Exception:236                        payload = {}237                    self._apply_unit(lst, payload)238239                    listings[ext_id] = lst240                except Exception:241                    continue  # ne jamais planter sur une carte malformée242243        return list(listings.values())244245    # -- page unité ------------------------------------------------------------246    def _fetch_unit(self, url: str) -> dict:247        if self._detail_fetches >= self.max_details:248            raise _DetailBudget(url)249        self._detail_fetches += 1250        soup = BeautifulSoup(self.get(url).text, "html.parser")251        out: dict = {}252253        # JSON-LD « apartment » : adresse (no d'unité + code postal), étage,254        # téléphone du bureau de location, plan de l'unité255        for sc in soup.find_all("script", type="application/ld+json"):256            try:257                data = json.loads(sc.string or "")258            except Exception:259                continue260            if not (isinstance(data, dict)261                    and str(data.get("@type", "")).lower() == "apartment"):262                continue263            addr = data.get("address") or {}264            # certaines valeurs JSON-LD contiennent des entités HTML (&#039;)265            street = htmllib.unescape((addr.get("streetAddress") or "")).strip()266            if street:267                bits = [street,268                        htmllib.unescape((addr.get("addressLocality") or "")).strip(),269                        (addr.get("postalCode") or "").strip()]270                out["address"] = ", ".join(b for b in bits if b)271            tel = re.sub(r"\D", "", str(data.get("telephone") or ""))272            tel = tel[1:] if len(tel) == 11 and tel.startswith("1") else tel273            if len(tel) == 10:274                out["phone"] = f"{tel[:3]}-{tel[3:6]}-{tel[6:]}"275            fl = str(data.get("floorLevel") or "").strip()276            if fl.isdigit():277                out["floor"] = int(fl)278            img = data.get("image") or ""279            if isinstance(img, str) and img.startswith("http"):280                out["plan"] = img281            break282283        # sommaire de l'unité : superficie, étage, disponibilité (structurés)284        for info in soup.select(".unitHeader-info"):285            label = _text(info.select_one(".unitHeader-infoLabel")).lower()286            value = _text(info.select_one(".unitHeader-infoValue"))287            if "superficie" in label:288                m = re.search(r"([\d\s ]+)\s*pi", value)289                if m:290                    try:291                        v = float(re.sub(r"[\s ]", "", m.group(1)))292                        if 80 <= v <= 20000:293                            out["area_sqft"] = v294                    except ValueError:295                        pass296            elif "tage" in label and re.fullmatch(r"\d{1,2}", value):297                out["floor"] = int(value)298            elif "disponibilit" in label and value:299                out["availability"] = value300301        # sections : Inclusions / Particularités / Services offerts /302        # Espaces communs (items texte, fidèles — « Rangement supplémentaire ($) »)303        amen: list[str] = []304        for item in soup.select(".projectServices-listItem"):305            t = _text(item)306            if t:307                amen.append(t)308        out["amenities"] = list(dict.fromkeys(amen))[:40]309        return out310311    def _apply_unit(self, lst: Listing, payload: dict) -> None:312        if not payload:313            return314        if payload.get("address"):315            lst.address = payload["address"]316        if payload.get("area_sqft"):317            lst.area_sqft = payload["area_sqft"]318        if payload.get("availability") and not lst.availability:319            lst.availability = payload["availability"]320        if payload.get("floor") is not None:321            lst.details["floor"] = payload["floor"]322        if payload.get("phone"):323            lst.details.setdefault("contact", {})["phone"] = payload["phone"]324        for a in payload.get("amenities", []):325            if a not in lst.amenities:326                lst.amenities.append(a)327        plan = payload.get("plan")328        if plan and plan not in lst.images:329            lst.images.append(plan)330