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/lafrance_mathieu.py : connecteur Lafrance & Mathieu5# (lafrance-mathieu.com — Québec, Lévis, Val-Bélair, Beauport, etc.)6# Page liste /louer-appartement-quebec (+ filtres ?boroughs=N), une annonce7# par immeuble. La page détail /logement/<id> expose : adresse complète avec8# code postal, lat/lng (script JSON building-coordinates), commodités et9# caractéristiques (p.attribute-description : chat accepté, balcon, entrées10# laveuse-sécheuse/lave-vaisselle…), unités individuelles (numéro, étage,11# disponibilité, prix), année de construction, téléphone de l'immeuble.12# Fiches visitées via self.detail() (cache BD, max max_details/sync).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import json18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, infer_city, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://lafrance-mathieu.com"26LIST_URL = f"{BASE}/louer-appartement-quebec"2728_IMG_RE = re.compile(29 r'https://gilm-site-vitrine-production\.s3\.amazonaws\.com/'30 r'media/real_estate/[^"\s\)]+\.(?:jpg|jpeg|png|webp)')31_POSTAL_RE = re.compile(r"^[A-Z]\d[A-Z]\s?\d[A-Z]\d$")32_PHONE_RE = re.compile(r"\b\d{3}[-. ]\d{3}[-. ]\d{4}\b")333435class _DetailBudget(Exception):36 """Budget de fiches détail atteint pour cette synchronisation."""373839class LafranceMathieuConnector(BaseConnector):40 source_id = "lafrance_mathieu"41 max_details = 150 # nouvelles fiches détail max par synchronisation4243 # -- fiche immeuble ---------------------------------------------------------44 def _fetch_detail(self, url: str) -> dict:45 """Page /logement/<id> -> payload JSON-sérialisable (cache BD)."""46 html = self.get(url).text47 soup = BeautifulSoup(html, "html.parser")48 payload: dict = {}4950 # photos de l'immeuble (bucket S3)51 payload["images"] = list(dict.fromkeys(_IMG_RE.findall(html)))5253 # lat/lng structurés : <script id="building-coordinates">[{"loc": …}]54 sc = soup.find("script", id="building-coordinates")55 if sc and sc.string:56 try:57 coords = json.loads(sc.string)58 loc = (coords[0] or {}).get("loc") if coords else None59 if loc and len(loc) == 2:60 payload["lat"] = float(loc[0])61 payload["lng"] = float(loc[1])62 except (ValueError, TypeError, AttributeError, IndexError):63 pass6465 # adresse complète (bloc « À propos de l'immeuble » > Adresse)66 strong = soup.find("strong", string=re.compile(r"^\s*Adresse\s*$"))67 if strong:68 bloc = strong.find_parent("div")69 lignes = []70 for p in bloc.find_all("p") if bloc else []:71 t = " ".join(p.get_text(" ", strip=True).split())72 if not t or t == "Adresse":73 continue74 t = re.sub(r"[()]", "", t)75 lignes.append(t)76 if lignes:77 payload["address"] = ", ".join(lignes)7879 # « Plus d'informations » : étages / unités / année de construction80 for sub in soup.select("p.subtext"):81 label = sub.get_text(" ", strip=True).lower()82 val_el = sub.find_next_sibling("p")83 val = val_el.get_text(" ", strip=True) if val_el else ""84 if "année de construction" in label and val.isdigit():85 payload["construction_year"] = int(val)86 elif "nombre total" in label and val:87 payload["total_units"] = val88 elif "nombre d" in label and val:89 payload["building_floors"] = val9091 # commodités + caractéristiques (immeuble, unité, accès et services)92 amenities: list[str] = []93 for p in soup.select("p.attribute-description"):94 label = " ".join(p.get_text(" ", strip=True).split())95 if label and label not in amenities:96 amenities.append(label)97 payload["amenities"] = amenities9899 # unités individuelles : numéro (· étage), disponibilité, prix100 unites: list[dict] = []101 vus: set[tuple] = set()102 for hdr in soup.find_all("p", class_="small-header"):103 if hdr.get_text(strip=True).lower() != "unité":104 continue105 row = hdr.find_parent("div", class_="row")106 if row is None:107 continue108 textes = [" ".join(p.get_text(" ", strip=True).split())109 for p in row.find_all("p")]110 unite = {}111 for i, t in enumerate(textes):112 low = t.lower()113 if low == "unité" and i + 1 < len(textes):114 unite["numero"] = textes[i + 1]115 elif low == "disponibilité" and i + 1 < len(textes):116 unite["dispo"] = textes[i + 1]117 elif low == "à partir de" and i + 1 < len(textes):118 unite["prix"] = textes[i + 1]119 cle = tuple(sorted(unite.items()))120 if unite.get("numero") and cle not in vus: # modal = doublon121 vus.add(cle)122 unites.append(unite)123 payload["unites"] = unites124125 # téléphone spécifique à l'immeuble (panneau « S'informer »)126 panel = soup.select_one(".inquire-step-1")127 if panel:128 m = _PHONE_RE.search(panel.get_text(" ", strip=True))129 if m:130 payload["phone"] = m.group(0).replace(".", "-").replace(" ", "-")131132 return payload133134 def _enrich(self, lst: Listing) -> None:135 """Complète l'annonce avec la fiche immeuble (via cache self.detail)."""136 key = hashlib.sha1(137 f"{lst.price_label}|{lst.availability}|{'|'.join(lst.amenities)}"138 .encode("utf-8")).hexdigest()139140 def fetch_fn():141 if self._detail_fetches >= self.max_details:142 raise _DetailBudget()143 self._detail_fetches += 1144 return self._fetch_detail(lst.url)145146 try:147 payload = self.detail(lst.external_id, key, fetch_fn)148 except _DetailBudget:149 return150 if not payload:151 return152153 if payload.get("images"):154 lst.images = payload["images"]155 if payload.get("address"):156 lst.address = payload["address"]157 if payload.get("lat") is not None and payload.get("lng") is not None:158 lst.lat, lst.lng = payload["lat"], payload["lng"]159 if payload.get("amenities"):160 # la carte liste n'a que 3-4 icônes ; la fiche est complète161 lst.amenities = payload["amenities"]162 if payload.get("construction_year"):163 lst.details["construction_year"] = payload["construction_year"]164 if payload.get("phone"):165 lst.details.setdefault("contact", {})["phone"] = payload["phone"]166167 # animaux : items structurés « Chat accepté » / « Chien accepté »168 amen_txt = " | ".join(payload.get("amenities", [])).lower()169 chat = "chat accepté" in amen_txt or "chats acceptés" in amen_txt170 chien = "chien accepté" in amen_txt or "chiens acceptés" in amen_txt171 if "animaux acceptés" in amen_txt or (chat and chien):172 lst.pets = "oui"173 elif chat or chien:174 lst.pets = "conditions" # une seule espèce acceptée175176 # description : infos immeuble + liste des unités disponibles177 desc_parts: list[str] = []178 infos = []179 if payload.get("construction_year"):180 infos.append(f"construit en {payload['construction_year']}")181 if payload.get("total_units"):182 infos.append(payload["total_units"])183 if infos:184 desc_parts.append("Immeuble " + ", ".join(infos))185 for u in payload.get("unites", []):186 ligne = f"Unité {u.get('numero', '?')}"187 if u.get("dispo"):188 ligne += f" — disponibilité : {u['dispo']}"189 if u.get("prix"):190 ligne += f" — à partir de {u['prix']}"191 desc_parts.append(ligne)192 if desc_parts:193 lst.description = " | ".join(desc_parts)[:600]194195 # disponibilité de secours : première unité listée196 if not lst.availability:197 for u in payload.get("unites", []):198 if u.get("dispo"):199 lst.availability = u["dispo"]200 break201202 # -- contrat ---------------------------------------------------------------203 def fetch(self) -> list[Listing]:204 # 1) Découvrir tous les filtres d'arrondissement sur la page principale205 html = self.get(LIST_URL).text206 boroughs = sorted(set(re.findall(r"\?boroughs=(\d+)", html)))207 pages = [html] + [208 self.get(f"{LIST_URL}/?boroughs={b}").text for b in boroughs209 ]210211 # 2) Extraire toutes les cartes (dédupliquées par id)212 listings: dict[str, Listing] = {}213 for page in pages:214 soup = BeautifulSoup(page, "html.parser")215 for card in soup.select('a[href^="/logement/"]'):216 m = re.match(r"/logement/(\d+)", card.get("href", ""))217 if not m:218 continue219 ext_id = m.group(1)220 if ext_id in listings:221 continue222 borough_el = card.select_one(".apartment-borough")223 sector = ""224 if borough_el:225 # "Appartements · Beauport" -> "Beauport"226 sector = borough_el.get_text(" ", strip=True).split("·")[-1].strip()227 addr_el = card.select_one(".apartment-address")228 infos_el = card.select_one(".apartment-infos")229 avail_el = card.select_one(".apartment-availability")230 infos = infos_el.get_text(" ", strip=True) if infos_el else ""231 amenities = [img.get("alt", "").strip()232 for img in card.select("img[alt]")233 if img.get("alt") and img.get("alt") not in234 ("unit image", "")]235 address = addr_el.get_text(strip=True) if addr_el else ""236 listings[ext_id] = Listing(237 source=self.source_id,238 external_id=ext_id,239 url=f"{BASE}/logement/{ext_id}",240 title=address or f"Logement {ext_id}",241 address=address,242 sector=sector,243 city=infer_city(sector),244 unit_type=normalize_unit_type(infos),245 price=parse_price(infos),246 price_label=infos,247 availability=avail_el.get_text(strip=True) if avail_el else "",248 amenities=amenities,249 )250251 # 3) Fiche immeuble : adresse complète, lat/lng, commodités, unités…252 # via self.detail() (seules les fiches nouvelles/modifiées sont253 # réellement visitées, max self.max_details par synchronisation)254 self._detail_fetches = 0255 for lst in listings.values():256 try:257 self._enrich(lst)258 except Exception:259 continue260261 return list(listings.values())262