# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/logicom.py : connecteur Les Immeubles Logicom # (immeubleslogicom.com — Sainte-Foy, Sillery, Lévis…). WordPress rendu # serveur : /appartements-a-louer/ liste les immeubles (/immeuble//), # et chaque page immeuble expose ses unités disponibles en cartes # `.appartement` (no d'unité dans le titre, disponibilité, type n ½, # superficie, balcon, prix « À partir de … $ », plan PDF/PNG) + un marqueur # carte data-lat/data-lng. Granularité : unité. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://immeubleslogicom.com" LIST_URL = f"{BASE}/appartements-a-louer/" UNIT_NO_RE = re.compile(r"(\d{1,4}[A-Z]?)\s*$") TYPE_OK_RE = re.compile(r"studio|loft|\d\s*(?:½|1/2)", re.I) SQFT_RE = re.compile(r"([\d\s]+)\s*pi²", re.I) class LogicomConnector(BaseConnector): source_id = "logicom" request_delay = 0.6 max_buildings = 15 # garde-fou de crawl def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings slugs = list(dict.fromkeys( re.findall(r"/immeuble/([a-z0-9-]+)/", html)))[:self.max_buildings] seen: set[str] = set() for slug in slugs: url = f"{BASE}/immeuble/{slug}/" try: page = self.get(url).text except Exception: continue for lst in self._parse_building(slug, url, page): # certaines unités apparaissent en double sur la page if lst.external_id in seen: continue seen.add(lst.external_id) listings.append(lst) return listings def _parse_building(self, slug: str, url: str, page: str) -> list[Listing]: out: list[Listing] = [] soup = BeautifulSoup(page, "html.parser") h1 = soup.select_one("h1") building = h1.get_text(" ", strip=True) if h1 else slug # secteur : bloc « Localisation » du survol (hero-info) sector = "" for blk in soup.select(".hero-info span"): if "localisation" in blk.get_text(strip=True).lower(): p = blk.find_next("p") if p: sector = p.get_text(" ", strip=True) break city = infer_city(sector) lat = lng = None marker = soup.select_one(".marker[data-lat]") if marker: try: lat = float(marker["data-lat"]) lng = float(marker["data-lng"]) except (KeyError, ValueError): lat = lng = None # photos de l'immeuble (carrousel du projet) photos = [img.get("src", "") for img in soup.select(".slider-container img") if img.get("src", "").startswith("http")][:15] for card in soup.select("div.appartement"): try: title_el = card.select_one("[data-title]") if not title_el: continue title = title_el.get_text(" ", strip=True) m = UNIT_NO_RE.search(title) unit_no = m.group(1) if m else "" type_el = card.select_one(".type p") raw_type = type_el.get_text(" ", strip=True) if type_el else \ card.get("data-type", "") # résidentiel seulement (Studio / Loft / n ½) if not TYPE_OK_RE.search(raw_type): continue avail_el = title_el.find_next("p") availability = avail_el.get_text(" ", strip=True) \ if avail_el else "" sizes = [p.get_text(" ", strip=True) for p in card.select(".taille p")] area = None for s in sizes: sm = SQFT_RE.search(s) if sm and "$" not in s: area = float(sm.group(1).replace(" ", "") .replace(" ", "")) break price_label = next((re.sub(r"\s+", " ", s) for s in sizes if "$" in s), "") plan = card.select_one('a[href$=".pdf"]') plan_img = card.select_one("img") images = list(photos) if plan_img and plan_img.get("src", "").startswith("http"): images = [plan_img["src"]] + photos if not unit_no: unit_no = hashlib.sha1( f"{title}|{raw_type}|{area}".encode()).hexdigest()[:8] extras = [f"Balcon : {sizes[1]}"] \ if len(sizes) >= 3 and "pi²" in sizes[1] else [] details: dict = {} if plan: details["floor_plan"] = plan.get("href", "") out.append(Listing( source=self.source_id, external_id=f"{slug}-{unit_no}", url=url, title=title, sector=sector, city=city, unit_type=normalize_unit_type(raw_type), price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=area, description=f"Unité {unit_no} — {building} " f"({sector}).", amenities=extras, details=details, images=images, lat=lat, lng=lng, )) except Exception: continue return out