SPB Git

spb/lou-ka Public

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

HTML 99.7%
11.0 KB · 260 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/equinoxe_batimo.py : connecteur Collection Équinoxe (Batimo)5#   (collectionequinoxe.com — Équinoxe Saint-Elzéar / Lévesque / Lévesque O. /6#    Daniel-Johnson à Laval, Bois-Franc à Saint-Laurent (Montréal),7#    Le Carlyle à Mont-Royal, Le Westpark à Pointe-Claire).8#   La page /disponibilites/ affiche un tableau JetEngine paginé (10 unités9#   par page). La pagination passe par l'AJAX JetSmartFilters :10#   POST /wp-admin/admin-ajax.php  action=jet_smart_filters,11#   provider=jet-data-table/result, paged=N -> tbody HTML complet12#   (photos de l'unité incluses dans chaque rangée).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import re18import time1920from bs4 import BeautifulSoup2122from ..schema import Listing, parse_price23from .base import BaseConnector2425BASE = "https://collectionequinoxe.com"26DISPO_URL = f"{BASE}/disponibilites/"27AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"2829# Projet (colonne du tableau) -> (ville, secteur, page projet, adresse civique)30# Adresses vérifiées dans le bloc contact de chaque page projet (les tours31# Lévesque/Lévesque O. sont au 3030 et 3080, boul. Lévesque O., Chomedey).32PROJECTS = {33    "saint-elzear": ("Laval", "Chomedey", f"{BASE}/saint-elzear/",34                     "3830, boulevard Saint-Elzéar Ouest, Laval"),35    "levesque ouest": ("Laval", "Chomedey", f"{BASE}/levesqueouest/",36                       "3080, boulevard Lévesque Ouest, Laval"),37    "levesque": ("Laval", "Chomedey", f"{BASE}/levesque/",38                 "3030, boulevard Lévesque Ouest, Laval"),39    "daniel-johnson": ("Laval", "Chomedey", f"{BASE}/daniel-johnson/",40                       "3105, promenade du Quartier-Saint-Martin, Laval"),41    "bois-franc": ("Montréal", "Bois-Franc (Saint-Laurent)",42                   f"{BASE}/boisfranc/",43                   "5375, boulevard Henri-Bourassa Ouest, Saint-Laurent"),44    "le carlyle": ("Mont-Royal", "Ville de Mont-Royal", f"{BASE}/carlyle/",45                   "815, avenue Plymouth, Mont-Royal"),46    "carlyle": ("Mont-Royal", "Ville de Mont-Royal", f"{BASE}/carlyle/",47                "815, avenue Plymouth, Mont-Royal"),48    "le westpark": ("Pointe-Claire", "West Island", f"{BASE}/westpark/",49                    "265, boulevard Brunswick, Pointe-Claire"),50    "westpark": ("Pointe-Claire", "West Island", f"{BASE}/westpark/",51                 "265, boulevard Brunswick, Pointe-Claire"),52}535455def _project_meta(project: str) -> tuple[str, str, str, str]:56    key = project.strip().lower()57    key = (key.replace("é", "e").replace("è", "e").replace("ê", "e")58           .replace("ô", "o"))59    for name, meta in PROJECTS.items():60        if name in key:61            return meta62    return ("", "", DISPO_URL, "")636465def _unit_type(bedrooms: str, type_label: str) -> str:66    s = (type_label or "").lower()67    if "studio" in s:68        return "Studio"69    try:70        n = int(str(bedrooms).strip())71    except (TypeError, ValueError):72        return ""73    if n <= 0:74        return "Studio"75    return f"{n + 2}½"767778class EquinoxeBatimoConnector(BaseConnector):79    source_id = "equinoxe_batimo"80    request_delay = 0.681    max_pages = 80           # garde-fou (~10 unités/page)8283    def __init__(self) -> None:84        super().__init__()85        # commodités/inclusions par page projet (1 requête par projet)86        self._building_amenities: dict[str, list[str]] = {}8788    def fetch(self) -> list[Listing]:89        listings: dict[str, Listing] = {}9091        # 1) Page initiale : rangées 1-10 + nombre total de pages92        try:93            html = self.get(DISPO_URL).text94        except Exception:95            return []96        max_pages = 197        m = re.search(r"var JetSmartFilterSettings = (\{.*?\});", html, re.S)98        if m:99            try:100                settings, _ = json.JSONDecoder().raw_decode(m.group(1))101                max_pages = int(settings["props"]["jet-data-table"]["result"]102                                ["max_num_pages"])103            except Exception:104                max_pages = 1105        self._parse_rows(html, listings)106107        # 2) Pages suivantes via l'AJAX JetSmartFilters108        for page in range(2, min(max_pages, self.max_pages) + 1):109            try:110                frag = self._ajax_page(page)111            except Exception:112                continue113            if not frag.strip():          # plus de contenu -> stop114                break115            # NB : le tri du site n'est pas stable (égalités de loyer),116            # on ne s'arrête donc pas sur une page sans nouveauté.117            self._parse_rows(frag, listings)118119        return list(listings.values())120121    # -- AJAX -----------------------------------------------------------------122    def _ajax_page(self, page: int) -> str:123        wait = self.request_delay - (time.time() - self._last_request)124        if wait > 0:125            time.sleep(wait)126        resp = self.session.post(AJAX_URL, data={127            "action": "jet_smart_filters",128            "provider": "jet-data-table/result",129            "settings[table_id]": "6",130            "settings[thead]": "yes",131            "paged": str(page),132        }, headers={"X-Requested-With": "XMLHttpRequest"},133            timeout=self.timeout)134        self._last_request = time.time()135        resp.raise_for_status()136        return (resp.json() or {}).get("content") or ""137138    # -- commodités de l'immeuble (page projet) ---------------------------------139    def _project_amenities(self, url: str) -> list[str]:140        """Items des sections « Aires communes » / « Inclusions » de la page141        projet (titres Elementor sous les pictogrammes). Texte source fidèle."""142        if url in self._building_amenities:143            return self._building_amenities[url]144        items: list[str] = []145        try:146            html = self.get(url).text147            m = re.search(r"AIRES COMMUNES", html, re.I)148            if m:149                end = re.search(r"ADOPTEZ LE QUARTIER|LA Vie à Équinoxe",150                                html[m.end():], re.I)151                seg = html[m.end(): m.end() + (end.start() if end else 40000)]152                for mm in re.finditer(153                        r'class="elementor-heading-title[^"]*">([^<]{3,70})<',154                        seg):155                    t = re.sub(r"\s+", " ", mm.group(1)).replace("​", "")156                    t = t.strip()157                    # titres de section et boutons, pas des commodités158                    if (not t or re.search(r"voir|condos|contact", t, re.I)159                            or re.fullmatch(r"(?i)inclusions?", t)160                            or re.match(r"(?i)les aires communes", t)):161                        continue162                    if t not in items:163                        items.append(t)164        except Exception:165            items = []166        self._building_amenities[url] = items167        return items168169    # -- parsing du tableau -----------------------------------------------------170    def _parse_rows(self, html: str, listings: dict[str, Listing]) -> None:171        soup = BeautifulSoup(html, "html.parser")172        for row in soup.select("tr[data-item-object]"):173            try:174                ext_id = row.get("data-item-object") or ""175                if not ext_id or ext_id in listings:176                    continue177178                def col(name: str) -> str:179                    el = row.select_one(f"td.jet-dynamic-table__col--{name}")180                    return el.get_text(" ", strip=True) if el else ""181182                unit_no = col("unit")183                type_label = col("type")184                sqft = col("pi")185                project = col("projet")186                phase = col("phase")187                rent = col("loyer")188                bedrooms = col("dechambres")189                bathrooms = col("desdb")190                dispo = col("disponibilit")191192                if not project or not rent:193                    continue194                city, sector, proj_url, address = _project_meta(project)195                if not city:196                    continue          # projet inconnu -> prudence197198                # La colonne dispo contient parfois 2 dates (dispo + du jour)199                m = re.match(200                    r"((?:\d{1,2}(?:er)?\s+\S+\s+\d{4})|Maintenant|Immédiate)",201                    dispo, re.I)202                availability = m.group(1) if m else dispo.split("  ")[0]203204                # Photos de l'unité (galerie de la rangée) + plan205                images = []206                for img in row.select("img"):207                    u = img.get("src") or img.get("data-src") or ""208                    if u.startswith("http") and not re.search(209                            r"logo|icon|favicon", u, re.I):210                        images.append(u)211                for a in row.select("a[href]"):212                    href = a.get("href") or ""213                    if re.search(r"/wp-content/uploads/.*\.(jpe?g|png|webp)$",214                                 href):215                        images.append(href)216                images = list(dict.fromkeys(images))217218                # superficie structurée (colonne « pi² ») -> area_sqft explicite219                area_sqft = None220                am = re.search(r"(\d[\d\s]{1,6}(?:[.,]\d+)?)\s*pi", sqft)221                if am:222                    v = float(am.group(1).replace(" ", "").replace(",", "."))223                    if 80 <= v <= 20000:224                        area_sqft = v225226                amenities = []227                if sqft:228                    amenities.append(f"Superficie {sqft}")229                if bathrooms:230                    amenities.append(f"{bathrooms} salle(s) de bain")231                # commodités/inclusions de l'immeuble (page projet, mise en cache)232                amenities += [a for a in self._project_amenities(proj_url)233                              if a not in amenities]234235                phase_txt = f" (phase {phase})" if phase else ""236                title = (f"Équinoxe {project}{phase_txt} — unité {unit_no}"237                         if "carlyle" not in project.lower()238                         and "westpark" not in project.lower()239                         else f"{project}{phase_txt} — unité {unit_no}")240241                listings[ext_id] = Listing(242                    source=self.source_id,243                    external_id=ext_id,244                    url=proj_url,245                    title=title,246                    address=address,247                    sector=sector,248                    city=city,249                    unit_type=_unit_type(bedrooms, type_label),250                    price=parse_price(rent),251                    price_label=f"{rent}/mois" if rent else "",252                    availability=availability,253                    area_sqft=area_sqft,254                    description=type_label,255                    amenities=amenities,256                    images=images,257                )258            except Exception:259                continue260