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/location_di.py : connecteur Location DI / Groupe Sphère DI5# (locationdi.com) — +750 logements : Trois-Rivières, Bécancour, MRC de6# Joliette (Saint-Charles-Borromée, Saint-Félix-de-Valois…), Berthierville,7# Lachute, Montréal.8# Site SSR (Astro) très propre : la page /logements affiche toutes les9# unités disponibles sous forme de cartes avec ATTRIBUTS DE DONNÉES10# structurés (data-slug, data-ville, data-region, data-type, data-etage,11# data-prix, data-date). Chaque fiche embarque un JSON-LD schema.org12# « Apartment » (adresse, superficie, chambres, photos, description).13# Les pages régionales /logements/region/<r> sont aussi balayées (filet).14# robots.txt : pas de bloc User-agent générique ni de crawl-delay ; les15# bots IA « live fetch » sont explicitement autorisés, seuls les bots16# d'ENTRAÎNEMENT sont bloqués — notre usage (indexation d'annonces avec17# lien vers la source, UA transparent LouKaBot) est conforme.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import hashlib22import json23import re2425from bs4 import BeautifulSoup2627from ..schema import Listing, parse_availability_date28from .base import BaseConnector2930BASE = "https://locationdi.com"3132# JSON-LD des fiches : <script type="application/ld+json">{...}</script>33_LD_RE = re.compile(34 r'<script type="application/ld\+json">(.*?)</script>', re.S)353637class _CapAtteint(Exception):38 """Plafond de requêtes détail atteint pour cette synchronisation."""394041class LocationDIConnector(BaseConnector):42 source_id = "location_di"43 request_delay = 0.7 # politesse (petit site régional)44 max_region_pages = 12 # garde-fou : pages régionales balayées45 max_real_details = 60 # vraies requêtes fiches par sync (cache exclu)4647 # -- liste ------------------------------------------------------------------48 def _cards(self, html: str, found: dict[str, dict]) -> list[str]:49 """Cartes .logement-card (attributs data-*) ; retourne les pages50 régionales découvertes (/logements/region/…)."""51 soup = BeautifulSoup(html, "html.parser")52 for card in soup.select("div.logement-card[data-slug]"):53 slug = card.get("data-slug", "").strip()54 if not slug or slug in found:55 continue56 text = card.get_text(" ", strip=True)57 # badge de disponibilité affiché sur la carte (texte source)58 avail = ""59 m = re.search(r"(Disponible dès maintenant|Libre le [^·|]+)", text)60 if m:61 avail = m.group(1).strip()62 found[slug] = {63 "ville": card.get("data-ville", "").strip(),64 "region": card.get("data-region", "").strip(),65 "type": card.get("data-type", "").strip(),66 "etage": card.get("data-etage", "").strip(),67 "prix": card.get("data-prix", "").strip(),68 "date": card.get("data-date", "").strip(),69 "avail": avail,70 "from": "À partir de" in text,71 }72 regions = []73 for a in soup.select('a[href^="/logements/region/"]'):74 href = (a.get("href") or "").split("?")[0].split("#")[0]75 if href:76 regions.append(BASE + href)77 return regions7879 # -- fiche (JSON-LD + commodités) --------------------------------------------80 def _unit_detail(self, slug: str, key: str) -> dict:81 def _fetch() -> dict:82 if self._real_details >= self.max_real_details:83 raise _CapAtteint()84 self._real_details += 185 resp = self.get(f"{BASE}/logements/{slug}")86 resp.encoding = "utf-8" # pages servies sans charset explicite87 html = resp.text88 out: dict = {}89 for m in _LD_RE.finditer(html):90 try:91 ld = json.loads(m.group(1))92 except ValueError:93 continue94 if ld.get("@type") != "Apartment":95 continue96 out["description"] = str(ld.get("description") or "")97 adr = ld.get("address") or {}98 out["street"] = str(adr.get("streetAddress") or "")99 out["locality"] = str(adr.get("addressLocality") or "")100 fs = ld.get("floorSize") or {}101 try:102 v = float(fs.get("value"))103 if 80 <= v <= 20000:104 out["area"] = v105 except (TypeError, ValueError):106 pass107 beds = ld.get("numberOfRooms")108 if isinstance(beds, int):109 out["bedrooms"] = beds110 out["images"] = [u for u in (ld.get("image") or [])111 if isinstance(u, str) and u.startswith("http")]112 break113 # commodités : noms des items sous « Commodités et inclusions »114 soup = BeautifulSoup(html, "html.parser")115 amenities: list[str] = []116 for h2 in soup.find_all("h2"):117 if "Commodités" not in h2.get_text():118 continue119 cont = h2.find_next_sibling("div") or h2.parent120 for p in cont.find_all("p"):121 cls = " ".join(p.get("class") or [])122 if "font-semibold" not in cls:123 continue124 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))125 if 3 <= len(t) <= 60 and t not in amenities:126 amenities.append(t)127 break128 out["amenities"] = amenities[:20]129 return out130131 try:132 return self.detail(slug, key, _fetch)133 except Exception:134 return {}135136 # -- fetch --------------------------------------------------------------------137 def fetch(self) -> list[Listing]:138 self._real_details = 0139 found: dict[str, dict] = {}140141 resp = self.get(f"{BASE}/logements")142 resp.encoding = "utf-8" # pages servies sans charset explicite143 regions = self._cards(resp.text, found)144 # filet : pages régionales (au cas où une unité manque à la liste globale)145 for url in list(dict.fromkeys(regions))[: self.max_region_pages]:146 try:147 r = self.get(url)148 r.encoding = "utf-8"149 self._cards(r.text, found)150 except Exception:151 continue152153 listings: list[Listing] = []154 for slug, c in found.items():155 m = re.search(r"(\d+)$", slug)156 ext_id = m.group(1) if m else slug # id numérique stable du slug157 key = hashlib.sha1(json.dumps(c, sort_keys=True, ensure_ascii=False)158 .encode("utf-8")).hexdigest()[:16]159 det = self._unit_detail(slug, key)160161 try:162 price = float(c["prix"]) if c["prix"] else None163 except ValueError:164 price = None165 availability = c["avail"] or (f"Libre le {c['date']}" if c["date"]166 else "")167 # data-date ISO de la carte (une date passée = libre maintenant)168 avail_date = parse_availability_date(c["date"]) if c["date"] else None169170 address = det.get("street") or ""171 title_addr = ""172 if address:173 title_addr = address + (f", {det['locality']}"174 if det.get("locality") else "")175 details: dict = {}176 if c["etage"]:177 details["floor_label"] = c["etage"] # ex. « 3e Étage »178 if isinstance(det.get("bedrooms"), int):179 details["bedrooms"] = det["bedrooms"]180 if c["from"]:181 details["price_from"] = True182183 listings.append(Listing(184 source=self.source_id,185 external_id=ext_id,186 url=f"{BASE}/logements/{slug}",187 title=(f"{c['type']} — {title_addr}" if title_addr188 else f"{c['type']} — {slug}"),189 address=address,190 sector=c["region"], # ex. « Mauricie »191 city=det.get("locality") or c["ville"], # vraie ville192 unit_type=c["type"],193 price=price,194 price_label=(f"À partir de {c['prix']}$ / mois"195 if c["from"] and c["prix"] else ""),196 availability=availability,197 availability_date=avail_date,198 area_sqft=det.get("area"),199 description=str(det.get("description") or "")[:600],200 amenities=det.get("amenities") or [],201 details=details,202 images=(det.get("images") or [])[:20],203 ))204 return listings205