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/logisco.py : connecteur Logisco (logisco.com)5# Site rendu serveur. Crawl : page liste principale (+ pages villes) pour6# découvrir les pages projets, puis extraction des unités disponibles7# (cartes .unitCard) sur chaque page projet. Une annonce par unité.8# Chaque page unité (mise en cache via self.detail, plafond 150 vraies9# requêtes/sync) apporte : JSON-LD (adresse avec no d'unité + code postal,10# étage, téléphone, plan de l'unité), superficie/étage/dispo structurés11# (sommaire .unitHeader) et les 4 sections de commodités (Inclusions,12# Particularités, Services offerts, Espaces communs).13# Villes retenues : Québec, Lévis, Saint-Augustin-de-Desmaures14# (Donnacona et autres villes hors région exclues).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import html as htmllib20import json21import re22from urllib.parse import urljoin2324from bs4 import BeautifulSoup2526from ..schema import Listing, infer_city, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://logisco.com"30LIST_URL = f"{BASE}/fr/appartements-a-louer"3132# villes de la région Québec/Lévis retenues (segment d'URL -> nom de ville)33CITY_MAP = {34 "quebec": "Québec",35 "levis": "Lévis",36 "saint-augustin-de-desmaures": "Saint-Augustin-de-Desmaures",37}3839PROJECT_HREF_RE = re.compile(40 r'href="(https://logisco\.com/fr/appartements-a-louer/([a-z0-9\-]+(?:/[a-z0-9\-]+)*))"'41)42STATIC_IMG_RE = re.compile(43 r'(?:src|data-src|href)="(https://logisco\.com/static/[^"]+\.(?:webp|jpe?g|png))"'44)45EXCLUDE_UNIT_RE = re.compile(r"stationnement|parking|rangement|commercial|garage", re.I)464748def _text(el, sep: str = " ") -> str:49 """Texte d'un élément, robuste aux <template> Vue du site.5051 bs4 classe les chaînes situées sous une balise <template> comme52 TemplateString et les exclut de get_text() par défaut ; or les cartes53 d'unités de Logisco se retrouvent imbriquées sous de telles balises.54 `types=None` force la prise en compte de toutes les chaînes.55 """56 if el is None:57 return ""58 return el.get_text(sep, strip=True, types=None)596061class _DetailBudget(Exception):62 """Plafond de vraies requêtes de pages unité atteint pour cette synchro."""636465class LogiscoConnector(BaseConnector):66 source_id = "logisco"67 request_delay = 0.668 max_projects = 40 # garde-fou : nb max de pages projets crawlées69 max_project_imgs = 15 # nb max de photos projet par annonce70 max_details = 150 # plafond de VRAIES requêtes de pages unité/sync7172 def __init__(self) -> None:73 super().__init__()74 self._detail_fetches = 07576 # -- découverte des pages projets ----------------------------------------77 def _discover_projects(self) -> dict[str, tuple[str, str]]:78 """Retourne {url_projet: (segment_ville, segment_secteur)}."""79 pages = []80 try:81 pages.append(self.get(LIST_URL).text)82 except Exception:83 pass84 for city in CITY_MAP:85 try:86 pages.append(self.get(f"{LIST_URL}/{city}").text)87 except Exception:88 continue8990 projects: dict[str, tuple[str, str]] = {}91 for html in pages:92 for url, path in PROJECT_HREF_RE.findall(html):93 parts = path.split("/")94 city = parts[0]95 if city not in CITY_MAP:96 continue # ex. Donnacona et autres villes hors région97 # quebec/levis : ville/secteur/projet (3 segments)98 # saint-augustin : ville/projet (2 segments)99 if city in ("quebec", "levis"):100 if len(parts) != 3:101 continue102 sector = parts[1]103 elif len(parts) == 2:104 sector = city105 else:106 continue107 projects.setdefault(url, (city, sector))108 return projects109110 # -- extraction d'une page projet -----------------------------------------111 def _project_images(self, html: str) -> list[str]:112 """Photos du projet (sans logos ni plans d'unités), dédupliquées."""113 imgs = []114 for u in STATIC_IMG_RE.findall(html):115 name = u.rsplit("/", 1)[-1].lower()116 if name.startswith(("logo-", "plan-")) or "logo" in name.split("-")[:1]:117 continue118 imgs.append(u)119 return list(dict.fromkeys(imgs))[: self.max_project_imgs]120121 def fetch(self) -> list[Listing]:122 projects = self._discover_projects()123 listings: dict[str, Listing] = {}124125 for i, (proj_url, (city_seg, sector_seg)) in enumerate(projects.items()):126 if i >= self.max_projects:127 break128 try:129 html = self.get(proj_url).text130 except Exception:131 continue132 soup = BeautifulSoup(html, "html.parser")133134 project_name = ""135 h1 = soup.find("h1")136 if h1:137 # retirer le slogan SEO : « District GC • Appartements … »,138 # « L'AMALGAM - Grands appartements à louer … »139 project_name = re.split(r"\s+[•|–—]\s+|\s+-\s+", _text(h1))[0].strip()140 proj_imgs = self._project_images(html)141 cards = soup.select("li.unitCard, .unitCard")142 n_dispo = len(cards)143144 for card in cards:145 try:146 unit_gtm_type = (card.get("data-gtm-unit-type") or "").upper()147 if unit_gtm_type and unit_gtm_type != "APARTMENT":148 continue149 link = card.select_one("a.unitCard-link")150 if not link or not link.get("href"):151 continue152 url = urljoin(BASE, link["href"])153154 ext_id = card.get("data-gtm-unit-id") or ""155 if not ext_id:156 ext_id = url.rstrip("/").rsplit("/", 1)[-1]157158 title = _text(card.select_one(".unitCard-title"))159 if EXCLUDE_UNIT_RE.search(title):160 continue161162 address = _text(card.select_one(".unitCard-address"))163164 # secteur : « Desjardins, Lévis » -> « Desjardins »165 sector = ""166 map_el = card.select_one(".unitCard-address--map")167 if map_el:168 sector = _text(map_el).split(",")[0].strip()169 if not sector:170 sector = sector_seg.replace("-", " ").title()171172 sizes = [_text(s) for s in card.select(".unitCard-size")]173 unit_type = ""174 for s in sizes:175 if re.match(r"^\d\s*(?:1/2|½)|^(studio|loft)", s, re.I):176 unit_type = s177 break178179 price_label = _text(card.select_one(".unitCard-price"))180181 availability = _text(card.select_one(".unitCard-dispo"))182183 amenities = list(dict.fromkeys(184 t for t in (_text(sr) for sr in185 card.select(".unitCard-services .sr-only"))186 if t187 ))188189 images = []190 img_el = card.select_one("img.unitCard-image")191 if img_el and img_el.get("src"):192 images.append(urljoin(BASE, img_el["src"]))193 images += [u for u in proj_imgs if u not in images]194195 extra = ", ".join(s for s in sizes if s != unit_type)196 desc_bits = []197 if project_name:198 desc_bits.append(f"Projet {project_name}")199 if extra:200 desc_bits.append(extra)201 desc_bits.append(f"{n_dispo} unité(s) disponible(s) dans le projet")202203 default_city = CITY_MAP.get(city_seg, "Québec")204 full_title = (f"{project_name} — {title}"205 if project_name and title else title or project_name)206207 lst = Listing(208 source=self.source_id,209 external_id=str(ext_id),210 url=url,211 title=full_title or f"Unité {ext_id}",212 address=address,213 sector=sector,214 city=infer_city(sector, default=default_city),215 unit_type=normalize_unit_type(unit_type),216 price=parse_price(price_label),217 price_label=price_label,218 availability=availability,219 description=" — ".join(desc_bits)[:600],220 amenities=amenities,221 images=images,222 )223224 # page unité (avec cache BD) : adresse complète, superficie,225 # étage, téléphone, plan, sections de commodités226 # préfixe v2 = version de l'extraction (invalide le cache)227 key = "v2:" + hashlib.sha1(" | ".join(228 [title, address, price_label, availability]229 + sizes + amenities).encode("utf-8")).hexdigest()230 try:231 payload = self.detail(232 str(ext_id), key, lambda u=url: self._fetch_unit(u))233 except _DetailBudget:234 payload = {} # plafond atteint : complété au prochain sync235 except Exception:236 payload = {}237 self._apply_unit(lst, payload)238239 listings[ext_id] = lst240 except Exception:241 continue # ne jamais planter sur une carte malformée242243 return list(listings.values())244245 # -- page unité ------------------------------------------------------------246 def _fetch_unit(self, url: str) -> dict:247 if self._detail_fetches >= self.max_details:248 raise _DetailBudget(url)249 self._detail_fetches += 1250 soup = BeautifulSoup(self.get(url).text, "html.parser")251 out: dict = {}252253 # JSON-LD « apartment » : adresse (no d'unité + code postal), étage,254 # téléphone du bureau de location, plan de l'unité255 for sc in soup.find_all("script", type="application/ld+json"):256 try:257 data = json.loads(sc.string or "")258 except Exception:259 continue260 if not (isinstance(data, dict)261 and str(data.get("@type", "")).lower() == "apartment"):262 continue263 addr = data.get("address") or {}264 # certaines valeurs JSON-LD contiennent des entités HTML (')265 street = htmllib.unescape((addr.get("streetAddress") or "")).strip()266 if street:267 bits = [street,268 htmllib.unescape((addr.get("addressLocality") or "")).strip(),269 (addr.get("postalCode") or "").strip()]270 out["address"] = ", ".join(b for b in bits if b)271 tel = re.sub(r"\D", "", str(data.get("telephone") or ""))272 tel = tel[1:] if len(tel) == 11 and tel.startswith("1") else tel273 if len(tel) == 10:274 out["phone"] = f"{tel[:3]}-{tel[3:6]}-{tel[6:]}"275 fl = str(data.get("floorLevel") or "").strip()276 if fl.isdigit():277 out["floor"] = int(fl)278 img = data.get("image") or ""279 if isinstance(img, str) and img.startswith("http"):280 out["plan"] = img281 break282283 # sommaire de l'unité : superficie, étage, disponibilité (structurés)284 for info in soup.select(".unitHeader-info"):285 label = _text(info.select_one(".unitHeader-infoLabel")).lower()286 value = _text(info.select_one(".unitHeader-infoValue"))287 if "superficie" in label:288 m = re.search(r"([\d\s ]+)\s*pi", value)289 if m:290 try:291 v = float(re.sub(r"[\s ]", "", m.group(1)))292 if 80 <= v <= 20000:293 out["area_sqft"] = v294 except ValueError:295 pass296 elif "tage" in label and re.fullmatch(r"\d{1,2}", value):297 out["floor"] = int(value)298 elif "disponibilit" in label and value:299 out["availability"] = value300301 # sections : Inclusions / Particularités / Services offerts /302 # Espaces communs (items texte, fidèles — « Rangement supplémentaire ($) »)303 amen: list[str] = []304 for item in soup.select(".projectServices-listItem"):305 t = _text(item)306 if t:307 amen.append(t)308 out["amenities"] = list(dict.fromkeys(amen))[:40]309 return out310311 def _apply_unit(self, lst: Listing, payload: dict) -> None:312 if not payload:313 return314 if payload.get("address"):315 lst.address = payload["address"]316 if payload.get("area_sqft"):317 lst.area_sqft = payload["area_sqft"]318 if payload.get("availability") and not lst.availability:319 lst.availability = payload["availability"]320 if payload.get("floor") is not None:321 lst.details["floor"] = payload["floor"]322 if payload.get("phone"):323 lst.details.setdefault("contact", {})["phone"] = payload["phone"]324 for a in payload.get("amenities", []):325 if a not in lst.amenities:326 lst.amenities.append(a)327 plan = payload.get("plan")328 if plan and plan not in lst.images:329 lst.images.append(plan)330