spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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