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/desroches.py : connecteur Immeubles Claude Desroches5# (immeublesclaudedesroches.com — Granby, Cowansville, Drummondville).6# Site PHP custom : une seule page statique /logements-a-louer/ avec un7# tableau par ville (« Nos logements à louer à <Ville> ») ; une ligne par8# logement, repérée par son ancre stable <a name="logNN">. Colonnes :9# photo (badge est-loue.png = déjà loué -> ignoré), adresse, grandeur,10# prix, étage, note libre (mois de libération, inclusions, rénové…).11# La section « LOCAUX À LOUER » (commercial) n'a pas d'ancres logNN et12# est donc naturellement exclue. Aucune page détail n'existe.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type, parse_price21from .base import BaseConnector2223BASE = "https://www.immeublesclaudedesroches.com"24LIST_URL = f"{BASE}/logements-a-louer/"2526_ANCHOR_RE = re.compile(r"^log\d+$")27_CITY_RE = re.compile(r"logements à louer à\s+([A-Za-zÀ-ÿ' -]+)", re.I)2829# segments de la note libre qui parlent de disponibilité (mois ou « libre ») —30# le reste (« rénové », « chauffé », « Ascenseur »…) n'est pas une date et31# fausserait la normalisation centrale32_AVAIL_SEG_RE = re.compile(33 r"\b(janvier|f[ée]vrier|mars|avril|mai|juin|juillet|ao[ûu]t|septembre|"34 r"octobre|novembre|d[ée]cembre|libre|maintenant)\b", re.I)353637def _availability_from_note(note: str) -> str:38 """Ne garde de la note que les segments de disponibilité (« juillet »,39 « libre »…) ; « rénové, juillet » -> « juillet »."""40 segs = [s.strip() for s in re.split(r"[,;]", note or "")]41 return ", ".join(s for s in segs if s and _AVAIL_SEG_RE.search(s))424344class DesrochesConnector(BaseConnector):45 source_id = "desroches"46 request_delay = 0.64748 def fetch(self) -> list[Listing]:49 html = self.get(LIST_URL).text50 soup = BeautifulSoup(html, "html.parser")5152 listings: dict[str, Listing] = {}53 for anchor in soup.find_all("a", attrs={"name": _ANCHOR_RE}):54 try:55 lst = self._parse_row(anchor)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 # -- ligne du tableau -----------------------------------------------------------63 def _parse_row(self, anchor) -> Listing | None:64 row = anchor.find_parent("tr")65 if row is None:66 return None6768 # badge « est loué » superposé à la photo -> logement non offert69 if row.find("img", src=re.compile(r"est-loue", re.I)):70 return None7172 # ville : plus proche en-tête « Nos logements à louer à <Ville> »73 # avant la ligne (les tableaux sont groupés par ville)74 city = ""75 for prev in anchor.find_all_previous(["p", "h1"]):76 t = " ".join(prev.get_text(" ", strip=True).split())77 m = _CITY_RE.search(t)78 if m:79 city = m.group(1).strip()80 break81 if not city:82 return None8384 cells = [" ".join(td.get_text(" ", strip=True).split())85 for td in row.find_all("td")]86 # [photo, adresse, grandeur, prix, étage, note]87 if len(cells) < 4:88 return None89 address_raw = cells[1] if len(cells) > 1 else ""90 size_raw = cells[2] if len(cells) > 2 else ""91 price_raw = cells[3] if len(cells) > 3 else ""92 floor_raw = cells[4] if len(cells) > 4 else ""93 note_raw = cells[5] if len(cells) > 5 else ""94 if not address_raw:95 return None9697 amenities = []98 if floor_raw:99 amenities.append(f"Étage : {floor_raw}")100101 # photo réelle (default.png = image de remplacement, ignorée)102 images = []103 for img in row.find_all("img", src=True):104 src = img["src"]105 if re.search(r"est-loue|default\.png", src, re.I):106 continue107 src = re.sub(r"^(\.\./)+", "/", src)108 if not src.startswith("http"):109 src = BASE + (src if src.startswith("/") else f"/{src}")110 if src not in images:111 images.append(src)112113 ext_id = anchor.get("name")114 return Listing(115 source=self.source_id,116 external_id=ext_id,117 url=f"{LIST_URL}#{ext_id}",118 title=f"{address_raw}, {city}",119 address=f"{address_raw}, {city}",120 city=city,121 unit_type=normalize_unit_type(size_raw),122 price=parse_price(price_raw),123 price_label=price_raw,124 availability=_availability_from_note(note_raw), # « juillet », …125 description=note_raw, # note libre : inclusions, rénové…126 amenities=amenities,127 images=images[:5],128 )129