Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/sherplex.py : connecteur Sherplex immobilier5# (appartementssherbrooke.com) — appartements neufs à louer en Estrie :6# Weedon, Coaticook, Magog, Saint-François-Xavier-de-Brompton et7# Saint-Denis-de-Brompton. Squarespace rendu serveur : pages de secteur →8# pages de projet avec sections texte « Prix » (« 4 ½ : 1370$ à 1400$ »,9# « Meublé à partir de : 1450$ ») et « Disponibilité ». Granularité :10# typologie par projet (pas d'unités individuelles).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re1516from bs4 import BeautifulSoup1718from .base import BaseConnector19from ..schema import Listing, normalize_unit_type2021BASE = "https://www.appartementssherbrooke.com"2223SECTORS = [24 ("appartements-a-louer-weedon", "Weedon"),25 ("coaticook", "Coaticook"),26 ("magog", "Magog"),27 ("saint-francois-xavier-de-brompton",28 "Saint-François-Xavier-de-Brompton"),29 ("saintdenis-de-brompton", "Saint-Denis-de-Brompton"),30]3132# « 4 ½ : 1370$ à 1400$ »33TYPE_PRICE_RE = re.compile(r"^(\d)\s*(?:½|1/2)\s*[::]\s*"34 r"(\d[\d\s ]{2,6})\$(?:\s*à\s*"35 r"(\d[\d\s ]{2,6})\$)?", re.I)36# « Meublé à partir de : 1450$ » (Auberge Magog)37FURN_PRICE_RE = re.compile(r"^((?:semi-)?meublé)\s*à\s*partir\s*de\s*[::]?\s*"38 r"(\d[\d\s ]{2,6})\$", re.I)39TITLE_TYPES_RE = re.compile(r"(\d)\s*½")40IMG_RE = re.compile(r'https://images\.squarespace-cdn\.com/content/'41 r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)424344def _num(s: str) -> float | None:45 try:46 return float(re.sub(r"[\s ]", "", s))47 except (TypeError, ValueError):48 return None495051class SherplexConnector(BaseConnector):52 source_id = "sherplex"53 request_delay = 0.65455 def fetch(self) -> list[Listing]:56 listings: list[Listing] = []57 for sector, city in SECTORS:58 try:59 html = self.get(f"{BASE}/{sector}").text60 except Exception:61 continue62 paths = sorted(set(re.findall(63 r'href="(/%s/[a-z0-9-]+)"' % re.escape(sector), html)))64 for path in paths:65 try:66 listings += self._project(path, city)67 except Exception:68 continue69 # dédoublonnage par external_id70 uniq: dict[str, Listing] = {}71 for l in listings:72 uniq.setdefault(l.external_id, l)73 return list(uniq.values())7475 def _project(self, path: str, city: str) -> list[Listing]:76 url = BASE + path77 html = self.get(url).text78 soup = BeautifulSoup(html, "html.parser")79 lines = [l.strip() for l in80 soup.get_text("\n", strip=True).split("\n")]8182 name, title_txt = "", ""83 mt = re.search(r"<title>([^<]+)</title>", html)84 if mt:85 title_txt = re.sub(r"\s*(?:—|—)\s*Sherplex.*$", "",86 mt.group(1)).strip()87 name = title_txt.split("|")[0].strip()88 slug = path.rstrip("/").rsplit("/", 1)[-1]8990 availability = ""91 for i, l in enumerate(lines):92 if l == "Disponibilité" and i + 1 < len(lines):93 availability = lines[i + 1].strip(" .")94 break9596 images = [u for u in dict.fromkeys(IMG_RE.findall(html))97 if not re.search(r"favicon|logo|icon", u, re.I)][:15]9899 out: list[Listing] = []100101 def add(unit_type: str, type_slug: str, price: float | None,102 price_label: str, furnished: bool = False) -> None:103 out.append(Listing(104 source=self.source_id,105 external_id=f"{slug}-{type_slug}",106 url=url,107 title=f"{unit_type} — {name or city} (Sherplex)",108 city=city,109 unit_type=unit_type,110 price=price,111 price_label=price_label,112 availability=availability,113 details=({"project": name} if name else {})114 | ({"furnished": True} if furnished else {}),115 description=f"Appartements neufs Sherplex à {city}. "116 f"{name}." if name else117 f"Appartements neufs Sherplex à {city}.",118 images=images,119 ))120121 # 1) lignes de prix par typologie122 for l in lines:123 m = TYPE_PRICE_RE.match(l)124 if m:125 ut = normalize_unit_type(f"{m.group(1)}½")126 lo = _num(m.group(2))127 label = (f"{lo:.0f}$ à {_num(m.group(3)):.0f}$/mois"128 if m.group(3) and _num(m.group(3))129 else f"À partir de {lo:.0f}$/mois")130 add(ut, ut.replace("½", ".5"), lo, label)131 continue132 m = FURN_PRICE_RE.match(l)133 if m:134 # typologie tirée du <title> (« Appartements 3 ½ à louer… »)135 types = TITLE_TYPES_RE.findall(html[:2000])136 ut = normalize_unit_type(f"{types[0]}½") if types else ""137 kind = m.group(1).lower().replace("é", "e")138 price = _num(m.group(2))139 add(ut, f"{ut.replace('½', '.5') or 'x'}-{kind}", price,140 f"{m.group(1).capitalize()} à partir de {price:.0f}$/mois",141 furnished=("semi" not in kind))142143 # 2) aucun prix publié : une annonce par typologie du <title>144 if not out:145 for t in dict.fromkeys(TITLE_TYPES_RE.findall(title_txt)):146 ut = normalize_unit_type(f"{t}½")147 add(ut, ut.replace("½", ".5"), None, "Prix sur demande")148 return out149