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/desmarais.py : connecteur Immeubles Desmarais (immeublesdesmarais.ca)5# Gestionnaire historique de Gatineau (Hull, Aylmer, Buckingham). Site PHP6# custom encodé ISO-8859-1, rendu CÔTÉ SERVEUR : la page /logements liste7# les cartes (titre, secteur, chambres, superficie, type, prix « À partir8# de ») avec pagination ?entity=logements&page=N. Le script de la carte9# Google (showAddress) publie en plus l'adresse civique + code postal et le10# lien canonique /logements/<id>/<slug> de chaque annonce.11# La fiche détail ajoute la date de disponibilité, la description complète12# et la galerie (/upload/logements/<id>/NN.jpg) — via self.detail() (cache13# BD) avec plafond par sync. Les locaux commerciaux vivent sous /locaux14# (entité distincte) et ne sont donc jamais ramassés.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_area_sqft, parse_price24from .base import BaseConnector2526BASE = "https://www.immeublesdesmarais.ca"27LIST_URL = f"{BASE}/logements"2829# secteurs de l'agglomération de Gatineau (le site filtre par ces 4 secteurs)30_SECTEURS = {"hull": "Hull", "aylmer": "Aylmer", "buckingham": "Buckingham",31 "gatineau": ""}3233# showAddress(map, '<adresse civique>', '...href=\"/logements/<id>/<slug>\"...')34_MAP_RE = re.compile(35 r"showAddress\(map,\s*'([^']*)',\s*'.*?href=\\\"/logements/(\d+)/([^/\\\"]+)"36)373839def _clean_txt(s: str) -> str:40 return re.sub(r"\s+", " ", (s or "").strip())414243class DesmaraisConnector(BaseConnector):44 source_id = "desmarais"45 request_delay = 1.046 max_pages = 6 # garde-fou de pagination (12 annonces = 2 pages)47 max_details = 20 # fiches détail réellement visitées par sync48 max_images = 204950 def __init__(self) -> None:51 super().__init__()52 self._detail_calls = 05354 # -- helpers ---------------------------------------------------------------55 def _html(self, url: str) -> str:56 """Le site est encodé ISO-8859-1 (meta charset) : forcer le décodage,57 sinon les accents deviennent mojibake selon l'en-tête du serveur."""58 resp = self.get(url)59 resp.encoding = "iso-8859-1"60 return resp.text6162 @staticmethod63 def _city_sector(raw: str) -> tuple[str, str]:64 """« Hull (Québec) J8X 4G9 » -> ('Gatineau', 'Hull') ;65 « Gatineau (Québec) » -> ('Gatineau', '')."""66 base = re.split(r"[(]", _clean_txt(raw))[0].strip()67 key = base.lower()68 if key in _SECTEURS:69 return "Gatineau", _SECTEURS[key]70 return base, "" # imprévu : on garde le libellé source tel quel7172 @staticmethod73 def _unit_type(beds: str) -> str:74 """Nombre de chambres de la carte : « bach » (garçonnière) -> Studio,75 sinon « N chambres » normalisé par la couche commune."""76 b = _clean_txt(beds).lower()77 if b.startswith("bach") or "garconni" in b or "garçonni" in b:78 return "Studio"79 m = re.match(r"^(\d+)", b)80 return normalize_unit_type(f"{m.group(1)} chambres") if m else ""8182 # -- fiche détail ------------------------------------------------------------83 def _fetch_detail(self, url: str) -> dict:84 """Date de disponibilité, description complète et galerie photos."""85 self._detail_calls += 186 html = self._html(url)87 soup = BeautifulSoup(html, "html.parser")88 out: dict = {}8990 td = soup.find("td", string=re.compile(r"Date de disponibilit"))91 if td:92 nxt = td.find_next_sibling("td")93 if nxt:94 out["availability"] = _clean_txt(nxt.get_text(" ", strip=True))9596 desc = soup.select_one("#detailRight p.greyText")97 if desc:98 txt = desc.get_text("\n", strip=True)99 out["description"] = re.sub(r"\s*\n\s*", " ", txt).strip()[:2000]100101 images: list[str] = []102 for a in soup.select("a.glightbox[href^='/upload/']"):103 u = BASE + a["href"]104 if u not in images:105 images.append(u)106 out["images"] = images107 return out108109 # -- fetch -----------------------------------------------------------------110 def fetch(self) -> list[Listing]:111 listings: dict[str, Listing] = {}112 canon: dict[str, tuple[str, str]] = {} # id -> (adresse civique, slug)113114 for page in range(1, self.max_pages + 1):115 url = LIST_URL if page == 1 else f"{LIST_URL}?entity=logements&page={page}"116 try:117 html = self._html(url)118 except Exception:119 break120 # adresses civiques + liens canoniques publiés par la carte Google121 for addr, num, slug in _MAP_RE.findall(html):122 canon.setdefault(num, (_clean_txt(addr), slug))123 soup = BeautifulSoup(html, "html.parser")124 cards = soup.select("div.infoLogement")125 if not cards:126 break127 before = len(listings)128 for card in cards:129 try:130 self._parse_card(card, listings)131 except Exception:132 continue133 if len(listings) == before: # page sans nouveauté = fin134 break135136 # adresse civique (carte Google) + fiche détail (cache BD)137 for ext, lst in listings.items():138 addr, slug = canon.get(ext, ("", ""))139 if addr:140 # « 215 Rue de Canadel Gatineau, Québec J8T 8C3, J8T 8C3 » ->141 # partie civique seulement (la ville est déjà normalisée)142 civic = addr.split(",")[0].strip()143 civic = re.sub(r"\s+(Gatineau|Hull|Aylmer|Buckingham)$", "",144 civic, flags=re.I)145 lst.address = f"{civic}, {lst.city}" if civic else ""146 if slug:147 lst.url = f"{BASE}/logements/{ext}/{slug}"148149 key = hashlib.sha1(150 f"{lst.title}|{lst.price_label}|{lst.description}"151 .encode("utf-8")).hexdigest()[:20]152 if self._detail_calls >= self.max_details:153 continue154 try:155 payload = self.detail(ext, key,156 lambda u=lst.url: self._fetch_detail(u))157 except Exception:158 continue159 if payload.get("availability"):160 lst.availability = payload["availability"]161 if payload.get("description"):162 lst.description = payload["description"]163 for img in payload.get("images") or []:164 if img not in lst.images and len(lst.images) < self.max_images:165 lst.images.append(img)166167 return list(listings.values())168169 # -- carte de la liste -------------------------------------------------------170 def _parse_card(self, card, listings: dict[str, Listing]) -> None:171 link = card.select_one("a[href*='details.php']")172 if not link:173 return174 m = re.search(r"[?&]id=(\d+)", link.get("href", ""))175 if not m:176 return177 ext = m.group(1)178 if ext in listings:179 return180181 top = card.select_one(".logementImageTop")182 spans = top.select("span") if top else []183 title = _clean_txt(spans[0].get_text(" ", strip=True)) if spans else ""184 loc_raw = _clean_txt(spans[1].get_text(" ", strip=True)) if len(spans) > 1 else ""185 city, sector = self._city_sector(loc_raw)186187 # type d'immeuble (Appartement / Condo / Maison) + prix « À partir de »188 prix_sec = card.select_one(".logementPrixSection")189 btype, price_label = "", ""190 if prix_sec:191 st = prix_sec.select_one("span strong")192 btype = _clean_txt(st.get_text(strip=True)) if st else ""193 pr = prix_sec.select_one("span.pull-right")194 price_label = _clean_txt(pr.get_text(" ", strip=True)) if pr else ""195 if re.search(r"commercial|local", btype, re.I):196 return # résidentiel seulement197198 # chambres + superficie (blocs à icônes de la carte)199 beds = sqft = ""200 for div in card.select(".logementImageBot strong, .logementImageBotInfoBox strong"):201 t = _clean_txt(div.get_text(strip=True))202 if "pi²" in t or "pi2" in t.lower():203 sqft = sqft or t204 elif t:205 beds = beds or t206 if not beds: # repli : blocs « <strong>2</strong> chambres »207 m2 = re.search(r"<strong>\s*(bach|\d+)\s*</strong>\s*chambre",208 str(card), re.I)209 if m2:210 beds = m2.group(1)211 m3 = re.search(r"<strong>\s*([\d\s]+pi²)\s*</strong>", str(card))212 if m3 and not sqft:213 sqft = _clean_txt(m3.group(1))214215 # extrait de description de la carte (remplacé par la fiche détail)216 snippet_el = card.select_one(".contentLogementInt")217 snippet = _clean_txt(snippet_el.get_text(" ", strip=True)) if snippet_el else ""218 snippet = re.sub(r"\(\.\.\.\)$", "…", snippet)219220 img_el = card.select_one(".logementImage[style]")221 images: list[str] = []222 if img_el:223 m4 = re.search(r"url\(([^)]+)\)", img_el.get("style", ""))224 if m4:225 images.append(BASE + m4.group(1).strip("'\" "))226227 details: dict = {}228 if btype:229 details["building_type"] = btype230231 listings[ext] = Listing(232 source=self.source_id,233 external_id=ext,234 url=f"{BASE}/logements/{ext}/",235 title=title,236 sector=sector,237 city=city,238 unit_type=self._unit_type(beds),239 price=parse_price(price_label),240 price_label=price_label,241 area_sqft=parse_area_sqft(sqft),242 description=snippet,243 details=details,244 images=images,245 )246