# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/appartements_rimouski.py : connecteur Appartements Rimouski # (Immeubles DTM — appartementsrimouski.com, Rimouski et Le Bic, ~100+ # logements). WordPress multisite + thème Pro (Cornerstone) + plugin Estatik # pour l'annuaire d'immeubles ; la page « Choisir mon logement » # /choisir-mon-logement liste les UNITÉS actuellement en location : cartes # rendues serveur (titre, prix « 725$/mois », disponibilité « Disponible # maintenant »/« 1 septembre 2026 », adresse complète, chambres/SDB, badge # Résidentiel/Commercial, photo). Fiche unité /logements/ (via cache # BD) : description complète, quartier (« Quartier : Rimouski-Est »), # galerie et GPS via le JSON-LD schema.org/House (lat/lng inversés à la # source : corrigés). robots.txt : Disallow /pro/* seulement, sitemap XML. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as htmllib import json import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.appartementsrimouski.com" LIST_URL = f"{BASE}/choisir-mon-logement" _PRICE_RE = re.compile(r"\d[\d\s,.]*\$\s*/\s*mois", re.I) # type explicite dans le titre (« 3 1/2 », « Studio », « Chambre », « Loft ») _HALF_RE = re.compile(r"(\d+)\s*(?:½|1/2)") def _unit_type_from_title(title: str) -> str: """Type d'unité seulement s'il est explicite dans le titre (rien d'inventé).""" m = _HALF_RE.search(title) if m: return normalize_unit_type(m.group(0)) low = title.lower() if "studio" in low: return "Studio" if "loft" in low: return "Loft" if re.search(r"\bchambre\b", low): return "Chambre" return "" def _city_from_address(address: str) -> str: """« 94 Rue Notre Dame Est, Rimouski, QC G5L 1Z7, Canada » -> Rimouski.""" parts = [p.strip() for p in address.split(",") if p.strip()] for p in parts[1:]: if not re.match(r"^(QC|Qu[ée]bec|Canada|G\d[A-Z])", p, re.I): return re.sub(r"\s+(QC|Qu[ée]bec).*$", "", p, flags=re.I).strip() return "Rimouski" # parc concentré à Rimouski/Le Bic class AppartementsRimouskiConnector(BaseConnector): source_id = "appartements_rimouski" request_delay = 0.6 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") self._fetched = 0 listings: dict[str, Listing] = {} # une carte = un lien /logements/ englobant un
for a in soup.select('a[href*="/logements/"]'): if not a.select_one("article"): continue try: self._parse_card(a, listings) except Exception: continue return list(listings.values()) # -- carte (page « Choisir mon logement ») ----------------------------------- def _parse_card(self, a, listings: dict[str, Listing]) -> None: url = a["href"] m = re.search(r"/logements/([^/?#]+)", url) if not m: return ext_id = m.group(1) if ext_id in listings: return art = a.select_one("article") title_el = art.select_one("h2") title = title_el.get_text(strip=True) if title_el else "" # badge de catégorie (Résidentiel / Commercial) : on écarte le commercial badges = [b.get_text(strip=True) for b in art.select(".x-anchor-text-primary")] if any(re.search(r"commercial|bureau|local", b, re.I) for b in badges): return # « avec stationnement » est une commodité : n'exclure que les annonces # DE stationnement/garage (titre commençant par le mot) if re.search(r"commercial|bureau\b|^\s*(stationnement|garage|entreposage|entrep[oô]t)", title, re.I): return # bandeau de la vignette = disponibilité (« Disponible maintenant », # « 1 septembre 2026 », « Courte durée disponible ») avail_el = a.select_one("figure .x-text.x-content") availability = avail_el.get_text(strip=True) if avail_el else "" # prix (« 725$/mois ») et adresse : blocs texte de l'article price_label, address = "", "" for div in art.select(".x-text.x-content"): txt = div.get_text(" ", strip=True) if not price_label and _PRICE_RE.search(txt): price_label = txt elif not address and re.search(r",\s*(QC|Qu[ée]bec)\b", txt): address = txt # chambres / salles de bain : pastilles structurées de la carte amenities: list[str] = [] for p in art.select("p.x-text-content-text-primary"): txt = p.get_text(" ", strip=True) if re.search(r"chambre|sdb", txt, re.I): amenities.append(txt) img = a.select_one("figure img[src]") images = [img["src"]] if img and img["src"].startswith("http") else [] lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=htmllib.unescape(title), address=address, city=_city_from_address(address), unit_type=_unit_type_from_title(title), price=parse_price(price_label), price_label=price_label, availability=availability, amenities=amenities, images=images, ) key = hashlib.sha1( f"{title}|{price_label}|{availability}|{address}" .encode("utf-8")).hexdigest() try: payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u)) self._apply_detail(lst, payload) except Exception: pass listings[ext_id] = lst # -- fiche unité (/logements/) ------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Description, quartier, galerie et GPS (JSON-LD schema.org/House).""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # description complète (contenu de l'article, thème Pro) content = soup.select_one(".x-the-content") if content: txt = content.get_text("\n", strip=True) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] # « Quartier : Rimouski-Est » (bloc méta de la fiche) m = re.search(r"Quartier\s*:\s*([^\n|]{2,60})", soup.get_text("\n", strip=True)) if m: out["sector"] = m.group(1).strip() # JSON-LD schema.org/House : galerie + GPS (lat/lng inversés à la source) for script in soup.find_all("script", type="application/ld+json"): try: data = json.loads(script.get_text()) except Exception: continue if not isinstance(data, dict) or data.get("@type") != "House": continue imgs = data.get("image") or [] if isinstance(imgs, list): out["images"] = [u for u in imgs if isinstance(u, str) and u.startswith("http")][:30] geo = data.get("geo") or {} try: lat, lng = float(geo.get("latitude")), float(geo.get("longitude")) except (TypeError, ValueError): continue # le plugin publie latitude/longitude permutés : on remet à l'endroit if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0: out["lat"], out["lng"] = lat, lng elif 44.5 <= lng <= 63.0 and -80.0 <= lat <= -56.0: out["lat"], out["lng"] = lng, lat return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return if d.get("description"): lst.description = d["description"] if d.get("sector"): lst.sector = d["sector"] if d.get("images") and len(d["images"]) > len(lst.images): lst.images = d["images"] if d.get("lat") is not None and d.get("lng") is not None: lst.lat, lst.lng = d["lat"], d["lng"]