# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/info_logement.py : connecteur Info-Logement (info-logement.com) # Gestionnaire locatif de Lanaudière (Joliette, St-Charles-Borromée, # Notre-Dame-des-Prairies, Berthierville…). Site custom : la liste # /logements/tous (paginée ?page=N, filtre gardé en session) expose des # cartes avec data-logid stable, adresse (h2), tableau # Dimensions/Ville/Disponibilité, loyer et badge « En rénovation ». Les # fiches détail (via cache BD) ajoutent description, commodités, # proximités, adresse complète, galerie et GPS (LatLng de la carte). # robots.txt : « Disallow: » vide (tout permis). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.info-logement.com" LIST_URL = f"{BASE}/logements/tous" PAGE_URL = f"{BASE}/logements?page={{n}}" _LATLNG_RE = re.compile(r"LatLng\((-?\d+\.\d+),\s*(-?\d+\.\d+)\)") _THUMB_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) class InfoLogementConnector(BaseConnector): source_id = "info_logement" request_delay = 0.7 max_pages = 10 # garde-fou de pagination (3 pages actuellement) max_details = 60 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for page in range(1, self.max_pages + 1): # /logements/tous fixe le filtre « tous » en session ; les pages # suivantes se parcourent via /logements?page=N (mêmes cookies) url = LIST_URL if page == 1 else PAGE_URL.format(n=page) try: html = self.get(url).text except Exception: break soup = BeautifulSoup(html, "html.parser") cards = soup.select("a.result") if not cards: break before = len(listings) for card in cards: try: self._parse_card(card, listings) except Exception: continue if len(listings) == before: # page sans nouvelle annonce break # fiches détail (cache BD) : description, commodités, GPS, galerie self._fetched = 0 for lst in listings.values(): card_key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.url}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, card_key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte de la liste -------------------------------------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: url = card.get("href", "") # type dans l'URL : /logements//// — # on exclut garages/commercial (le site liste aussi des garages) m = re.search(r"/logements/([^/]+)/([^/]+)/([^/]+)/(\d+)$", url) if not m: return type_slug, ext_id = m.group(2), m.group(4) if re.search(r"garage|commercial|stationnement|rangement", type_slug): return if ext_id in listings: return h2 = card.select_one("h2") address = h2.get_text(" ", strip=True) if h2 else "" rows: dict[str, str] = {} for tr in card.select("table.resultData tr"): tds = tr.find_all("td") if len(tds) == 2: rows[tds[0].get_text(strip=True).lower()] = \ tds[1].get_text(" ", strip=True) city = rows.get("ville", "") dim = rows.get("dimensions", "") availability = rows.get("disponibilité", "") price_el = card.select_one("p.left") price_label = price_el.get_text(" ", strip=True) if price_el else "" # badge « En rénovation » : conservé (texte source) dans la description reno = card.select_one("span.reno") reno_txt = reno.get_text(" ", strip=True) if reno else "" images: list[str] = [] img = card.select_one(".resultPic img[src]") if img and img["src"].startswith("http"): images.append(_THUMB_SUFFIX.sub("", img["src"])) listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, # data-logid / id numérique de l'URL url=url, title=address, address=address, sector="", city=city, # ville affichée sur la carte unit_type=normalize_unit_type(dim), price=parse_price(price_label), price_label=price_label, availability=availability, description=reno_txt, images=images, ) # -- fiche détail ------------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: """Description, commodités/proximités, adresse complète, GPS, galerie.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # sections titrées h1 : Commodités / À proximité de / Description for h in soup.find_all("h1"): name = h.get_text(strip=True) if name not in ("Commodités", "À proximité de", "Description"): continue lines: list[str] = [] sib = h.find_next_sibling() while sib is not None and sib.name != "h1": for t in sib.stripped_strings: t = re.sub(r"\s+", " ", t).strip() if t and t not in lines: lines.append(t) sib = sib.find_next_sibling() if name == "Description": out["description"] = "\n".join(lines)[:1200] else: out.setdefault("amenities", []).extend(lines[:15]) # adresse complète (« 1400, Line-Rainville, app. 201, Joliette QC J6E ») h1 = soup.find("h1") if h1: nxt = h1.find_next(string=re.compile(r"QC")) if nxt: out["address"] = re.sub(r"\s+", " ", str(nxt)).strip() m = _LATLNG_RE.search(html) if m: out["lat"], out["lng"] = float(m.group(1)), float(m.group(2)) images: list[str] = [] for img in soup.select('img[src*="/medias/"]'): src = _THUMB_SUFFIX.sub("", img["src"]) if src.startswith("http") and src not in images: images.append(src) if images: out["images"] = images[:25] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("description"): lst.description = (lst.description + "\n" + d["description"]).strip() if d.get("amenities"): lst.amenities = list(dict.fromkeys(d["amenities"])) if d.get("address"): lst.address = d["address"] if d.get("images"): lst.images = d["images"] if d.get("lat") is not None and d.get("lng") is not None: lst.lat, lst.lng = d["lat"], d["lng"]