spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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