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/multi_logis.py : connecteur Multi-Logis (multi-logis.com — 500+5# logements : Sept-Îles, Port-Cartier — plus gros parc de la Côte-Nord —6# et aussi Lévis, toutes les villes conservées). WordPress + Elementor +7# JetEngine + plugin maison « multilogis-listing », tout rendu serveur.8# Liste /trouver-mon-appartement/ : une carte par bâtiment (nom, adresse,9# typologies offertes, « À partir de »). Page /batiment/<slug>/ (via cache10# BD) : unités disponibles individuelles (numéro, type, description, loyer,11# superficie, disponibilité, galerie, plan), adresse structurée du champ ACF12# (ville, code postal, lat/lng) et listes « Inclus » / « Caractéristiques »13# de l'immeuble. Une annonce Lou-Ka = une unité disponible. Les champs des14# unités sont identifiés par leur CONTENU (motifs « $/mois », « Studio/n½ »,15# « Superficie: », « Disponibilité: ») — robustes aux ids Elementor.16# Immeubles commerciaux (« Le St-Georges ») sans unités : ignorés d'eux-mêmes.17# robots.txt ouvert, sitemap Yoast.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import hashlib22import re2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://multi-logis.com"30LIST_URL = f"{BASE}/trouver-mon-appartement/"3132# suffixe de redimensionnement WordPress (« -1024x1024.png » -> pleine taille)33_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)3435_TYPE_RE = re.compile(r"^(studio|\d\s*½|\d\s*1/2)$", re.I)36_ACF_RE = re.compile(r"'(address|city|post_code|lat|lng)'\s*=>\s*'?([^',\n]+)'?")373839def _clean_img(url: str) -> str:40 return _SIZE_SUFFIX.sub("", url.strip())414243class MultiLogisConnector(BaseConnector):44 source_id = "multi_logis"45 request_delay = 0.646 max_details = 25 # garde-fou pages bâtiment (vraies requêtes par sync)4748 def fetch(self) -> list[Listing]:49 html = self.get(LIST_URL).text50 soup = BeautifulSoup(html, "html.parser")5152 # cartes bâtiment (le sélecteur de carte duplique la grille : dédupliquer)53 buildings: dict[str, dict] = {}54 for item in soup.select(".jet-listing-grid__item"):55 link = item.select_one('a[href*="/batiment/"]')56 if not link:57 continue58 m = re.search(r"/batiment/([^/?#]+)", link["href"])59 if not m:60 continue61 slug = m.group(1).strip("/")62 fields = [re.sub(r"\s+", " ", f.get_text(" ", strip=True))63 for f in item.select(".jet-listing-dynamic-field__content")]64 fields = [f for f in fields if f]65 if slug in buildings and len(fields) <= len(buildings[slug]["fields"]):66 continue67 buildings[slug] = {"fields": fields}6869 self._fetched = 070 listings: list[Listing] = []71 for slug, info in buildings.items():72 try:73 listings.extend(self._parse_building(slug, info["fields"]))74 except Exception:75 continue76 return listings7778 # -- un bâtiment ------------------------------------------------------------------79 def _parse_building(self, slug: str, fields: list[str]) -> list[Listing]:80 url = f"{BASE}/batiment/{slug}/"81 name = fields[0] if fields else slug82 card_addr = next((f for f in fields if re.search(r",\s*QC", f, re.I)), "")8384 # page bâtiment (cache BD, invalidée quand la carte liste change)85 key = hashlib.sha1("|".join(fields).encode("utf-8")).hexdigest()8687 def fetch_fn():88 if self._fetched >= self.max_details:89 raise RuntimeError("budget de pages bâtiment atteint")90 self._fetched += 191 return self._fetch_building(url)9293 payload = self.detail(slug, key, fetch_fn)94 if not payload:95 return []9697 address = payload.get("address") or card_addr98 city = payload.get("city") or ""99 if payload.get("post_code") and payload["post_code"] not in address:100 address = f"{address}, {payload['post_code']}"101 lat, lng = payload.get("lat"), payload.get("lng")102 amenities_building = payload.get("amenities", [])103104 out: list[Listing] = []105 for u in payload.get("units", []):106 numero = u.get("numero") or ""107 m = re.search(r"#\s*([\w-]+)", numero)108 unit_no = (m.group(1) if m else "").strip(" ,#").lower()109 if not unit_no:110 # unité affichée sans numéro (« Appartement # ») : identifiant111 # stable dérivé du contenu invariant (type, superficie, plan,112 # début de description) — pas du prix ni de la disponibilité113 sig = (f"{u.get('type', '')}|{u.get('superficie', '')}|"114 f"{u.get('plan', '')}|{u.get('description', '')[:40]}")115 unit_no = "sn-" + hashlib.sha1(sig.encode("utf-8")).hexdigest()[:8]116117 amenities = list(amenities_building)118 details: dict = {"building": name}119 if u.get("plan"):120 details["floorplan"] = u["plan"]121122 area = None123 if u.get("superficie"):124 m_a = re.search(r"([\d\s]{2,6})\s*pi", u["superficie"])125 if m_a:126 val = float(re.sub(r"\s", "", m_a.group(1)))127 if 80 <= val <= 20000:128 area = val129130 availability = ""131 if u.get("disponibilite"):132 availability = re.sub(r"^Disponibilit[ée]\s*:\s*", "",133 u["disponibilite"], flags=re.I).strip()134135 out.append(Listing(136 source=self.source_id,137 external_id=f"{slug}--{unit_no}",138 url=url,139 title=f"{name} — {numero}" if numero else name,140 address=address,141 city=city,142 unit_type=normalize_unit_type(u.get("type", "")),143 price=parse_price(u.get("loyer", "")),144 price_label=u.get("loyer", ""),145 availability=availability,146 area_sqft=area,147 description=u.get("description", ""),148 amenities=amenities,149 details=details,150 images=(u.get("images") or [])[:20],151 lat=lat,152 lng=lng,153 ))154 return out155156 # -- page bâtiment ------------------------------------------------------------------157 def _fetch_building(self, url: str) -> dict:158 """Payload JSON-sérialisable : unités disponibles + infos du bâtiment."""159 resp = self.get(url)160 html = resp.text161 soup = BeautifulSoup(html, "html.parser")162 payload: dict = {}163164 # adresse structurée : champ ACF Google Maps rendu en clair dans la page165 # ('address' => '40, rue Saint-Étienne, à Lévis', 'city' => 'Lévis'…)166 acf = dict()167 for k, v in _ACF_RE.findall(html):168 acf.setdefault(k, v.strip())169 if acf.get("address"):170 payload["address"] = re.sub(r",?\s*à\s+", ", ", acf["address"])171 for k in ("city", "post_code"):172 if acf.get(k):173 payload[k] = acf[k]174 try:175 payload["lat"] = float(acf["lat"])176 payload["lng"] = float(acf["lng"])177 except (KeyError, ValueError):178 pass179180 # listes « Inclus » et « Caractéristiques » de l'immeuble181 amenities: list[str] = []182 for h in soup.find_all(["h2", "h3", "h4"]):183 titre = h.get_text(strip=True).lower()184 if titre not in ("inclus", "caractéristiques", "caracteristiques"):185 continue186 cont = h.find_parent(class_="elementor-widget")187 for sib in (cont.find_next_siblings() if cont else []):188 lis = sib.select("li")189 if lis:190 for li in lis:191 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))192 if t and t not in amenities:193 amenities.append(t)194 break195 payload["amenities"] = amenities[:25]196197 # unités disponibles : items JetEngine contenant un champ « numéro »198 units: list[dict] = []199 for item in soup.select(".jet-listing-grid__item"):200 unit = self._parse_unit(item)201 if unit:202 units.append(unit)203 payload["units"] = units204 return payload205206 # -- une unité (classification des champs par contenu) -------------------------------207 def _parse_unit(self, item) -> dict | None:208 texts: list[str] = []209 for f in item.select(".jet-listing-dynamic-field__content"):210 t = re.sub(r"\s+", " ", f.get_text(" ", strip=True))211 if t:212 texts.append(t)213 unit: dict = {}214 for t in texts:215 if re.search(r"^Appartement\s*#", t, re.I) and "numero" not in unit:216 unit["numero"] = t.rstrip(" ,")217 elif _TYPE_RE.match(t) and "type" not in unit:218 unit["type"] = t219 elif re.search(r"\$\s*/\s*mois|\d\$/mois", t) and "loyer" not in unit:220 unit["loyer"] = t221 elif re.search(r"^Superficie\s*:", t, re.I):222 unit["superficie"] = t223 elif re.search(r"^Disponibilit[ée]\s*:", t, re.I):224 unit["disponibilite"] = t225 elif len(t) > 60 and "description" not in unit:226 unit["description"] = t[:1200]227 if "numero" not in unit:228 return None # carte bâtiment / bloc décoratif229230 # galerie de l'unité (liens lightbox pleine taille)231 images: list[str] = []232 gal = item.select_one(".single-unit-gallery") or item233 for a in gal.select('a[href*="/wp-content/uploads/"]'):234 u = _clean_img(a["href"])235 if u.startswith("http") and u not in images:236 images.append(u)237 unit["images"] = images238239 # plan d'étage (« Voir le plan », seulement si un lien réel est fourni)240 for a in item.select("a[href]"):241 if "plan" in a.get_text(strip=True).lower():242 href = a["href"].strip()243 if href.startswith("http") and "/wp-content/" in href:244 unit["plan"] = href245 break246 return unit247