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%
10.4 KB · 248 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/logisquebec.py : LogisQuébec (logisquebec.com) — portail locatif5#   québécois (Réglisse Média). ~9 800 fiches location, province entière.6#   Découverte par les sitemaps (99,7 % du parc vérifié vs compteurs des pages7#   liste) : URLs de fiche « {type}-a-louer-{ville}-l###### ». Chaque fiche8#   (SSR, Cloudflare tolérant) expose un JSON-LD complet : SingleFamilyResidence9#   (adresse + code postal + GPS + téléphone) et Product (nom, description,10#   offers.price, sku = id). Galerie lightGallery, disponibilité et11#   caractéristiques actives (span .caracteristique-texte) dans le HTML.12#   Fiche expirée -> HTTP 410 Gone (retrait immédiat). Cache détail avec TTL13#   (les prix changent) via _detailutil.TtlDetailCache.14#   Superficie : la fiche n'a AUCUN champ structuré (ni JSON-LD floorSize, ni15#   caractéristique dédiée — vérifié 2026-08-18) ; seule l'extraction texte de16#   finalize() (description/commodités) s'applique, ~26 % du parc en donne une.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import os21import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type26from .base import BaseConnector27from . import _detailutil as du2829BASE = "https://www.logisquebec.com"3031# type d'URL -> (type d'unité par défaut, court terme ?) ; les slugs32# commerciaux (local-commercial, bureau, entrepot-industriel…) sont exclus33TYPES = {34    "appartement": ("", False),35    "condo": ("", False),36    "maison": ("Maison", False),37    "studio": ("Studio", False),38    "loft": ("Loft", False),39    "chambre-et-colocation": ("Chambre", False),40    "chalet": ("Chalet", False),41    "appartement-condo-court-terme": ("", True),42    "maison-court-terme": ("Maison", True),43    "chambre-court-terme": ("Chambre", True),44}4546_FICHE_RE = re.compile(47    r"^https://www\.logisquebec\.com/(" + "|".join(map(re.escape, TYPES)) +48    r")-a-louer-([a-z0-9_-]+)-l(\d+)$")49_LOC_RE = re.compile(r"<loc>(.*?)</loc>")50_PHOTO_RE = re.compile(r"https://i\.logisquebec\.com/i-a-louer/\d+/\d+/[^'\"]+")51_DISPO_RE = re.compile(r"Disponible à partir de\s*:?\s*\|\s*([^|]{1,60})")5253# Dimensionnement du budget détail : parc ~6 000 fiches, TTL 7 j, watch ~1054# passes/jour -> ~90 rafraîchissements/passe suffisent à tenir le TTL. 40055# laisse 4x de marge (poussées de nouvelles fiches) en ramenant le cycle de56# ~19 min à ~5 min. Les fiches JAMAIS VUES sont servies en premier (voir57# fetch) : aucune perte de volume, le parc se complète au même rythme.58DETAIL_LIMIT = int(os.environ.get("LOUKA_LOGISQUEBEC_DETAIL_LIMIT", "400"))59TTL_DAYS = float(os.environ.get("LOUKA_LOGISQUEBEC_TTL_DAYS", "7"))60MAX_FICHES = int(os.environ.get("LOUKA_LOGISQUEBEC_MAX", "0"))   # 0 = tout616263def _parse_fiche(html: str) -> dict:64    """Extrait le payload complet d'une page fiche LogisQuébec."""65    out: dict = {}66    for node in du.ld_nodes(html):67        t = node.get("@type") or ""68        if t == "SingleFamilyResidence":69            addr = node.get("address") or {}70            out["address"] = (addr.get("streetAddress") or "").strip()71            out["locality"] = (addr.get("addressLocality") or "").strip()72            out["postal_code"] = (addr.get("postalCode") or "").strip()73            geo = node.get("geo") or {}74            try:75                out["lat"] = float(geo.get("latitude"))76                out["lng"] = float(geo.get("longitude"))77            except (TypeError, ValueError):78                pass79            if node.get("telephone"):80                out["telephone"] = str(node["telephone"]).strip()81            rooms = node.get("numberOfRooms")82            if isinstance(rooms, (int, float)) and rooms > 0:83                out["rooms"] = int(rooms)84        elif t == "Product":85            out["title"] = (node.get("name") or "").strip()86            out["description"] = (node.get("description") or "").strip()[:6000]87            offers = node.get("offers") or {}88            try:89                out["price"] = float(str(offers.get("price")).replace(",", "."))90            except (TypeError, ValueError):91                pass92            if node.get("sku"):93                out["sku"] = str(node["sku"])9495    photos, seen = [], set()96    for u in _PHOTO_RE.findall(html):97        if "/thumbnail/" in u or u in seen:98            continue99        seen.add(u)100        photos.append(u)101    if photos:102        out["images"] = photos103104    # « Disponible à partir de : Immédiatement / 1er juillet… » (texte aplati)105    flat = du.flatten(html)106    m = _DISPO_RE.search(flat)107    if m:108        out["availability"] = m.group(1).strip(" .")109110    # caractéristiques ACTIVES = items possédant un span .caracteristique-texte111    # (les items sans texte sont la légende inactive des icônes)112    soup = BeautifulSoup(html, "html.parser")113    amenities = []114    for it in soup.select(".caracteristique-item"):115        if it.select_one(".caracteristique-texte") is None:116            continue117        label = it.get_text(" ", strip=True)118        label = re.sub(r"\s+", " ", label)119        # le titre est dupliqué dans le texte : « 2 chambres 2 chambres »120        half = len(label) // 2121        if len(label) % 2 == 1 and label[:half] == label[half + 1:]:122            label = label[:half]123        if label and label not in amenities:124            amenities.append(label)125    if amenities:126        out["amenities"] = amenities127128    vt = du.virtual_tour(html)      # lien Matterport/iGUIDE… si la fiche en a un129    if vt:130        out["virtual_tour"] = vt131    return out132133134class LogisQuebecConnector(BaseConnector):135    source_id = "logisquebec"136    request_delay = 0.5137138    # -- découverte ------------------------------------------------------------139    def _fiche_urls(self) -> list[tuple[str, str, str, str]]:140        """(url, type_slug, ville_slug, id) des fiches location résidentielles."""141        index = self.get(f"{BASE}/sitemap.xml").text142        subs = [u for u in _LOC_RE.findall(index) if "/sitemap/" in u]143        out, seen = [], set()144        for sub in subs:145            try:146                xml = self.get(sub).text147            except Exception:148                continue149            for u in _LOC_RE.findall(xml):150                m = _FICHE_RE.match(u)151                if m and m.group(3) not in seen:152                    seen.add(m.group(3))153                    out.append((u, m.group(1), m.group(2), m.group(3)))154        return out155156    # -- construction ----------------------------------------------------------157    def _to_listing(self, url: str, type_slug: str, payload: dict) -> Listing | None:158        if not payload or payload.get("gone"):159            return None160        eid = payload.get("sku") or url.rsplit("-l", 1)[-1]161        default_type, court_terme = TYPES[type_slug]162163        title = payload.get("title") or ""164        unit_type = normalize_unit_type(title)165        if not re.match(r"^\d½|^6½\+$", unit_type or ""):166            unit_type = ""167        if not unit_type and payload.get("rooms"):168            n = payload["rooms"]169            unit_type = "6½+" if n >= 6 else f"{n}½"170        if not unit_type:                # « 2 chambres » des caractéristiques171            for a in payload.get("amenities") or []:172                if re.match(r"^\d+ chambres?$", a):173                    unit_type = normalize_unit_type(a)174                    break175        if not unit_type:176            unit_type = default_type177178        locality = payload.get("locality") or ""179        m = re.match(r"^(.*?)\s*\((.+)\)\s*$", locality)180        sector, city = (m.group(1), m.group(2)) if m else ("", locality)181182        price = payload.get("price")183        # clés canoniques (mêmes noms que normalize.extract_details : le184        # merge_details de finalize() fusionne contact{} clé par clé)185        details: dict = {}186        if payload.get("postal_code"):187            details["postal_code"] = payload["postal_code"]188        if payload.get("telephone"):189            details["contact"] = {"phone": payload["telephone"]}190        if payload.get("rooms"):191            details["rooms"] = payload["rooms"]   # numberOfRooms (JSON-LD)192        if court_terme:193            details["Location court terme"] = "oui"194        if payload.get("virtual_tour"):195            details["virtual_tour"] = payload["virtual_tour"]196197        amenities = payload.get("amenities") or []198        lst = Listing(199            source=self.source_id,200            external_id=str(eid),201            url=url,202            title=title,203            address=payload.get("address") or "",204            sector=sector,205            city=city,206            unit_type=unit_type,207            price=price,208            price_label=(f"{price:,.0f} $/mois".replace(",", " ") if price else ""),209            availability=payload.get("availability") or "",210            description=payload.get("description") or "",211            amenities=amenities,212            details=details,213            images=payload.get("images") or [],214            lat=payload.get("lat"),215            lng=payload.get("lng"),216        )217        low = [a.lower() for a in amenities]218        if any("animaux accept" in a for a in low):219            lst.pets = "oui"220        if "meublé" in low:221            lst.furnished = True222        return lst223224    def fetch(self) -> list[Listing]:225        fiches = self._fiche_urls()226        if MAX_FICHES:227            fiches = fiches[:MAX_FICHES]228        cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS)229        # priorité du budget aux fiches jamais visitées (tri stable) : les230        # nouveautés sont toujours captées au premier passage, le reste du231        # budget sert au rafraîchissement TTL232        seen = {r["external_id"] for r in cache.con.execute(233            "SELECT external_id FROM detail_cache WHERE source=?",234            (self.source_id,))}235        fiches.sort(key=lambda f: f[3] in seen)236        out: list[Listing] = []237        try:238            for url, type_slug, _ville, eid in fiches:239                payload = cache.get(eid, url, _parse_fiche)240                if payload is None:      # jamais visitée + budget épuisé :241                    continue             # captée à une prochaine synchronisation242                lst = self._to_listing(url, type_slug, payload)243                if lst is not None:244                    out.append(lst)245        finally:246            cache.close()247        return out248