# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sherplex.py : connecteur Sherplex immobilier # (appartementssherbrooke.com) — appartements neufs à louer en Estrie : # Weedon, Coaticook, Magog, Saint-François-Xavier-de-Brompton et # Saint-Denis-de-Brompton. Squarespace rendu serveur : pages de secteur → # pages de projet avec sections texte « Prix » (« 4 ½ : 1370$ à 1400$ », # « Meublé à partir de : 1450$ ») et « Disponibilité ». Granularité : # typologie par projet (pas d'unités individuelles). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from .base import BaseConnector from ..schema import Listing, normalize_unit_type BASE = "https://www.appartementssherbrooke.com" SECTORS = [ ("appartements-a-louer-weedon", "Weedon"), ("coaticook", "Coaticook"), ("magog", "Magog"), ("saint-francois-xavier-de-brompton", "Saint-François-Xavier-de-Brompton"), ("saintdenis-de-brompton", "Saint-Denis-de-Brompton"), ] # « 4 ½ : 1370$ à 1400$ » TYPE_PRICE_RE = re.compile(r"^(\d)\s*(?:½|1/2)\s*[::]\s*" r"(\d[\d\s ]{2,6})\$(?:\s*à\s*" r"(\d[\d\s ]{2,6})\$)?", re.I) # « Meublé à partir de : 1450$ » (Auberge Magog) FURN_PRICE_RE = re.compile(r"^((?:semi-)?meublé)\s*à\s*partir\s*de\s*[::]?\s*" r"(\d[\d\s ]{2,6})\$", re.I) TITLE_TYPES_RE = re.compile(r"(\d)\s*½") IMG_RE = re.compile(r'https://images\.squarespace-cdn\.com/content/' r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I) def _num(s: str) -> float | None: try: return float(re.sub(r"[\s ]", "", s)) except (TypeError, ValueError): return None class SherplexConnector(BaseConnector): source_id = "sherplex" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] for sector, city in SECTORS: try: html = self.get(f"{BASE}/{sector}").text except Exception: continue paths = sorted(set(re.findall( r'href="(/%s/[a-z0-9-]+)"' % re.escape(sector), html))) for path in paths: try: listings += self._project(path, city) except Exception: continue # dédoublonnage par external_id uniq: dict[str, Listing] = {} for l in listings: uniq.setdefault(l.external_id, l) return list(uniq.values()) def _project(self, path: str, city: str) -> list[Listing]: url = BASE + path html = self.get(url).text soup = BeautifulSoup(html, "html.parser") lines = [l.strip() for l in soup.get_text("\n", strip=True).split("\n")] name, title_txt = "", "" mt = re.search(r"([^<]+)", html) if mt: title_txt = re.sub(r"\s*(?:—|—)\s*Sherplex.*$", "", mt.group(1)).strip() name = title_txt.split("|")[0].strip() slug = path.rstrip("/").rsplit("/", 1)[-1] availability = "" for i, l in enumerate(lines): if l == "Disponibilité" and i + 1 < len(lines): availability = lines[i + 1].strip(" .") break images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"favicon|logo|icon", u, re.I)][:15] out: list[Listing] = [] def add(unit_type: str, type_slug: str, price: float | None, price_label: str, furnished: bool = False) -> None: out.append(Listing( source=self.source_id, external_id=f"{slug}-{type_slug}", url=url, title=f"{unit_type} — {name or city} (Sherplex)", city=city, unit_type=unit_type, price=price, price_label=price_label, availability=availability, details=({"project": name} if name else {}) | ({"furnished": True} if furnished else {}), description=f"Appartements neufs Sherplex à {city}. " f"{name}." if name else f"Appartements neufs Sherplex à {city}.", images=images, )) # 1) lignes de prix par typologie for l in lines: m = TYPE_PRICE_RE.match(l) if m: ut = normalize_unit_type(f"{m.group(1)}½") lo = _num(m.group(2)) label = (f"{lo:.0f}$ à {_num(m.group(3)):.0f}$/mois" if m.group(3) and _num(m.group(3)) else f"À partir de {lo:.0f}$/mois") add(ut, ut.replace("½", ".5"), lo, label) continue m = FURN_PRICE_RE.match(l) if m: # typologie tirée du (« Appartements 3 ½ à louer… ») types = TITLE_TYPES_RE.findall(html[:2000]) ut = normalize_unit_type(f"{types[0]}½") if types else "" kind = m.group(1).lower().replace("é", "e") price = _num(m.group(2)) add(ut, f"{ut.replace('½', '.5') or 'x'}-{kind}", price, f"{m.group(1).capitalize()} à partir de {price:.0f}$/mois", furnished=("semi" not in kind)) # 2) aucun prix publié : une annonce par typologie du <title> if not out: for t in dict.fromkeys(TITLE_TYPES_RE.findall(title_txt)): ut = normalize_unit_type(f"{t}½") add(ut, ut.replace("½", ".5"), None, "Prix sur demande") return out