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/immeubles_db.py : connecteur Les Immeubles D.B. Busque-Massé5# (immeublesdb.com) — 12 immeubles / ~400 apparts à Sherbrooke, 4 secteurs.6# Site custom PHP (« pm_editor », derrière Cloudflare — le UA de base.py7# passe) : 4 pages statiques /logements-a-louer/secteur-{nord,est,8# centre-ville,ouest}/. Chaque logement offert = une section .container9# avec une cellule galerie (photos /modules/upload/…) et une cellule texte10# (h3 = adresse civique, liste <li> = type, prix « … $ par mois »,11# inclusions). Aucun identifiant publié : external_id = empreinte stable12# (secteur + adresse + ligne de type). Les logements loués sont simplement13# retirés de la page (aucun marqueur « complet » à filtrer).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://immeublesdb.com"26SECTORS = {27 "secteur-nord": "Nord",28 "secteur-est": "Est",29 "secteur-centre-ville": "Centre-ville",30 "secteur-ouest": "Ouest",31}3233_PRICE_LI = re.compile(r"\d[\d\s]{0,5}\s?\$\s*par mois", re.I)343536def _clean_price_label(label: str) -> str:37 """« 1 125$ par mois » (espace de milliers) -> compatible parse_price."""38 return re.sub(r"(\d)\s(\d{3})", r"\1\2", label)394041class ImmeublesDbConnector(BaseConnector):42 source_id = "immeubles_db"43 request_delay = 0.64445 def fetch(self) -> list[Listing]:46 listings: dict[str, Listing] = {}47 for slug, sector in SECTORS.items():48 try:49 html = self.get(f"{BASE}/logements-a-louer/{slug}/").text50 except Exception:51 continue52 soup = BeautifulSoup(html, "html.parser")53 for container in soup.select("div.container"):54 try:55 lst = self._parse_block(container, sector)56 except Exception:57 continue58 if lst and lst.external_id not in listings:59 listings[lst.external_id] = lst60 return list(listings.values())6162 # -- une section .container = un logement offert --------------------------------63 def _parse_block(self, container, sector: str) -> Listing | None:64 h3 = container.select_one(".cell_container h3")65 ul = container.select_one(".cell_container ul")66 if not (h3 and ul):67 return None68 address_full = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip(" ,")69 lis = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()70 for li in ul.select("li")]71 lis = [t for t in lis if t]72 if not (address_full and lis):73 return None7475 # « 250, Olivier, Sherbrooke, QC » -> adresse sans la province76 parts = [p.strip() for p in address_full.split(",") if p.strip()]77 address = ", ".join(p for p in parts if p.upper() != "QC")78 city = "Sherbrooke"7980 type_line = lis[0]81 # « 4 CHAMBRES disponibles à partir de 550 $ » = location à la chambre82 # (normalize_unit_type transformerait « 4 chambres » en 6½+)83 if re.search(r"chambres?\s+disponibles?", type_line, re.I):84 unit_type = "Chambre"85 else:86 unit_type = normalize_unit_type(type_line)87 if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison",88 unit_type or ""):89 unit_type = ""9091 # prix : première ligne « … $ par mois »92 price = None93 price_label = ""94 for t in lis:95 if _PRICE_LI.search(t):96 price_label = t97 price = parse_price(_clean_price_label(t))98 break99100 # disponibilité : seulement si la source l'écrit (sinon vide)101 availability = ""102 m = re.search(r"libre\s+imm[ée]diatement", " ".join(lis), re.I)103 if m:104 availability = "Libre immédiatement"105106 # photos de la galerie jumelle (chemins relatifs /modules/upload/…)107 images: list[str] = []108 for img in container.select(".galerie_img_block a[href]"):109 href = (img.get("href") or "").strip()110 if href.startswith("/"):111 href = BASE + href112 if href.startswith("http") and href not in images:113 images.append(href)114115 ext_id = hashlib.sha1(116 f"{sector}|{address_full}|{type_line}".encode("utf-8")).hexdigest()[:16]117118 return Listing(119 source=self.source_id,120 external_id=ext_id,121 url=f"{BASE}/logements-a-louer/"122 f"{[k for k, v in SECTORS.items() if v == sector][0]}/",123 title=f"{type_line} — {address}",124 address=address,125 sector=sector,126 city=city,127 unit_type=unit_type,128 price=price,129 price_label=price_label,130 availability=availability,131 description=" | ".join(lis)[:1500],132 images=images[:20],133 )134