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/logement_mauricie.py : connecteur Logement Mauricie5# (logementmauricie.com) — regroupement de propriétaires (+200 logements).6# Builder type WebSelf : 4 pages secteur (Trois-Rivières, Cap-de-la-7# Madeleine, Shawinigan, Shawinigan-Sud), chaque immeuble = un widget8# texte (id GUID stable) avec champs libellés « Type / Actuellement9# disponible / Date / Chauffé éclairé / Combien $ / Contact… ». On ne10# retient que les immeubles avec une disponibilité réelle (≠ Complet).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re15from urllib.parse import urljoin1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "http://logementmauricie.com"2324# pages secteur -> (ville réelle, secteur) ; Cap-de-la-Madeleine et25# Shawinigan-Sud sont des secteurs des villes fusionnées (2002)26PAGES = [27 ("trois-rivieres", "Trois-Rivières", ""),28 ("cap-de-la-madeleine", "Trois-Rivières", "Cap-de-la-Madeleine"),29 ("shawinigan", "Shawinigan", ""),30 ("shawinigan-sud", "Shawinigan", "Shawinigan-Sud"),31]3233# libellés des champs du gabarit d'immeuble34_LABELS = re.compile(35 r"^(Type|Actuellement disponible|Date|Chauff[ée] [ée]clair[ée]|"36 r"Commodit[ée]s?|Entr[ée]e laveuse et s[ée]cheuse|Stationnements?|"37 r"Plus|Combien \$|Contact)\s*:?\s*$", re.I)383940def _parse_fields(lines: list[str]) -> dict[str, str]:41 """Paires libellé -> valeur (les valeurs suivent le libellé, ligne(s)42 suivante(s) jusqu'au prochain libellé)."""43 fields: dict[str, list[str]] = {}44 cur: str | None = None45 for ln in lines:46 m = _LABELS.match(ln)47 if m:48 cur = m.group(1).lower()49 fields.setdefault(cur, [])50 elif cur is not None:51 fields[cur].append(ln)52 return {k: " ".join(v).strip() for k, v in fields.items()}535455class LogementMauricieConnector(BaseConnector):56 source_id = "logement_mauricie"57 request_delay = 0.858 max_pages = 4 # 4 pages secteur, pas de pagination5960 def fetch(self) -> list[Listing]:61 listings: list[Listing] = []62 for slug, city, sector in PAGES[:self.max_pages]:63 url = f"{BASE}/{slug}/"64 try:65 html = self.get(url).text66 except Exception:67 continue68 soup = BeautifulSoup(html, "html.parser")69 for w in soup.select("div.widget.widget-text[id]"):70 try:71 lst = self._parse_widget(w, url, city, sector)72 except Exception:73 continue74 if lst is not None:75 listings.append(lst)76 return listings7778 def _parse_widget(self, w, page_url: str, city: str,79 sector: str) -> Listing | None:80 txt = w.get_text("\n", strip=True)81 if "Actuellement disponible" not in txt:82 return None # widget décoratif (intro, bandeau…)83 lines = [re.sub(r"\s+", " ", ln).strip()84 for ln in txt.split("\n") if ln.strip()]85 fields = _parse_fields(lines)8687 # disponibilité réelle seulement : « Complet », vide ou « - » = ignoré88 dispo = fields.get("actuellement disponible", "").strip(" - ")89 if not dispo or re.search(r"^complet", dispo, re.I):90 return None91 if re.search(r"commercial|stationnement seulement|local\b", dispo, re.I):92 return None9394 h2 = w.find("h2")95 address = h2.get_text(" ", strip=True) if h2 else ""96 if not address or not re.match(r"\d", address):97 return None # bloc sans adresse civique = pas un immeuble9899 ext_id = w.get("id", "") # GUID du widget, stable dans le builder100 if not ext_id:101 return None102103 date_txt = fields.get("date", "").strip(" - ")104 price_label = fields.get("combien $", "").strip(" - ")105106 # description : bloc de champs + paragraphe descriptif du secteur107 desc_lines = []108 started = False109 for ln in lines:110 if ln == address:111 started = True112 continue113 if started and ln.lower() not in ("revenir en haut",):114 desc_lines.append(ln)115116 images: list[str] = []117 for img in w.find_all("img", src=True):118 src = urljoin(page_url, img["src"])119 if re.search(r"google-maps|icon|logo", src, re.I):120 continue121 if src.startswith("http") and src not in images:122 images.append(src)123124 return Listing(125 source=self.source_id,126 external_id=ext_id,127 url=f"{page_url}#{ext_id}", # pas de fiche individuelle128 title=f"{dispo} — {address}",129 address=address,130 sector=sector,131 city=city,132 unit_type=normalize_unit_type(dispo),133 price=parse_price(price_label),134 price_label=price_label,135 availability=date_txt,136 description="\n".join(desc_lines)[:900],137 images=images[:10],138 )139