# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/logispro.py : connecteur LogisPro Rimouski # (logisprorimouski.com — Rimouski, parc de 110 logements, 30+ ans). WordPress # + Elementor + WP Rocket, tout rendu serveur. La page /nos-logements/ # (« Logements disponibles ») liste les unités en location : cartes Elementor # (adresse, type, photo, bouton « Voir la fiche » vers une page WP par # unité). La fiche unité // fait foi : h1 = type (« 4 1/2 pièces »), # h4 = adresse, widget statut (« Disponible » / « Non disponible » -> exclu), # widget prix nu (« 850.00 », parfois « 600.00$ 655.00$ »), description en # listes (inclusions, AUCUN ANIMAL, « Disponible Juillet 2026 ») + section # « À proximité », galerie complète. Fiches via cache BD. Quand aucun # logement n'est libre la page n'a plus de cartes -> [] (marché plein = # normal). robots.txt Yoast ouvert, sitemap XML. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://logisprorimouski.com" LIST_URL = f"{BASE}/nos-logements/" # widget prix : nombres nus ou avec « $ » (« 850.00 », « 600.00$ 655.00$ ») _PRICE_WIDGET_RE = re.compile(r"^[\d\s.,$]{3,30}$") _BARE_PRICE_RE = re.compile(r"^\d{3,4}(?:\.\d{2})?$") # date plus précise dans la description (« Disponible Juillet 2026 ») _AVAIL_RE = re.compile( r"Disponible\s+(?:d[èe]s\s+)?(?:maintenant|janvier|f[ée]vrier|mars|avril|" r"mai|juin|juillet|ao[ûu]t|septembre|octobre|novembre|d[ée]cembre)" r"(?:\s+\d{4})?", re.I) # pages du site qui ne sont pas des fiches d'unité _NON_UNITS = {"nos-logements", "nos-proprietes", "nous-joindre", "le-william", "le-st-laurent", "le-st-jean"} class LogisproConnector(BaseConnector): source_id = "logispro" request_delay = 0.6 max_details = 20 # 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 = bouton « Voir la fiche » vers la page de l'unité for btn in soup.select("a.elementor-button[href]"): try: self._parse_card(btn, listings) except Exception: continue return list(listings.values()) # -- carte (page « Logements disponibles ») ----------------------------------- def _parse_card(self, btn, listings: dict[str, Listing]) -> None: url = btn["href"] m = re.search(r"^https://logisprorimouski\.com/([^/?#]+)/?$", url) if not m: return ext_id = m.group(1) if ext_id in _NON_UNITS or ext_id in listings: return # photo de la carte (la fiche fournit la galerie complète) card = btn img = None for _ in range(10): card = card.parent if card is None: break img = card.select_one('img[src*="/uploads/"]') if img is not None: break images = ([img["src"]] if img and img.get("src", "").startswith("http") else []) # la fiche unité fait foi (type h1, adresse h4, statut, prix, photos). # Pas de cache BD ici : la liste ne porte ni prix ni statut (aucun # signal de fraîcheur pour invalider un cache) et le parc affiché est # minuscule (~6 fiches par sync, plafonnées par max_details). try: payload = self._fetch_detail(url) except Exception: payload = {} # unité retirée ou marquée « Non disponible » sur sa fiche : on saute if re.match(r"^(non\s+disponible|lou[ée])", payload.get("availability", ""), re.I): return unit_label = payload.get("unit_label", "") address = payload.get("address", "") lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{unit_label} — {address}".strip(" —"), address=address, city="Rimouski", # tout le parc est à Rimouski unit_type=normalize_unit_type(unit_label), price=payload.get("price"), price_label=payload.get("price_label", ""), availability=payload.get("availability", ""), description=payload.get("description", ""), images=payload.get("images") or images, ) listings[ext_id] = lst # -- fiche unité (//) ----------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Type, adresse, statut, prix (widget nu), description, galerie.""" 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 = {} h1 = soup.select_one("h1.elementor-heading-title") if h1: out["unit_label"] = h1.get_text(" ", strip=True) h4 = soup.select_one("h4.elementor-heading-title") if h4: out["address"] = h4.get_text(" ", strip=True) # widgets texte Elementor : statut et prix (« 850.00 », « 600.00$ … ») for w in soup.select(".elementor-widget-text-editor .elementor-widget-container"): t = re.sub(r"\s+", " ", w.get_text(" ", strip=True)) if "price_label" not in out and _PRICE_WIDGET_RE.match(t) \ and re.search(r"\d{3}", t): out["price_label"] = t if "$" in t: out["price"] = parse_price(t) elif _BARE_PRICE_RE.match(t): out["price"] = float(t) elif re.match(r"^(non\s+)?disponible\b.{0,40}$|^lou[ée]\b.{0,20}$", t, re.I) and "availability" not in out: out["availability"] = t # description : sections « Description » et « À proximité » (listes) parts: list[str] = [] for h in soup.select("h5.elementor-heading-title"): label = h.get_text(strip=True) if not re.match(r"Description|À proximité", label, re.I): continue section = h.find_parent(class_="e-con") if section: txt = section.get_text("\n", strip=True) parts.append(re.sub(r"[ \t]+", " ", txt)) if parts: desc = "\n".join(parts) out["description"] = desc[:1500] # date plus précise éventuelle (« Disponible Juillet 2026 ») m = _AVAIL_RE.search(desc) if m and len(m.group(0)) > len(out.get("availability", "")): out["availability"] = re.sub(r"\s+", " ", m.group(0)) out["images"] = [ im["src"] for im in soup.select('img[src*="/uploads/"]') if im.get("src", "").startswith("http") and not im["src"].endswith(".svg")][:25] return out