# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/groupe_theoret.py : connecteur Groupe Théorêt (locationappartement.ca) # GoDaddy Website Builder. La page « Appartements à louer » expose un widget # « menu » : une section par immeuble (adresse + ville) et un item par unité # (type, prix/mois, disponibilité) ; le lien « Plus d'informations » porte un # UUID stable (data-section-jump) qui sert d'external_id. Les pages immeuble # (cache BD) ajoutent l'adresse complète et les services inclus. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://locationappartement.ca" LIST_URL = f"{BASE}/appartements-%C3%A0-louer" # villes réelles du parc (les titres varient en casse : STE-THÉRÈSE, MONTREAL…) _CITY_MAP = { "montreal": "Montréal", "laval": "Laval", "charlemagne": "Charlemagne", "terrebonne": "Terrebonne", "ste-therese": "Sainte-Thérèse", "sainte-therese": "Sainte-Thérèse", "shawinigan": "Shawinigan", "grand-mere": "Shawinigan", # secteur fusionné de Shawinigan } def _city_from(raw: str) -> str: key = strip_accents(raw.strip().lower()) return _CITY_MAP.get(key, raw.strip().title()) class GroupeTheoretConnector(BaseConnector): source_id = "groupe_theoret" request_delay = 0.8 max_pages = 1 # tout le parc annoncé tient sur la page « menu » max_details = 20 # garde-fou pages immeuble (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: list[Listing] = [] buildings: dict[str, list[Listing]] = {} # slug page immeuble -> annonces for n in range(0, 60): title_el = soup.select_one(f'[data-aid="MENU_SECTION_TITLE_{n}"]') cont = soup.select_one(f'[data-aid="MENU_ITEM_CONTAINER_{n}"]') if not (title_el and cont): break # « 5080 Pie-IX, Montréal » -> adresse + ville réelle sec_title = title_el.get_text(" ", strip=True) parts = [p.strip() for p in sec_title.split(",")] address = parts[0] city = _city_from(parts[-1]) if len(parts) > 1 else "" occ: dict[str, int] = {} # occurrence par type dans l'immeuble for m in range(0, 40): lst = self._parse_item(soup, n, m, sec_title, address, city, occ) if lst is None: break listings.append(lst) slug = lst.url.replace(BASE, "").split("#")[0].strip("/") # certains liens « Plus d'informations » pointent vers le # mauvais immeuble : on ne rattache la page immeuble que si # son slug correspond à l'adresse de la section, et on replie # l'URL de l'annonce sur la page liste en cas de lien erroné if slug and self._slug_matches(slug, address): buildings.setdefault(slug, []).append(lst) elif slug: lst.url = LIST_URL # pages immeuble (cache BD, 1 requête par immeuble) : adresse complète # + « Services disponibles » (inclusions) partagés par leurs unités self._fetched = 0 for slug, group in buildings.items(): key = hashlib.sha1("|".join( f"{l.title}|{l.price_label}|{l.availability}" for l in group) .encode("utf-8")).hexdigest() try: payload = self.detail(f"bldg:{slug}", key, lambda s=slug: self._fetch_building(s)) except Exception: continue for l in group: if payload.get("amenities"): l.amenities = payload["amenities"] return listings @staticmethod def _slug_matches(slug: str, address: str) -> bool: """Le slug de page immeuble correspond-il à l'adresse de la section ? (garde-fou contre les liens « Plus d'informations » erronés du site)""" slug_k = strip_accents(slug.lower()) tokens = [t for t in re.split(r"[^a-z0-9]+", strip_accents(address.lower())) if len(t) > 2] return any(t in slug_k for t in tokens) # -- item du widget « menu » GoDaddy ------------------------------------------ def _parse_item(self, soup, n: int, m: int, sec_title: str, address: str, city: str, occ: dict) -> Listing | None: title_el = soup.select_one(f'[data-aid="MENU_SECTION{n}_ITEM{m}_TITLE"]') if not title_el: return None unit_title = title_el.get_text(" ", strip=True) # « 3 1/2 » price_el = soup.select_one(f'[data-aid="MENU_SECTION{n}_ITEM{m}_PRICE"]') desc_el = soup.select_one(f'[data-aid="MENU_SECTION{n}_ITEM{m}_DESC"]') price_label = price_el.get_text(" ", strip=True) if price_el else "" availability, url = "", LIST_URL if desc_el: link = desc_el.select_one("a[href]") if link: url = link["href"] if url.startswith("/"): url = BASE + url txt = desc_el.get_text(" ", strip=True) txt = re.sub(r"Plus d'informations\s*$", "", txt).strip() availability = re.sub(r"^Disponibilit[ée]\s*:\s*", "", txt).strip() # external_id : empreinte immeuble + type + rang parmi les unités de # même type de l'immeuble (les ancres UUID du builder sont dupliquées # entre items — inutilisables comme identifiant) k = occ.get(unit_title, 0) occ[unit_title] = k + 1 ext_id = hashlib.sha1( f"{sec_title}|{unit_title}|{k}".encode("utf-8")).hexdigest()[:16] return Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{unit_title} au {address}, {city}".strip(", "), address=address, city=city, unit_type=normalize_unit_type(unit_title), price=parse_price(price_label), price_label=price_label, availability=availability, ) # -- page immeuble -------------------------------------------------------------- def _fetch_building(self, slug: str) -> dict: """« Services disponibles » (Eau chaude (Inclus)…) de la page immeuble.""" if self._fetched >= self.max_details: raise RuntimeError("budget de pages immeuble atteint") self._fetched += 1 html = self.get(f"{BASE}/{slug}").text soup = BeautifulSoup(html, "html.parser") out: dict = {} lines = (soup.body.get_text("\n", strip=True) if soup.body else "").split("\n") try: i = lines.index("Services disponibles") except ValueError: return out amen: list[str] = [] for line in lines[i + 1:i + 15]: if re.search(r"Canada|T[ée]l[ée]phone|Bureau|Cellulaire|^Vos\b", line): break if line and line not in amen: amen.append(line) out["amenities"] = amen[:12] return out