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/simard.py : connecteur Immeubles Simard (immeublessimard.com)5# Section « À louer » — catégorie appartement uniquement (les bureaux,6# commerces et laboratoires sont exclus d'office). Pages détail (cache BD7# self.detail) : bloc specs étiqueté (#infos : pièces, prix, superficie,8# disponibilité), « Caractéristiques et inclusions », description, images.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import hashlib13import re1415from bs4 import BeautifulSoup1617from ..schema import (Listing, infer_city, merge_details, normalize_unit_type,18 strip_accents)19from .base import BaseConnector2021BASE = "https://immeublessimard.com"22LIST_URL = f"{BASE}/a-louer/categorie/appartement/"2324DETAIL_RE = re.compile(r"/a-louer/appartement/([a-z0-9\-]+)/([a-z0-9\-]+)/?$")25IMG_RE = re.compile(26 r"https://immeublessimard\.com/wp-content/uploads/[^\"'\\\s\)]+"27 r"\.(?:jpg|jpeg|png|webp)", re.I)28IMG_NOISE_RE = re.compile(r"logo|favicon|icon", re.I)29# Prix affiché « $1975.00 » ou « 1 975$ »30PRICE_RE = re.compile(r"(?:\$\s*([\d\s,]+(?:\.\d{2})?)|([\d][\d\s]*(?:,\d{2})?)\s*\$)")313233def _parse_price(text: str) -> float | None:34 """Gère les deux formats : « $1975.00 » et « 1 975$ »."""35 if not text:36 return None37 s = text.replace(" ", " ").replace(" ", " ")38 s = re.sub(r"(\d),(\d{3})", r"\1\2", s) # 1,975 -> 197539 m = PRICE_RE.search(s)40 if not m:41 return None42 num = (m.group(1) or m.group(2)).replace(" ", "").replace(",", ".")43 try:44 val = float(num)45 except ValueError:46 return None47 return val if 100 <= val <= 20000 else None484950class SimardConnector(BaseConnector):51 source_id = "simard"52 request_delay = 0.653 max_details = 60 # garde-fou5455 def fetch(self) -> list[Listing]:56 html = self.get(LIST_URL).text57 soup = BeautifulSoup(html, "html.parser")5859 listings: dict[str, Listing] = {}60 for card in soup.select("div.item a[href*='/a-louer/appartement/']"):61 try:62 lst = self._parse_card(card)63 except Exception:64 continue65 if lst and lst.external_id not in listings:66 listings[lst.external_id] = lst6768 # Pages détail (cache BD) : specs, prix, dispo, commodités, images69 fetched = 070 for i, lst in enumerate(listings.values()):71 if i >= self.max_details:72 break73 # « v3 » : version du format de payload (invalide les caches anciens)74 key = hashlib.sha1(75 f"v3|{lst.title}|{lst.description}".encode("utf-8")).hexdigest()7677 def _fetch(url=lst.url):78 nonlocal fetched79 if fetched >= self.max_details:80 raise RuntimeError("plafond de requêtes détail atteint")81 fetched += 182 return self._fetch_detail(url)8384 try:85 payload = self.detail(lst.external_id, key, _fetch)86 except Exception:87 payload = {}88 if payload:89 self._apply_detail(lst, payload)9091 return list(listings.values())9293 def _parse_card(self, card) -> Listing | None:94 href = card.get("href", "").split("?")[0]95 m = DETAIL_RE.search(href)96 if not m:97 return None98 sector_slug, slug = m.groups()99 title_el = card.select_one("h2")100 sector_el = card.select_one(".secteur")101 bullets = [li.get_text(" ", strip=True) for li in card.select("li")]102 title = title_el.get_text(" ", strip=True) if title_el else slug103 sector = sector_el.get_text(strip=True) if sector_el else \104 sector_slug.replace("-", " ").title()105 unit_type = ""106 for b in bullets + [title]:107 m2 = re.search(r"\d\s*(?:½|1/2)|studio|loft", b, re.I)108 if m2:109 unit_type = normalize_unit_type(m2.group(0))110 break111 # certains titres SONT l'adresse civique (« 1276-24 Chanoine-Morel »)112 address = title if re.match(r"^\d{2,5}(?:-\d+[A-Za-z]?)?\s+\D", title) else ""113 return Listing(114 source=self.source_id,115 external_id=slug,116 url=href if href.startswith("http") else BASE + href,117 title=title,118 address=address,119 sector=sector,120 city=infer_city(sector),121 unit_type=unit_type,122 description=" • ".join(b for b in bullets if b)[:400],123 )124125 def _fetch_detail(self, url: str) -> dict:126 """Télécharge une fiche et en extrait le payload brut (cacheable)."""127 html = self.get(url).text128 soup = BeautifulSoup(html, "html.parser")129130 # Bloc specs étiqueté (#infos) : « Nombre de pièces : 4½ »,131 # « Prix : $1975 », « Superficie : 902 pi2 », « Disponibilité : … »132 specs: dict[str, str] = {}133 for li in soup.select("#infos ul li"):134 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))135 m = re.match(r"(Nombre de pièces|Prix|Superficie|Disponibilité)"136 r"\s*:\s*(.+)", t, re.I)137 if m:138 specs[strip_accents(m.group(1).lower())] = m.group(2).strip()139140 # statut affiché dans l'entête (« Disponible », « Loué »)141 status_el = soup.select_one("#infos p.details .text-right")142 status = status_el.get_text(" ", strip=True) if status_el else ""143144 # bloc « Location : » — contact structuré (tel:/mailto:)145 contact: dict = {}146 tel = soup.select_one("#infos a[href^='tel:']")147 if tel:148 digits = re.sub(r"\D", "", tel.get("href", ""))149 if len(digits) == 10:150 contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"151 mail = soup.select_one("#infos a[href^='mailto:']")152 if mail:153 m = re.match(r"mailto:([^?]+)", mail.get("href", ""))154 if m:155 contact["email"] = m.group(1).strip()156157 # « Caractéristiques et inclusions » + « Description »158 amenities: list[str] = []159 desc_parts: list[str] = []160 contenu = soup.select_one("section .contenu")161 if contenu:162 for h2 in contenu.select("h2"):163 title = h2.get_text(" ", strip=True)164 if re.search(r"Caractéristiques", title, re.I):165 ul = h2.find_next_sibling("ul")166 for li in (ul.select("li") if ul else []):167 t = li.get_text(" ", strip=True)168 if t and len(t) < 80 and t not in amenities:169 amenities.append(t)170 elif re.search(r"Description", title, re.I):171 for p in h2.find_next_siblings("p"):172 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))173 if t:174 desc_parts.append(t)175 desc = " ".join(desc_parts)176 if not desc: # repli : meta description177 og = soup.find("meta", attrs={"property": "og:description"}) or \178 soup.find("meta", attrs={"name": "description"})179 desc = (og.get("content") or "").strip() if og else ""180181 imgs = [u for u in dict.fromkeys(IMG_RE.findall(html))182 if not IMG_NOISE_RE.search(u)]183 # éliminer les variantes redimensionnées quand l'originale est là184 originals = {re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) for u in imgs}185 images = [u for u in imgs186 if re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) == u187 or re.sub(r"-\d+x\d+(\.\w+)$", r"\1", u) not in originals][:25]188189 return {"specs": specs, "status": status, "amenities": amenities,190 "contact": contact, "description": desc[:1200],191 "images": images}192193 def _apply_detail(self, lst: Listing, payload: dict) -> None:194 """Applique le payload d'une fiche (frais ou depuis le cache BD)."""195 specs = payload.get("specs") or {}196197 if specs.get("prix"):198 price = _parse_price(specs["prix"])199 if price:200 lst.price = price201 lst.price_label = specs["prix"]202 if lst.price is None:203 # repli : prix dans la description (« Prix: $1975.00 … ») en204 # ignorant les mentions de stationnement (« Stationnement; $90.00 »)205 cleaned = re.sub(r"[Ss]tationnement[^.]{0,40}\$\s*[\d.,]+", " ",206 payload.get("description") or "")207 price = _parse_price(cleaned)208 if price:209 lst.price = price210 m = PRICE_RE.search(re.sub(r"(\d),(\d{3})", r"\1\2", cleaned))211 lst.price_label = m.group(0).strip() if m else ""212213 if specs.get("disponibilite"):214 lst.availability = f"Disponibilité : {specs['disponibilite']}"215 elif payload.get("status") and re.search(r"disponible",216 payload["status"], re.I):217 lst.availability = payload["status"]218219 if specs.get("nombre de pieces"):220 lst.unit_type = normalize_unit_type(specs["nombre de pieces"])221 if not lst.unit_type:222 m = re.search(r"\d\s*(?:½|1/2)|studio|loft",223 payload.get("description") or "", re.I)224 if m:225 lst.unit_type = normalize_unit_type(m.group(0))226227 amenities = list(payload.get("amenities") or [])228 # superficie : texte source (« 902 pi2 ») ajouté aux commodités pour229 # affichage + parsing par finalize(). Quirk du site : le champ230 # contient parfois un type d'unité (« Superficie : 4 ½ »).231 surf = specs.get("superficie") or ""232 if re.search(r"\d\s*(?:pi|m)\b|pi[²2]|m[²2]", surf):233 amenities.append(f"Superficie : {surf}")234 elif surf and not lst.unit_type:235 lst.unit_type = normalize_unit_type(surf)236 if amenities:237 lst.amenities = amenities238239 if payload.get("contact"):240 lst.details = merge_details(lst.details,241 {"contact": payload["contact"]})242243 if payload.get("description"):244 lst.description = payload["description"]245 if payload.get("images"):246 lst.images = payload["images"]247