SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.7 KB · 214 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/appartements_rimouski.py : connecteur Appartements Rimouski5#   (Immeubles DTM — appartementsrimouski.com, Rimouski et Le Bic, ~100+6#   logements). WordPress multisite + thème Pro (Cornerstone) + plugin Estatik7#   pour l'annuaire d'immeubles ; la page « Choisir mon logement »8#   /choisir-mon-logement liste les UNITÉS actuellement en location : cartes9#   rendues serveur (titre, prix « 725$/mois », disponibilité « Disponible10#   maintenant »/« 1 septembre 2026 », adresse complète, chambres/SDB, badge11#   Résidentiel/Commercial, photo). Fiche unité /logements/<slug> (via cache12#   BD) : description complète, quartier (« Quartier : Rimouski-Est »),13#   galerie et GPS via le JSON-LD schema.org/House (lat/lng inversés à la14#   source : corrigés). robots.txt : Disallow /pro/* seulement, sitemap XML.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import html as htmllib20import json21import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type, parse_price26from .base import BaseConnector2728BASE = "https://www.appartementsrimouski.com"29LIST_URL = f"{BASE}/choisir-mon-logement"3031_PRICE_RE = re.compile(r"\d[\d\s,.]*\$\s*/\s*mois", re.I)32# type explicite dans le titre (« 3 1/2 », « Studio », « Chambre », « Loft »)33_HALF_RE = re.compile(r"(\d+)\s*(?:½|1/2)")343536def _unit_type_from_title(title: str) -> str:37    """Type d'unité seulement s'il est explicite dans le titre (rien d'inventé)."""38    m = _HALF_RE.search(title)39    if m:40        return normalize_unit_type(m.group(0))41    low = title.lower()42    if "studio" in low:43        return "Studio"44    if "loft" in low:45        return "Loft"46    if re.search(r"\bchambre\b", low):47        return "Chambre"48    return ""495051def _city_from_address(address: str) -> str:52    """« 94 Rue Notre Dame Est, Rimouski, QC G5L 1Z7, Canada » -> Rimouski."""53    parts = [p.strip() for p in address.split(",") if p.strip()]54    for p in parts[1:]:55        if not re.match(r"^(QC|Qu[ée]bec|Canada|G\d[A-Z])", p, re.I):56            return re.sub(r"\s+(QC|Qu[ée]bec).*$", "", p, flags=re.I).strip()57    return "Rimouski"                      # parc concentré à Rimouski/Le Bic585960class AppartementsRimouskiConnector(BaseConnector):61    source_id = "appartements_rimouski"62    request_delay = 0.663    max_details = 30     # garde-fou fiches détail (vraies requêtes par sync)6465    def fetch(self) -> list[Listing]:66        html = self.get(LIST_URL).text67        soup = BeautifulSoup(html, "html.parser")6869        self._fetched = 070        listings: dict[str, Listing] = {}71        # une carte = un lien /logements/<slug> englobant un <article>72        for a in soup.select('a[href*="/logements/"]'):73            if not a.select_one("article"):74                continue75            try:76                self._parse_card(a, listings)77            except Exception:78                continue79        return list(listings.values())8081    # -- carte (page « Choisir mon logement ») -----------------------------------82    def _parse_card(self, a, listings: dict[str, Listing]) -> None:83        url = a["href"]84        m = re.search(r"/logements/([^/?#]+)", url)85        if not m:86            return87        ext_id = m.group(1)88        if ext_id in listings:89            return9091        art = a.select_one("article")92        title_el = art.select_one("h2")93        title = title_el.get_text(strip=True) if title_el else ""9495        # badge de catégorie (Résidentiel / Commercial) : on écarte le commercial96        badges = [b.get_text(strip=True)97                  for b in art.select(".x-anchor-text-primary")]98        if any(re.search(r"commercial|bureau|local", b, re.I) for b in badges):99            return100        # « avec stationnement » est une commodité : n'exclure que les annonces101        # DE stationnement/garage (titre commençant par le mot)102        if re.search(r"commercial|bureau\b|^\s*(stationnement|garage|entreposage|entrep[oô]t)",103                     title, re.I):104            return105106        # bandeau de la vignette = disponibilité (« Disponible maintenant »,107        # « 1 septembre 2026 », « Courte durée disponible »)108        avail_el = a.select_one("figure .x-text.x-content")109        availability = avail_el.get_text(strip=True) if avail_el else ""110111        # prix (« 725$/mois ») et adresse : blocs texte de l'article112        price_label, address = "", ""113        for div in art.select(".x-text.x-content"):114            txt = div.get_text(" ", strip=True)115            if not price_label and _PRICE_RE.search(txt):116                price_label = txt117            elif not address and re.search(r",\s*(QC|Qu[ée]bec)\b", txt):118                address = txt119120        # chambres / salles de bain : pastilles structurées de la carte121        amenities: list[str] = []122        for p in art.select("p.x-text-content-text-primary"):123            txt = p.get_text(" ", strip=True)124            if re.search(r"chambre|sdb", txt, re.I):125                amenities.append(txt)126127        img = a.select_one("figure img[src]")128        images = [img["src"]] if img and img["src"].startswith("http") else []129130        lst = Listing(131            source=self.source_id,132            external_id=ext_id,133            url=url,134            title=htmllib.unescape(title),135            address=address,136            city=_city_from_address(address),137            unit_type=_unit_type_from_title(title),138            price=parse_price(price_label),139            price_label=price_label,140            availability=availability,141            amenities=amenities,142            images=images,143        )144145        key = hashlib.sha1(146            f"{title}|{price_label}|{availability}|{address}"147            .encode("utf-8")).hexdigest()148        try:149            payload = self.detail(ext_id, key,150                                  lambda u=url: self._fetch_detail(u))151            self._apply_detail(lst, payload)152        except Exception:153            pass154        listings[ext_id] = lst155156    # -- fiche unité (/logements/<slug>) -------------------------------------------157    def _fetch_detail(self, url: str) -> dict:158        """Description, quartier, galerie et GPS (JSON-LD schema.org/House)."""159        if self._fetched >= self.max_details:160            raise RuntimeError("budget de fiches détail atteint")161        self._fetched += 1162        html = self.get(url).text163        soup = BeautifulSoup(html, "html.parser")164        out: dict = {}165166        # description complète (contenu de l'article, thème Pro)167        content = soup.select_one(".x-the-content")168        if content:169            txt = content.get_text("\n", strip=True)170            out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]171172        # « Quartier : Rimouski-Est » (bloc méta de la fiche)173        m = re.search(r"Quartier\s*:\s*([^\n|]{2,60})",174                      soup.get_text("\n", strip=True))175        if m:176            out["sector"] = m.group(1).strip()177178        # JSON-LD schema.org/House : galerie + GPS (lat/lng inversés à la source)179        for script in soup.find_all("script", type="application/ld+json"):180            try:181                data = json.loads(script.get_text())182            except Exception:183                continue184            if not isinstance(data, dict) or data.get("@type") != "House":185                continue186            imgs = data.get("image") or []187            if isinstance(imgs, list):188                out["images"] = [u for u in imgs189                                 if isinstance(u, str) and u.startswith("http")][:30]190            geo = data.get("geo") or {}191            try:192                lat, lng = float(geo.get("latitude")), float(geo.get("longitude"))193            except (TypeError, ValueError):194                continue195            # le plugin publie latitude/longitude permutés : on remet à l'endroit196            if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:197                out["lat"], out["lng"] = lat, lng198            elif 44.5 <= lng <= 63.0 and -80.0 <= lat <= -56.0:199                out["lat"], out["lng"] = lng, lat200        return out201202    def _apply_detail(self, lst: Listing, d: dict) -> None:203        """Reporte le payload (frais ou en cache) sur l'annonce."""204        if not d:205            return206        if d.get("description"):207            lst.description = d["description"]208        if d.get("sector"):209            lst.sector = d["sector"]210        if d.get("images") and len(d["images"]) > len(lst.images):211            lst.images = d["images"]212        if d.get("lat") is not None and d.get("lng") is not None:213            lst.lat, lst.lng = d["lat"], d["lng"]214