# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/location_di.py : connecteur Location DI / Groupe Sphère DI # (locationdi.com) — +750 logements : Trois-Rivières, Bécancour, MRC de # Joliette (Saint-Charles-Borromée, Saint-Félix-de-Valois…), Berthierville, # Lachute, Montréal. # Site SSR (Astro) très propre : la page /logements affiche toutes les # unités disponibles sous forme de cartes avec ATTRIBUTS DE DONNÉES # structurés (data-slug, data-ville, data-region, data-type, data-etage, # data-prix, data-date). Chaque fiche embarque un JSON-LD schema.org # « Apartment » (adresse, superficie, chambres, photos, description). # Les pages régionales /logements/region/ sont aussi balayées (filet). # robots.txt : pas de bloc User-agent générique ni de crawl-delay ; les # bots IA « live fetch » sont explicitement autorisés, seuls les bots # d'ENTRAÎNEMENT sont bloqués — notre usage (indexation d'annonces avec # lien vers la source, UA transparent LouKaBot) est conforme. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing, parse_availability_date from .base import BaseConnector BASE = "https://locationdi.com" # JSON-LD des fiches : _LD_RE = re.compile( r'', re.S) class _CapAtteint(Exception): """Plafond de requêtes détail atteint pour cette synchronisation.""" class LocationDIConnector(BaseConnector): source_id = "location_di" request_delay = 0.7 # politesse (petit site régional) max_region_pages = 12 # garde-fou : pages régionales balayées max_real_details = 60 # vraies requêtes fiches par sync (cache exclu) # -- liste ------------------------------------------------------------------ def _cards(self, html: str, found: dict[str, dict]) -> list[str]: """Cartes .logement-card (attributs data-*) ; retourne les pages régionales découvertes (/logements/region/…).""" soup = BeautifulSoup(html, "html.parser") for card in soup.select("div.logement-card[data-slug]"): slug = card.get("data-slug", "").strip() if not slug or slug in found: continue text = card.get_text(" ", strip=True) # badge de disponibilité affiché sur la carte (texte source) avail = "" m = re.search(r"(Disponible dès maintenant|Libre le [^·|]+)", text) if m: avail = m.group(1).strip() found[slug] = { "ville": card.get("data-ville", "").strip(), "region": card.get("data-region", "").strip(), "type": card.get("data-type", "").strip(), "etage": card.get("data-etage", "").strip(), "prix": card.get("data-prix", "").strip(), "date": card.get("data-date", "").strip(), "avail": avail, "from": "À partir de" in text, } regions = [] for a in soup.select('a[href^="/logements/region/"]'): href = (a.get("href") or "").split("?")[0].split("#")[0] if href: regions.append(BASE + href) return regions # -- fiche (JSON-LD + commodités) -------------------------------------------- def _unit_detail(self, slug: str, key: str) -> dict: def _fetch() -> dict: if self._real_details >= self.max_real_details: raise _CapAtteint() self._real_details += 1 resp = self.get(f"{BASE}/logements/{slug}") resp.encoding = "utf-8" # pages servies sans charset explicite html = resp.text out: dict = {} for m in _LD_RE.finditer(html): try: ld = json.loads(m.group(1)) except ValueError: continue if ld.get("@type") != "Apartment": continue out["description"] = str(ld.get("description") or "") adr = ld.get("address") or {} out["street"] = str(adr.get("streetAddress") or "") out["locality"] = str(adr.get("addressLocality") or "") fs = ld.get("floorSize") or {} try: v = float(fs.get("value")) if 80 <= v <= 20000: out["area"] = v except (TypeError, ValueError): pass beds = ld.get("numberOfRooms") if isinstance(beds, int): out["bedrooms"] = beds out["images"] = [u for u in (ld.get("image") or []) if isinstance(u, str) and u.startswith("http")] break # commodités : noms des items sous « Commodités et inclusions » soup = BeautifulSoup(html, "html.parser") amenities: list[str] = [] for h2 in soup.find_all("h2"): if "Commodités" not in h2.get_text(): continue cont = h2.find_next_sibling("div") or h2.parent for p in cont.find_all("p"): cls = " ".join(p.get("class") or []) if "font-semibold" not in cls: continue t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if 3 <= len(t) <= 60 and t not in amenities: amenities.append(t) break out["amenities"] = amenities[:20] return out try: return self.detail(slug, key, _fetch) except Exception: return {} # -- fetch -------------------------------------------------------------------- def fetch(self) -> list[Listing]: self._real_details = 0 found: dict[str, dict] = {} resp = self.get(f"{BASE}/logements") resp.encoding = "utf-8" # pages servies sans charset explicite regions = self._cards(resp.text, found) # filet : pages régionales (au cas où une unité manque à la liste globale) for url in list(dict.fromkeys(regions))[: self.max_region_pages]: try: r = self.get(url) r.encoding = "utf-8" self._cards(r.text, found) except Exception: continue listings: list[Listing] = [] for slug, c in found.items(): m = re.search(r"(\d+)$", slug) ext_id = m.group(1) if m else slug # id numérique stable du slug key = hashlib.sha1(json.dumps(c, sort_keys=True, ensure_ascii=False) .encode("utf-8")).hexdigest()[:16] det = self._unit_detail(slug, key) try: price = float(c["prix"]) if c["prix"] else None except ValueError: price = None availability = c["avail"] or (f"Libre le {c['date']}" if c["date"] else "") # data-date ISO de la carte (une date passée = libre maintenant) avail_date = parse_availability_date(c["date"]) if c["date"] else None address = det.get("street") or "" title_addr = "" if address: title_addr = address + (f", {det['locality']}" if det.get("locality") else "") details: dict = {} if c["etage"]: details["floor_label"] = c["etage"] # ex. « 3e Étage » if isinstance(det.get("bedrooms"), int): details["bedrooms"] = det["bedrooms"] if c["from"]: details["price_from"] = True listings.append(Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}/logements/{slug}", title=(f"{c['type']} — {title_addr}" if title_addr else f"{c['type']} — {slug}"), address=address, sector=c["region"], # ex. « Mauricie » city=det.get("locality") or c["ville"], # vraie ville unit_type=c["type"], price=price, price_label=(f"À partir de {c['prix']}$ / mois" if c["from"] and c["prix"] else ""), availability=availability, availability_date=avail_date, area_sqft=det.get("area"), description=str(det.get("description") or "")[:600], amenities=det.get("amenities") or [], details=details, images=(det.get("images") or [])[:20], )) return listings