SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.5 KB · 171 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/groupe_theoret.py : connecteur Groupe Théorêt (locationappartement.ca)5#   GoDaddy Website Builder. La page « Appartements à louer » expose un widget6#   « menu » : une section par immeuble (adresse + ville) et un item par unité7#   (type, prix/mois, disponibilité) ; le lien « Plus d'informations » porte un8#   UUID stable (data-section-jump) qui sert d'external_id. Les pages immeuble9#   (cache BD) ajoutent l'adresse complète et les services inclus.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import hashlib14import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, normalize_unit_type, parse_price, strip_accents19from .base import BaseConnector2021BASE = "https://locationappartement.ca"22LIST_URL = f"{BASE}/appartements-%C3%A0-louer"2324# villes réelles du parc (les titres varient en casse : STE-THÉRÈSE, MONTREAL…)25_CITY_MAP = {26    "montreal": "Montréal",27    "laval": "Laval",28    "charlemagne": "Charlemagne",29    "terrebonne": "Terrebonne",30    "ste-therese": "Sainte-Thérèse",31    "sainte-therese": "Sainte-Thérèse",32    "shawinigan": "Shawinigan",33    "grand-mere": "Shawinigan",     # secteur fusionné de Shawinigan34}353637def _city_from(raw: str) -> str:38    key = strip_accents(raw.strip().lower())39    return _CITY_MAP.get(key, raw.strip().title())404142class GroupeTheoretConnector(BaseConnector):43    source_id = "groupe_theoret"44    request_delay = 0.845    max_pages = 1        # tout le parc annoncé tient sur la page « menu »46    max_details = 20     # garde-fou pages immeuble (vraies requêtes)4748    def fetch(self) -> list[Listing]:49        html = self.get(LIST_URL).text50        soup = BeautifulSoup(html, "html.parser")5152        listings: list[Listing] = []53        buildings: dict[str, list[Listing]] = {}   # slug page immeuble -> annonces54        for n in range(0, 60):55            title_el = soup.select_one(f'[data-aid="MENU_SECTION_TITLE_{n}"]')56            cont = soup.select_one(f'[data-aid="MENU_ITEM_CONTAINER_{n}"]')57            if not (title_el and cont):58                break59            # « 5080 Pie-IX, Montréal » -> adresse + ville réelle60            sec_title = title_el.get_text(" ", strip=True)61            parts = [p.strip() for p in sec_title.split(",")]62            address = parts[0]63            city = _city_from(parts[-1]) if len(parts) > 1 else ""64            occ: dict[str, int] = {}    # occurrence par type dans l'immeuble65            for m in range(0, 40):66                lst = self._parse_item(soup, n, m, sec_title, address, city, occ)67                if lst is None:68                    break69                listings.append(lst)70                slug = lst.url.replace(BASE, "").split("#")[0].strip("/")71                # certains liens « Plus d'informations » pointent vers le72                # mauvais immeuble : on ne rattache la page immeuble que si73                # son slug correspond à l'adresse de la section, et on replie74                # l'URL de l'annonce sur la page liste en cas de lien erroné75                if slug and self._slug_matches(slug, address):76                    buildings.setdefault(slug, []).append(lst)77                elif slug:78                    lst.url = LIST_URL7980        # pages immeuble (cache BD, 1 requête par immeuble) : adresse complète81        # + « Services disponibles » (inclusions) partagés par leurs unités82        self._fetched = 083        for slug, group in buildings.items():84            key = hashlib.sha1("|".join(85                f"{l.title}|{l.price_label}|{l.availability}" for l in group)86                .encode("utf-8")).hexdigest()87            try:88                payload = self.detail(f"bldg:{slug}", key,89                                      lambda s=slug: self._fetch_building(s))90            except Exception:91                continue92            for l in group:93                if payload.get("amenities"):94                    l.amenities = payload["amenities"]95        return listings9697    @staticmethod98    def _slug_matches(slug: str, address: str) -> bool:99        """Le slug de page immeuble correspond-il à l'adresse de la section ?100        (garde-fou contre les liens « Plus d'informations » erronés du site)"""101        slug_k = strip_accents(slug.lower())102        tokens = [t for t in re.split(r"[^a-z0-9]+",103                                      strip_accents(address.lower())) if len(t) > 2]104        return any(t in slug_k for t in tokens)105106    # -- item du widget « menu » GoDaddy ------------------------------------------107    def _parse_item(self, soup, n: int, m: int, sec_title: str,108                    address: str, city: str, occ: dict) -> Listing | None:109        title_el = soup.select_one(f'[data-aid="MENU_SECTION{n}_ITEM{m}_TITLE"]')110        if not title_el:111            return None112        unit_title = title_el.get_text(" ", strip=True)      # « 3 1/2 »113        price_el = soup.select_one(f'[data-aid="MENU_SECTION{n}_ITEM{m}_PRICE"]')114        desc_el = soup.select_one(f'[data-aid="MENU_SECTION{n}_ITEM{m}_DESC"]')115        price_label = price_el.get_text(" ", strip=True) if price_el else ""116117        availability, url = "", LIST_URL118        if desc_el:119            link = desc_el.select_one("a[href]")120            if link:121                url = link["href"]122                if url.startswith("/"):123                    url = BASE + url124            txt = desc_el.get_text(" ", strip=True)125            txt = re.sub(r"Plus d'informations\s*$", "", txt).strip()126            availability = re.sub(r"^Disponibilit[ée]\s*:\s*", "", txt).strip()127128        # external_id : empreinte immeuble + type + rang parmi les unités de129        # même type de l'immeuble (les ancres UUID du builder sont dupliquées130        # entre items — inutilisables comme identifiant)131        k = occ.get(unit_title, 0)132        occ[unit_title] = k + 1133        ext_id = hashlib.sha1(134            f"{sec_title}|{unit_title}|{k}".encode("utf-8")).hexdigest()[:16]135136        return Listing(137            source=self.source_id,138            external_id=ext_id,139            url=url,140            title=f"{unit_title} au {address}, {city}".strip(", "),141            address=address,142            city=city,143            unit_type=normalize_unit_type(unit_title),144            price=parse_price(price_label),145            price_label=price_label,146            availability=availability,147        )148149    # -- page immeuble --------------------------------------------------------------150    def _fetch_building(self, slug: str) -> dict:151        """« Services disponibles » (Eau chaude (Inclus)…) de la page immeuble."""152        if self._fetched >= self.max_details:153            raise RuntimeError("budget de pages immeuble atteint")154        self._fetched += 1155        html = self.get(f"{BASE}/{slug}").text156        soup = BeautifulSoup(html, "html.parser")157        out: dict = {}158        lines = (soup.body.get_text("\n", strip=True) if soup.body else "").split("\n")159        try:160            i = lines.index("Services disponibles")161        except ValueError:162            return out163        amen: list[str] = []164        for line in lines[i + 1:i + 15]:165            if re.search(r"Canada|T[ée]l[ée]phone|Bureau|Cellulaire|^Vos\b", line):166                break167            if line and line not in amen:168                amen.append(line)169        out["amenities"] = amen[:12]170        return out171