SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
5.9 KB · 149 lines python
Raw Blame History
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*(?:—|&mdash;)\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