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/lokalia.py : connecteur Espaces Lokalia (espaceslokalia.ca)5# Le gestionnaire couvre plusieurs régions (Québec/Lévis + Grand Montréal :6# Laval, Longueuil/Rive-Sud, Montérégie, Rive-Nord proche...)7# -> on interroge l'endpoint AJAX WordPress `get_immeubles_map_data`8# région par région (slugs découverts dans le filtre du site), ce qui donne9# la ville par défaut ET les lat/lng structurés de chaque immeuble.10# Page immeuble : adresse structurée (lien Google Maps sous le H1),11# accordéons Inclusions / Équipements & sécurité / Aires communes,12# description (paragraphes réels + promotion + « N unités • N étages »),13# téléphone du bureau de location, grille « Prix et types d'unités ».14# Une annonce par type d'unité offert dans chaque immeuble (les variantes15# « + DEN » ou avec parenthèse gardent leur propre annonce).16# Ni disponibilité ni superficie ne sont publiées par la source.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import json21import re22import time2324from bs4 import BeautifulSoup2526from ..schema import Listing, infer_city, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://www.espaceslokalia.ca"30AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"31# slug de région (filtre du site) -> ville par défaut des immeubles32REGIONS = {33 # Région de Québec / Lévis34 "quebec": "Québec",35 "levis": "Lévis",36 # Laval37 "laval": "Laval",38 # Longueuil / Rive-Sud / Montérégie39 "longueuil": "Longueuil",40 "saint-hubert": "Longueuil",41 "brossard": "Brossard",42 "saint-lambert": "Saint-Lambert",43 "boucherville": "Boucherville",44 "saint-basile-le-grand": "Saint-Basile-le-Grand",45 "sainte-julie": "Sainte-Julie",46 "chambly": "Chambly",47 "saint-constant": "Saint-Constant",48 "delson": "Delson",49 "candiac": "Candiac",50 "chateauguay": "Châteauguay",51 "beauharnois": "Beauharnois",52 "salaberry-de-valleyfield": "Salaberry-de-Valleyfield",53 "granby": "Granby",54 # Rive-Nord proche55 "lachenaie": "Terrebonne",56 "terrebonne": "Terrebonne",57 "mascouche": "Mascouche",58 "saint-jerome": "Saint-Jérôme",59}6061# repli si le lien Google Maps sous le H1 est absent62ADDR_RE = re.compile(63 r"(?<![\d\)])\b\d{1,5}\s+(?:rue|route|rte|boulevard|bd\.?|avenue|av\.|"64 r"chemin|place|mont[ée]e|all[ée]e)\s[^,<>]{1,50},\s*[^,<>]{2,40},\s*"65 r"(?:QC|Qu[ée]bec)[^,<>]{0,12}(?:,\s*Canada)?", re.I)66IMG_RE = re.compile(r"https://www\.espaceslokalia\.ca/wp-content/uploads/"67 r'[^"\s\\)]+\.(?:jpe?g|webp)', re.I)686970def _slugify(s: str) -> str:71 return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-",72 s.lower().replace("½", ".5"))).strip("-")737475class LokaliaConnector(BaseConnector):76 source_id = "lokalia"77 request_delay = 0.678 max_buildings = 60 # garde-fou de crawl (toutes régions)7980 def _buildings(self) -> list[tuple[str, str, float | None, float | None]]:81 """Endpoint AJAX interrogé région par région82 -> [(url immeuble, ville par défaut, lat, lng), ...] dédupliqués."""83 out: list[tuple[str, str, float | None, float | None]] = []84 seen: set[str] = set()85 for region, city in REGIONS.items():86 try:87 time.sleep(self.request_delay) # politesse (POST direct)88 resp = self.session.post(89 AJAX_URL,90 data={"action": "get_immeubles_map_data",91 "regions": json.dumps([region])},92 timeout=self.timeout,93 )94 resp.raise_for_status()95 items = resp.json()96 except Exception:97 continue98 for item in items:99 content = (item.get("list_content") or "") + \100 (item.get("popup_content") or "")101 m = re.search(r'href="(https://www\.espaceslokalia\.ca/'102 r'immeuble/[^"]+)"', content)103 if not m or m.group(1) in seen:104 continue105 seen.add(m.group(1))106 # lat/lng structurés fournis par la carte107 lat = lng = None108 try:109 lat = float(item.get("lat"))110 lng = float(item.get("lng"))111 except (TypeError, ValueError):112 lat = lng = None113 out.append((m.group(1), city, lat, lng))114 return out115116 def fetch(self) -> list[Listing]:117 listings: list[Listing] = []118 for url, default_city, lat, lng in self._buildings()[: self.max_buildings]:119 try:120 html = self.get(url).text121 except Exception:122 continue123 try:124 listings.extend(125 self._parse_building(url, html, default_city, lat, lng))126 except Exception:127 continue128 return listings129130 def _parse_building(self, url: str, html: str, default_city: str = "Québec",131 lat: float | None = None,132 lng: float | None = None) -> list[Listing]:133 soup = BeautifulSoup(html, "html.parser")134 slug = url.rstrip("/").split("/")[-1]135 h1 = soup.find("h1")136 title = h1.get_text(" ", strip=True) if h1 else slug137 # « Appartements à louer à Lévis - Vivaxcès Le Nicolas » -> nom court138 name = title.split(" - ")[-1].split("│")[-1].strip() or slug139140 # Adresse structurée : lien Google Maps sous le H1141 address = ""142 wrap = soup.select_one(".category-wrapper a.description[href*='maps.google']")143 if wrap:144 address = re.sub(r"\s+", " ", wrap.get_text(" ", strip=True)).strip()145 if not address:146 text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))147 m = ADDR_RE.search(text)148 address = m.group(0).strip() if m else ""149 # étiquettes de la fiche (« Nouveau », « Promotion »)150 tags = [t.get_text(strip=True)151 for t in soup.select(".category-wrapper span.category")]152153 # secteur / ville : « 89 Rue du Doré-Jaune, Lachenaie, Terrebonne, QC »154 sector = ""155 city = default_city156 if address:157 parts = [p.strip() for p in address.split(",")]158 if len(parts) >= 2:159 sector = parts[1]160 city = infer_city(sector, default=city)161 if not sector and "Lévis" in title:162 city = "Lévis"163 if sector and sector.lower() == city.lower():164 sector = "" # éviter secteur == ville (redondant)165166 # Description : paragraphes réels de la fiche + « N unités • N étages »167 # + promotion, sinon og:description en repli168 desc_bits: list[str] = []169 promo = soup.select_one(".promotion-main h2")170 if promo:171 desc_bits.append("Promotion : "172 + promo.get_text(" ", strip=True))173 elif "Promotion" in tags:174 desc_bits.append("Promotion en cours")175 paras = [p.get_text(" ", strip=True)176 for p in soup.select(".wx-single-description .description-left p")177 if "wx-disclaimer" not in (p.get("class") or [])]178 if paras:179 desc_bits.append(" ".join(t for t in paras if t))180 size = soup.select_one(".wx-single-description .category-wrap span")181 if size:182 desc_bits.append(re.sub(r"\s+", " ", size.get_text(" ", strip=True)))183 if not desc_bits:184 og = soup.find("meta", attrs={"property": "og:description"})185 if og and og.get("content"):186 desc_bits.append(og["content"].strip())187 desc = " — ".join(b for b in desc_bits if b)[:700]188189 # Commodités : inclusions (icônes avec attribut title) + accordéons190 # « Équipements & sécurité » et « Aires communes » (items <li>)191 amenities = [el.get("title").strip()192 for el in soup.select("div.icon-box[title]")193 if el.get("title")]194 for acc_cls in ("equipment", "aires"):195 for li in soup.select(f"li.wx-single-accordion.{acc_cls} ul li"):196 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))197 if t:198 amenities.append(t)199 amenities = list(dict.fromkeys(a for a in amenities if a))[:30]200201 # Téléphone du bureau de location (lien tel:)202 details: dict = {}203 tel = soup.select_one('a[href^="tel:"]')204 if tel:205 num = re.sub(r"\D", "", tel.get("href", ""))206 num = num[1:] if len(num) == 11 and num.startswith("1") else num207 if len(num) == 10:208 details["contact"] = {"phone": f"{num[:3]}-{num[3:6]}-{num[6:10]}"}209210 # Photos (exclure icônes .png des inclusions)211 images = [u for u in dict.fromkeys(IMG_RE.findall(html))212 if not re.search(r"logo|icon|favicon|-150x150", u, re.I)][:25]213214 # Types d'unités + prix : blocs .square-box (h5 = type, p = prix).215 # Dédupliqués par libellé BRUT : « 4 1/2 » et « 4 1/2 + DEN » restent216 # deux annonces (l'id de la 1re occurrence d'un type normalisé garde217 # son schéma historique ; les variantes reçoivent un suffixe du libellé).218 results: list[Listing] = []219 seen_raw: set[str] = set()220 seen_norm: set[str] = set()221 for box in soup.select("div.square-box"):222 h5 = box.find("h5")223 if not h5:224 continue225 unit_raw = re.sub(r"\s+", " ", h5.get_text(" ", strip=True)).strip()226 unit_type = normalize_unit_type(unit_raw)227 raw_key = _slugify(unit_raw)228 if not unit_type or not raw_key or raw_key in seen_raw:229 continue230 seen_raw.add(raw_key)231 if unit_type not in seen_norm:232 seen_norm.add(unit_type)233 ext_id = f"{slug}-{unit_type.replace('½', '.5')}"234 else: # variante (« + DEN », parenthèse…)235 ext_id = f"{slug}-{raw_key}"236 price_label = re.sub(r"\s+", " ",237 box.get_text(" ", strip=True)238 .replace(unit_raw, "", 1)).strip()239 pm = re.search(r"(?:À partir de\s*)?([\d\s]+)\$\s*/?\s*mois",240 price_label)241 price_label = (f"À partir de {pm.group(1).strip()}$/mois"242 if pm else price_label[:60])243 results.append(Listing(244 source=self.source_id,245 external_id=ext_id,246 url=url,247 title=f"{name} — {unit_raw}",248 address=address,249 sector=sector,250 city=city,251 unit_type=unit_type,252 price=parse_price(price_label),253 price_label=price_label,254 availability="",255 description=desc,256 amenities=amenities,257 details=dict(details),258 images=images,259 lat=lat,260 lng=lng,261 ))262263 # Immeuble sans grille de prix : annonce par immeuble quand même264 if not results:265 results.append(Listing(266 source=self.source_id,267 external_id=slug,268 url=url,269 title=name,270 address=address,271 sector=sector,272 city=city,273 description=desc,274 amenities=amenities,275 details=dict(details),276 images=images,277 lat=lat,278 lng=lng,279 ))280 return results281