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/uptimo.py : connecteur Uptimo Gestion immobilière (uptimo.ca)5# Apparts à Sherbrooke (Fleurimont, Mont-Bellevue, Jacques-Cartier…) et6# environs. WordPress Themify + Post Type Builder : l'archive7# /logements-a-louer/ (CPT « propriete », 15 cartes/page, ~6 pages) liste le8# catalogue à louer — cartes .ptb_post avec titre, arrondissement9# (taxonomie) et photo ; l'ID WordPress (classe post-<id>) sert10# d'external_id stable. Les fiches (via self.detail, cache BD) portent les11# champs structurés : « Adresse: », « Ville: », « Prix: »,12# « Type de propriété: » (2 1/2…), salles de bain, description et galerie.13# Aucun statut structuré de disponibilité — la date de libération n'apparaît14# qu'en texte libre dans la description (textmine central).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://www.uptimo.ca"27LIST_URL = f"{BASE}/logements-a-louer/"2829_POST_ID_RE = re.compile(r"\bpost-(\d+)\b")30_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)313233class UptimoConnector(BaseConnector):34 source_id = "uptimo"35 request_delay = 0.636 max_pages = 10 # garde-fou de pagination (6 pages observées)37 max_details = 90 # garde-fou fiches détail (77 propriétés au catalogue)3839 def fetch(self) -> list[Listing]:40 listings: dict[str, Listing] = {}41 for page in range(1, self.max_pages + 1):42 url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/"43 try:44 html = self.get(url).text45 except Exception:46 break47 soup = BeautifulSoup(html, "html.parser")48 cards = soup.select(".ptb_post")49 if not cards:50 break51 for card in cards:52 try:53 lst = self._parse_card(card)54 except Exception:55 continue56 if lst and lst.external_id not in listings:57 listings[lst.external_id] = lst5859 # fiches détail (cache BD) : adresse, ville, prix, type, description,60 # salles de bain, galerie61 self._fetched = 062 for lst in listings.values():63 key = hashlib.sha1(64 f"{lst.title}|{lst.sector}|{lst.images[:1]}"65 .encode("utf-8")).hexdigest()66 try:67 payload = self.detail(lst.external_id, key,68 lambda u=lst.url: self._fetch_detail(u))69 except Exception:70 continue71 self._apply_detail(lst, payload)72 return list(listings.values())7374 # -- carte d'archive -------------------------------------------------------------75 def _parse_card(self, card) -> Listing | None:76 link = card.select_one("h3.ptb_post_title a[href]")77 if not link:78 return None79 url = link["href"]80 title = re.sub(r"\s+", " ", link.get_text(" ", strip=True)).strip()81 m = _POST_ID_RE.search(" ".join(card.get("class") or []))82 ext_id = m.group(1) if m else re.sub(r".*/propriete/([^/]+)/?.*", r"\1", url)8384 # arrondissement (taxonomie) : « Sherbrooke-Fleurimont »85 sector = ""86 tax = card.select_one(".ptb_taxonomies_tous_les_arrondissements")87 if tax:88 sector = re.sub(r"\s+", " ", tax.get_text(" ", strip=True)).strip()89 sector = re.sub(r"^Sherbrooke-", "", sector)9091 img = card.select_one("img[src]")92 images = []93 if img and str(img.get("src", "")).startswith("http"):94 images.append(_VARIANT_IMG.sub("", img["src"]))9596 # type d'unité dans le titre le cas échéant (« - Studio », « 3 1/2 »)97 unit_type = normalize_unit_type(title)98 if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison",99 unit_type or ""):100 unit_type = ""101102 return Listing(103 source=self.source_id,104 external_id=str(ext_id),105 url=url,106 title=title,107 address="", # complété par la fiche108 sector=sector,109 city="Sherbrooke",110 unit_type=unit_type,111 availability="", # aucun statut structuré publié112 images=images,113 )114115 # -- fiche propriété (modules PTB) ---------------------------------------------116 def _fetch_detail(self, url: str) -> dict:117 if self._fetched >= self.max_details:118 raise RuntimeError("budget de fiches détail atteint")119 self._fetched += 1120 html = self.get(url).text121 soup = BeautifulSoup(html, "html.parser")122 out: dict = {}123124 def module_text(sel: str) -> str:125 el = soup.select_one(sel)126 return re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if el else ""127128 addr = module_text(".ptb_proprite_adresse")129 if addr:130 out["address"] = re.sub(r"^Adresse\s*:\s*", "", addr, flags=re.I)131 prix = module_text(".ptb_proprite_prix")132 if prix:133 out["price_label"] = re.sub(r"^Prix\s*:\s*", "", prix, flags=re.I)134135 # taxonomies : « Ville: Sherbrooke », « Type de propriété: 2 1/2 »,136 # « Nombre de salle(s) de bain: 1 »137 for tax in soup.select(".ptb_taxonomies"):138 t = re.sub(r"\s+", " ", tax.get_text(" ", strip=True)).strip()139 if t.lower().startswith("ville"):140 out["city"] = re.sub(r"^Ville\s*:\s*", "", t, flags=re.I)141 elif t.lower().startswith("type de propriété"):142 out["type_raw"] = re.sub(r"^Type de propriété\s*:\s*", "", t,143 flags=re.I)144 elif "salle(s) de bain" in t.lower():145 m = re.search(r"(\d+)\s*$", t)146 if m:147 out["bathrooms"] = int(m.group(1))148149 desc_el = soup.select_one(".ptb_textarea")150 if desc_el:151 out["description"] = re.sub(152 r"[ \t]+", " ", desc_el.get_text("\n", strip=True)).strip()[:1500]153154 images: list[str] = []155 for img in soup.select(".ptb_gallery img[src], "156 ".ptb_proprite_image_principal img[src]"):157 src = _VARIANT_IMG.sub("", str(img.get("src") or ""))158 if src.startswith("http") and src not in images:159 images.append(src)160 out["images"] = images[:20]161 return out162163 def _apply_detail(self, lst: Listing, d: dict) -> None:164 if not d:165 return166 if d.get("address"):167 lst.address = d["address"]168 if d.get("city"):169 # la taxonomie « Ville » mêle parfois la province (« Québec »,170 # « Québec, Sherbrooke ») : ne retenir qu'une ville réelle connue171 m = re.search(r"\b(Sherbrooke|Magog|Windsor|East Angus|Coaticook|"172 r"Ascot Corner|Lennoxville|Richmond)\b",173 d["city"], re.I)174 if m:175 lst.city = m.group(1).title()176 if d.get("price_label"):177 lst.price_label = d["price_label"]178 lst.price = parse_price(re.sub(r"(\d)[,\s](\d{3})", r"\1\2",179 d["price_label"]))180 if d.get("description"):181 lst.description = d["description"]182 if not lst.unit_type and d.get("type_raw"):183 ut = normalize_unit_type(d["type_raw"])184 if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""):185 lst.unit_type = ut186 if d.get("bathrooms"):187 lst.details = {**lst.details, "bathrooms": d["bathrooms"]}188 if d.get("images"):189 lst.images = list(dict.fromkeys(d["images"] + lst.images))[:20]190