# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/uptimo.py : connecteur Uptimo Gestion immobilière (uptimo.ca) # Apparts à Sherbrooke (Fleurimont, Mont-Bellevue, Jacques-Cartier…) et # environs. WordPress Themify + Post Type Builder : l'archive # /logements-a-louer/ (CPT « propriete », 15 cartes/page, ~6 pages) liste le # catalogue à louer — cartes .ptb_post avec titre, arrondissement # (taxonomie) et photo ; l'ID WordPress (classe post-) sert # d'external_id stable. Les fiches (via self.detail, cache BD) portent les # champs structurés : « Adresse: », « Ville: », « Prix: », # « Type de propriété: » (2 1/2…), salles de bain, description et galerie. # Aucun statut structuré de disponibilité — la date de libération n'apparaît # qu'en texte libre dans la description (textmine central). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.uptimo.ca" LIST_URL = f"{BASE}/logements-a-louer/" _POST_ID_RE = re.compile(r"\bpost-(\d+)\b") _VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) class UptimoConnector(BaseConnector): source_id = "uptimo" request_delay = 0.6 max_pages = 10 # garde-fou de pagination (6 pages observées) max_details = 90 # garde-fou fiches détail (77 propriétés au catalogue) def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for page in range(1, self.max_pages + 1): url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/" try: html = self.get(url).text except Exception: break soup = BeautifulSoup(html, "html.parser") cards = soup.select(".ptb_post") if not cards: break for card in cards: try: lst = self._parse_card(card) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # fiches détail (cache BD) : adresse, ville, prix, type, description, # salles de bain, galerie self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.sector}|{lst.images[:1]}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte d'archive ------------------------------------------------------------- def _parse_card(self, card) -> Listing | None: link = card.select_one("h3.ptb_post_title a[href]") if not link: return None url = link["href"] title = re.sub(r"\s+", " ", link.get_text(" ", strip=True)).strip() m = _POST_ID_RE.search(" ".join(card.get("class") or [])) ext_id = m.group(1) if m else re.sub(r".*/propriete/([^/]+)/?.*", r"\1", url) # arrondissement (taxonomie) : « Sherbrooke-Fleurimont » sector = "" tax = card.select_one(".ptb_taxonomies_tous_les_arrondissements") if tax: sector = re.sub(r"\s+", " ", tax.get_text(" ", strip=True)).strip() sector = re.sub(r"^Sherbrooke-", "", sector) img = card.select_one("img[src]") images = [] if img and str(img.get("src", "")).startswith("http"): images.append(_VARIANT_IMG.sub("", img["src"])) # type d'unité dans le titre le cas échéant (« - Studio », « 3 1/2 ») unit_type = normalize_unit_type(title) if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", unit_type or ""): unit_type = "" return Listing( source=self.source_id, external_id=str(ext_id), url=url, title=title, address="", # complété par la fiche sector=sector, city="Sherbrooke", unit_type=unit_type, availability="", # aucun statut structuré publié images=images, ) # -- fiche propriété (modules PTB) --------------------------------------------- def _fetch_detail(self, url: str) -> dict: if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} def module_text(sel: str) -> str: el = soup.select_one(sel) return re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if el else "" addr = module_text(".ptb_proprite_adresse") if addr: out["address"] = re.sub(r"^Adresse\s*:\s*", "", addr, flags=re.I) prix = module_text(".ptb_proprite_prix") if prix: out["price_label"] = re.sub(r"^Prix\s*:\s*", "", prix, flags=re.I) # taxonomies : « Ville: Sherbrooke », « Type de propriété: 2 1/2 », # « Nombre de salle(s) de bain: 1 » for tax in soup.select(".ptb_taxonomies"): t = re.sub(r"\s+", " ", tax.get_text(" ", strip=True)).strip() if t.lower().startswith("ville"): out["city"] = re.sub(r"^Ville\s*:\s*", "", t, flags=re.I) elif t.lower().startswith("type de propriété"): out["type_raw"] = re.sub(r"^Type de propriété\s*:\s*", "", t, flags=re.I) elif "salle(s) de bain" in t.lower(): m = re.search(r"(\d+)\s*$", t) if m: out["bathrooms"] = int(m.group(1)) desc_el = soup.select_one(".ptb_textarea") if desc_el: out["description"] = re.sub( r"[ \t]+", " ", desc_el.get_text("\n", strip=True)).strip()[:1500] images: list[str] = [] for img in soup.select(".ptb_gallery img[src], " ".ptb_proprite_image_principal img[src]"): src = _VARIANT_IMG.sub("", str(img.get("src") or "")) if src.startswith("http") and src not in images: images.append(src) out["images"] = images[:20] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("address"): lst.address = d["address"] if d.get("city"): # la taxonomie « Ville » mêle parfois la province (« Québec », # « Québec, Sherbrooke ») : ne retenir qu'une ville réelle connue m = re.search(r"\b(Sherbrooke|Magog|Windsor|East Angus|Coaticook|" r"Ascot Corner|Lennoxville|Richmond)\b", d["city"], re.I) if m: lst.city = m.group(1).title() if d.get("price_label"): lst.price_label = d["price_label"] lst.price = parse_price(re.sub(r"(\d)[,\s](\d{3})", r"\1\2", d["price_label"])) if d.get("description"): lst.description = d["description"] if not lst.unit_type and d.get("type_raw"): ut = normalize_unit_type(d["type_raw"]) if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): lst.unit_type = ut if d.get("bathrooms"): lst.details = {**lst.details, "bathrooms": d["bathrooms"]} if d.get("images"): lst.images = list(dict.fromkeys(d["images"] + lst.images))[:20]