Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/location_sb.py : connecteur Location Résidentielle SB5# (locationresidentielle.com — jumelés, appartements et maisons à louer à6# Saint-Georges, Beauce). WordPress thème RealHomes rendu serveur : la page7# /a-louer/ liste des cartes <article class="property-item"> (titre,8# extrait, vignette, lien /property/<slug>/). Petit inventaire (2-3 fiches9# actives) ; on écarte les cartes « liste d'attente » et « NON10# DISPONIBLE ». La page détail (via cache BD) fournit adresse, chambres,11# salles de bain, prix ($/mois — souvent « communiquez avec nous »),12# disponibilité, caractéristiques (<li>) et photos.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price22from .base import BaseConnector2324BASE = "https://locationresidentielle.com"25LIST_URL = f"{BASE}/a-louer/"2627CITY = "Saint-Georges"2829SLUG_RE = re.compile(r"/property/([^/?#]+)")30# cartes qui ne sont pas de vraies annonces31SKIP_RE = re.compile(r"liste\s+d.attente|inscription|non\s+disponible", re.I)32ADDR_RE = re.compile(r"(\d{3,6},?\s+\d{1,3}e\s+(?:Rue|Avenue),?\s+"33 r"(?:Saint|St)-Georges(?:,?\s+G\d[A-Z]\s*\d[A-Z]\d)?)",34 re.I)35BED_RE = re.compile(r"(\d+)\s*(?:grandes?\s*)?chambres", re.I)36BATH_RE = re.compile(r"(\d+)\s*salles? de bain", re.I)37PRICE_RE = re.compile(r"(\d[\d\s]{2,6})\s*\$\s*/?\s*mois", re.I)38DISPO_RE = re.compile(r"(?:Disponibilit[ée]\s*:\s*([^\n]{2,60})|"39 r"((?:CESSION DE BAIL\s+)?DISPONIBLE EN [A-ZÉÛ]+))")40TYPE_RE = re.compile(r"\d\s*1/2")41UNIT_TEL_RE = re.compile(r"\b(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})\b")42IMG_RE = re.compile(r'(?:src|href)="(https://locationresidentielle\.com/'43 r'wp-content/uploads/[^"]+\.(?:jpe?g|png|webp))"', re.I)44IMG_SKIP_RE = re.compile(r"logo|signature|carte|a_louer|"45 r"jumeles-appartements|-\d{2,3}x\d{2,3}\.", re.I)464748class LocationSbConnector(BaseConnector):49 source_id = "location_sb"50 request_delay = 0.65152 def fetch(self) -> list[Listing]:53 listings: list[Listing] = []54 html = self.get(LIST_URL).text55 soup = BeautifulSoup(html, "html.parser")5657 for item in soup.select("article.property-item"):58 try:59 a = item.select_one("h4 a[href]")60 if not a:61 continue62 title = re.sub(r"[•\s]+", " ", a.get_text(strip=True)).strip()63 if not title or SKIP_RE.search(title):64 continue65 m = SLUG_RE.search(a["href"])66 if not m:67 continue68 slug = m.group(1)69 url = f"{BASE}/property/{slug}/"7071 excerpt = ""72 p = item.find("p")73 if p:74 excerpt = re.sub(75 r"\s+", " ",76 p.get_text(" ", strip=True)).replace(77 "Plus d'informations", "").strip(" …-")7879 key = hashlib.sha1(80 f"{url}|{title}|{excerpt}".encode()).hexdigest()81 try:82 d = self.detail(slug, key, lambda u=url: self._detail(u))83 except Exception:84 d = {}8586 tm = TYPE_RE.search(title)87 unit_type = normalize_unit_type(tm.group(0)) if tm else ""8889 listings.append(Listing(90 source=self.source_id,91 external_id=slug,92 url=url,93 title=title,94 address=d.get("address")95 or f"{title}, Saint-Georges",96 city=CITY,97 unit_type=unit_type,98 bedrooms=d.get("bedrooms"),99 bathrooms=d.get("bathrooms"),100 price=d.get("price"),101 price_label=d.get("price_label", ""),102 availability=d.get("availability", ""),103 description=d.get("description") or excerpt,104 amenities=d.get("amenities") or [],105 details=d.get("details") or {},106 images=d.get("images") or [],107 ))108 except Exception:109 continue110 return listings111112 # -- page détail /property/<slug>/ (thème RealHomes) ------------------------113 def _detail(self, url: str) -> dict:114 html = self.get(url).text115 soup = BeautifulSoup(html, "html.parser")116 art = soup.find("article") or soup117 txt = re.sub(r"[ \t]+", " ", art.get_text("\n", strip=True))118 flat = re.sub(r"\s+", " ", txt)119 out: dict = {}120121 m = ADDR_RE.search(flat)122 if m:123 addr = re.sub(r"\s+", " ", m.group(1)).strip()124 if not re.search(r"saint-georges", addr, re.I):125 addr = re.sub(r"St-Georges", "Saint-Georges", addr, flags=re.I)126 out["address"] = addr127 m = BED_RE.search(flat)128 if m:129 out["bedrooms"] = float(m.group(1))130 m = BATH_RE.search(flat)131 if m:132 out["bathrooms"] = float(m.group(1))133 m = PRICE_RE.search(flat)134 if m:135 out["price"] = parse_price(m.group(0))136 out["price_label"] = f"{m.group(1).strip()} $/mois"137 elif re.search(r"Prix\s*:\s*communiquez", flat, re.I):138 out["price_label"] = "Prix : communiquez avec nous"139 m = DISPO_RE.search(txt)140 if m:141 out["availability"] = (m.group(1) or m.group(2) or "") \142 .strip().capitalize()143144 # caractéristiques / inclusions : listes <li> du contenu145 amenities: list[str] = []146 for li in art.find_all("li"):147 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))148 t = re.sub(r"^[^\w]+|►.*$", "", t).strip()149 if (t and 3 <= len(t) < 80 and "ICI" not in t150 and not re.search(r"estimez|inscrivez", t, re.I)151 and t not in amenities):152 amenities.append(t)153 out["amenities"] = amenities[:20]154155 details: dict = {}156 pm = UNIT_TEL_RE.search(flat)157 if pm:158 details["contact"] = {159 "phone": f"{pm.group(1)}-{pm.group(2)}-{pm.group(3)}"}160 if details:161 out["details"] = details162163 out["images"] = [u for u in dict.fromkeys(IMG_RE.findall(html))164 if not IMG_SKIP_RE.search(u)][:15]165 return out166