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/logispro.py : connecteur LogisPro Rimouski5# (logisprorimouski.com — Rimouski, parc de 110 logements, 30+ ans). WordPress6# + Elementor + WP Rocket, tout rendu serveur. La page /nos-logements/7# (« Logements disponibles ») liste les unités en location : cartes Elementor8# (adresse, type, photo, bouton « Voir la fiche » vers une page WP par9# unité). La fiche unité /<slug>/ fait foi : h1 = type (« 4 1/2 pièces »),10# h4 = adresse, widget statut (« Disponible » / « Non disponible » -> exclu),11# widget prix nu (« 850.00 », parfois « 600.00$ 655.00$ »), description en12# listes (inclusions, AUCUN ANIMAL, « Disponible Juillet 2026 ») + section13# « À proximité », galerie complète. Fiches via cache BD. Quand aucun14# logement n'est libre la page n'a plus de cartes -> [] (marché plein =15# normal). robots.txt Yoast ouvert, sitemap XML.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://logisprorimouski.com"27LIST_URL = f"{BASE}/nos-logements/"2829# widget prix : nombres nus ou avec « $ » (« 850.00 », « 600.00$ 655.00$ »)30_PRICE_WIDGET_RE = re.compile(r"^[\d\s.,$]{3,30}$")31_BARE_PRICE_RE = re.compile(r"^\d{3,4}(?:\.\d{2})?$")32# date plus précise dans la description (« Disponible Juillet 2026 »)33_AVAIL_RE = re.compile(34 r"Disponible\s+(?:d[èe]s\s+)?(?:maintenant|janvier|f[ée]vrier|mars|avril|"35 r"mai|juin|juillet|ao[ûu]t|septembre|octobre|novembre|d[ée]cembre)"36 r"(?:\s+\d{4})?", re.I)3738# pages du site qui ne sont pas des fiches d'unité39_NON_UNITS = {"nos-logements", "nos-proprietes", "nous-joindre",40 "le-william", "le-st-laurent", "le-st-jean"}414243class LogisproConnector(BaseConnector):44 source_id = "logispro"45 request_delay = 0.646 max_details = 20 # garde-fou fiches détail (vraies requêtes par sync)4748 def fetch(self) -> list[Listing]:49 html = self.get(LIST_URL).text50 soup = BeautifulSoup(html, "html.parser")5152 self._fetched = 053 listings: dict[str, Listing] = {}54 # une carte = bouton « Voir la fiche » vers la page de l'unité55 for btn in soup.select("a.elementor-button[href]"):56 try:57 self._parse_card(btn, listings)58 except Exception:59 continue60 return list(listings.values())6162 # -- carte (page « Logements disponibles ») -----------------------------------63 def _parse_card(self, btn, listings: dict[str, Listing]) -> None:64 url = btn["href"]65 m = re.search(r"^https://logisprorimouski\.com/([^/?#]+)/?$", url)66 if not m:67 return68 ext_id = m.group(1)69 if ext_id in _NON_UNITS or ext_id in listings:70 return7172 # photo de la carte (la fiche fournit la galerie complète)73 card = btn74 img = None75 for _ in range(10):76 card = card.parent77 if card is None:78 break79 img = card.select_one('img[src*="/uploads/"]')80 if img is not None:81 break82 images = ([img["src"]] if img and img.get("src", "").startswith("http")83 else [])8485 # la fiche unité fait foi (type h1, adresse h4, statut, prix, photos).86 # Pas de cache BD ici : la liste ne porte ni prix ni statut (aucun87 # signal de fraîcheur pour invalider un cache) et le parc affiché est88 # minuscule (~6 fiches par sync, plafonnées par max_details).89 try:90 payload = self._fetch_detail(url)91 except Exception:92 payload = {}9394 # unité retirée ou marquée « Non disponible » sur sa fiche : on saute95 if re.match(r"^(non\s+disponible|lou[ée])", payload.get("availability", ""),96 re.I):97 return9899 unit_label = payload.get("unit_label", "")100 address = payload.get("address", "")101 lst = Listing(102 source=self.source_id,103 external_id=ext_id,104 url=url,105 title=f"{unit_label} — {address}".strip(" —"),106 address=address,107 city="Rimouski", # tout le parc est à Rimouski108 unit_type=normalize_unit_type(unit_label),109 price=payload.get("price"),110 price_label=payload.get("price_label", ""),111 availability=payload.get("availability", ""),112 description=payload.get("description", ""),113 images=payload.get("images") or images,114 )115 listings[ext_id] = lst116117 # -- fiche unité (/<slug>/) -----------------------------------------------------118 def _fetch_detail(self, url: str) -> dict:119 """Type, adresse, statut, prix (widget nu), description, galerie."""120 if self._fetched >= self.max_details:121 raise RuntimeError("budget de fiches détail atteint")122 self._fetched += 1123 html = self.get(url).text124 soup = BeautifulSoup(html, "html.parser")125 out: dict = {}126127 h1 = soup.select_one("h1.elementor-heading-title")128 if h1:129 out["unit_label"] = h1.get_text(" ", strip=True)130 h4 = soup.select_one("h4.elementor-heading-title")131 if h4:132 out["address"] = h4.get_text(" ", strip=True)133134 # widgets texte Elementor : statut et prix (« 850.00 », « 600.00$ … »)135 for w in soup.select(".elementor-widget-text-editor .elementor-widget-container"):136 t = re.sub(r"\s+", " ", w.get_text(" ", strip=True))137 if "price_label" not in out and _PRICE_WIDGET_RE.match(t) \138 and re.search(r"\d{3}", t):139 out["price_label"] = t140 if "$" in t:141 out["price"] = parse_price(t)142 elif _BARE_PRICE_RE.match(t):143 out["price"] = float(t)144 elif re.match(r"^(non\s+)?disponible\b.{0,40}$|^lou[ée]\b.{0,20}$",145 t, re.I) and "availability" not in out:146 out["availability"] = t147148 # description : sections « Description » et « À proximité » (listes)149 parts: list[str] = []150 for h in soup.select("h5.elementor-heading-title"):151 label = h.get_text(strip=True)152 if not re.match(r"Description|À proximité", label, re.I):153 continue154 section = h.find_parent(class_="e-con")155 if section:156 txt = section.get_text("\n", strip=True)157 parts.append(re.sub(r"[ \t]+", " ", txt))158 if parts:159 desc = "\n".join(parts)160 out["description"] = desc[:1500]161 # date plus précise éventuelle (« Disponible Juillet 2026 »)162 m = _AVAIL_RE.search(desc)163 if m and len(m.group(0)) > len(out.get("availability", "")):164 out["availability"] = re.sub(r"\s+", " ", m.group(0))165166 out["images"] = [167 im["src"] for im in soup.select('img[src*="/uploads/"]')168 if im.get("src", "").startswith("http")169 and not im["src"].endswith(".svg")][:25]170 return out171