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/place_florimay.py : connecteur Place Florimay (placeflorimay.ca)5# Boulevard Taché Ouest, Montmagny (Chaudière-Appalaches) — 170 condos6# locatifs en 3 phases ; seule la phase 2 (60 unités, 5 étages) est exposée7# dans le sélecteur du site.8# Site WordPress (Elementor/Qode) avec le sélecteur d'unités « do1011 »9# (variante du plugin do-selecteur-plans utilisé par La Cour / AULA /10# Le St-Georges), rendu côté serveur :11# - polygones SVG par unité : class disponible / indisponible,12# data-numero (id du contenu WP), data-etage, data-type (« 4½ coin »),13# data-prix, data-promotion ;14# - REST /wp-json/do-selecteur-plans/v1/unite?id=<data-numero> :15# n° d'unité, état (« disponible » / « indisponible » + libellé16# « Louée »), type, superficie habitable et de balcon, plan PDF, galerie.17# Cette variante du plugin ne renvoie PAS de champ `montant` : le loyer18# publié est celui du polygone (data-prix) — aucune autre valeur n'est19# supposée. La carte SVG sert de clé de cache BD (self.detail) : le REST20# n'est rappelé que si l'unité a changé.21# ⚠ Cloudflare renvoie 403 aux robots sans en-tête de navigateur : le22# User-Agent de base.py (Chrome + LouKaBot) passe. robots.txt : Allow: /23# pour les agents génériques (blocage ciblé des robots d'entraînement IA) ;24# signal de contenu « use=reference », compatible avec un agrégateur qui25# renvoie vers la source.26# ⚠ État du marché au 2026-08-09 : les phases 1 et 3 sont complètes et les27# 60 unités de la phase 2 sont toutes « Louée » -> le connecteur retourne28# 0 annonce tant qu'aucune unité n'est remise en location (vérifié aussi29# unité par unité via le REST).30# -----------------------------------------------------------------------------31from __future__ import annotations3233import hashlib34import json35import re3637from bs4 import BeautifulSoup3839from ..schema import Listing, normalize_unit_type, parse_price40from .base import BaseConnector4142BASE = "https://placeflorimay.ca"43SELECTOR_PATH = "/" # le sélecteur d'unités est sur l'accueil44REST_UNIT = f"{BASE}/wp-json/do-selecteur-plans/v1/unite"45NAME = "Place Florimay"46# seule adresse publiée par la source (bureau de location du projet) :47# les adresses civiques par phase ne sont pas affichées sur le site.48ADDRESS = "136, boulevard Taché Ouest, Montmagny, QC G5V 3A5"49CITY = "Montmagny"5051_IMG_RE = re.compile(52 r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+'53 r'\.(?:jpg|jpeg|png|webp)', re.I)54_SKIP_IMG = re.compile(55 r"logo|icon|favicon|flavicon|cropped-|plan|selecteur"56 r"|-\d{2,4}x\d{2,4}\.", re.I)575859class PlaceFlorimayConnector(BaseConnector):60 source_id = "place_florimay"61 request_delay = 0.762 max_rest_units = 70 # garde-fou : appels REST max par sync63 max_images = 106465 # -- helpers ---------------------------------------------------------------66 @staticmethod67 def _svg_cards(html: str) -> list[tuple[str, dict]]:68 """Unités DISPONIBLES du sélecteur : (data-numero, attributs data-*).6970 Chaque unité apparaît deux fois (polygone + bouton mobile) : dédup par71 data-numero. Les classes « indisponible » (unité louée) sont exclues.72 """73 soup = BeautifulSoup(html, "html.parser")74 cards: list[tuple[str, dict]] = []75 seen: set[str] = set()76 for el in soup.select("[data-numero]"):77 cls = " ".join(el.get("class") or [])78 if not re.search(r"\bdisponible\b", cls) \79 or re.search(r"indisponible|attente", cls):80 continue81 pid = (el.get("data-numero") or "").strip()82 if not pid or pid in seen:83 continue84 seen.add(pid)85 attrs = {k: str(v) for k, v in el.attrs.items()86 if k.startswith("data-")}87 attrs["class"] = cls88 cards.append((pid, attrs))89 return cards9091 @staticmethod92 def _amenities(soup) -> list[str]:93 """Commodités de l'immeuble : items d'icônes du site (structure béton,94 ascenseur, lockers…)."""95 items: list[str] = []96 for el in soup.select(".qodef-icon-list-item .qodef-e-title-text"):97 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()98 if 3 <= len(t) <= 90 and t not in items:99 items.append(t)100 return items[:30]101102 def _images(self, html: str) -> list[str]:103 imgs: list[str] = []104 for u in dict.fromkeys(_IMG_RE.findall(html)):105 if not _SKIP_IMG.search(u) and u not in imgs:106 imgs.append(u)107 return imgs[: self.max_images]108109 @staticmethod110 def _sqft(raw) -> float | None:111 s = str(raw or "").strip().replace(" ", "").replace(",", ".")112 try:113 v = float(s)114 except ValueError:115 return None116 return v if 80 <= v <= 20000 else None117118 # -- fetch -----------------------------------------------------------------119 def fetch(self) -> list[Listing]:120 html = self.get(f"{BASE}{SELECTOR_PATH}").text121 soup = BeautifulSoup(html, "html.parser")122 cards = self._svg_cards(html)123 amenities = self._amenities(soup)124 images = self._images(html)125 rest_calls = 0126127 out: list[Listing] = []128 for pid, attrs in cards[: self.max_rest_units]:129 def _fetch(p=pid):130 nonlocal rest_calls131 if rest_calls >= self.max_rest_units:132 raise RuntimeError("budget de requêtes REST atteint")133 rest_calls += 1134 return self.get(REST_UNIT, params={"id": p}).json()135 try:136 # clé de cache BD = attributs du polygone (état, prix, type) :137 # le REST n'est appelé que si l'unité a changé138 key = hashlib.sha1(json.dumps(139 attrs, sort_keys=True).encode()).hexdigest()[:20]140 data = self.detail(pid, key, _fetch)141 except Exception:142 continue143 if not data or data.get("etat") != "disponible":144 continue145146 num = str(data.get("unite") or "").strip() or pid147 t = str(data.get("type") or attrs.get("data-type") or "")148 sup = str(data.get("superficie_habitable") or "").strip()149 terrasse = str(data.get("superficie_terrasse") or "").strip()150 etage = (attrs.get("data-etage") or "").strip()151 style = str(data.get("style") or "").strip()152 chambres = str(data.get("nb_chambres") or "").strip()153154 # prix : cette variante du plugin n'expose pas `montant` -> le155 # loyer publié est celui du polygone du sélecteur156 prix = (attrs.get("data-prix") or "").strip()157 price_label = f"{prix} $ par mois" if prix else ""158159 date_libre = str(data.get("date_libre") or "").strip(" |")160 availability = (f"Libre {date_libre}" if date_libre161 else str(data.get("etat_titre") or "Disponible"))162163 desc = " — ".join(x for x in [164 t, f"{sup} pi²" if sup else "",165 f"balcon {terrasse} pi²" if terrasse else "",166 f"{chambres} chambre(s)" if chambres else "",167 style,168 f"étage {etage}" if etage else ""] if x)169170 details: dict = {}171 if etage.isdigit():172 details["floor"] = int(etage)173 pdf = str(data.get("pdf") or "")174 if pdf.startswith("http"):175 details["plan_pdf"] = pdf176177 imgs = list(images)178 for g in reversed(data.get("gallery") or []):179 gu = g.get("url") if isinstance(g, dict) else str(g or "")180 if gu.startswith("http") and gu not in imgs:181 imgs.insert(0, gu)182183 out.append(Listing(184 source=self.source_id,185 external_id=num,186 url=f"{BASE}{SELECTOR_PATH}#unite-{num}",187 title=f"{NAME} — Unité {num}",188 address=ADDRESS,189 sector="",190 city=CITY,191 unit_type=normalize_unit_type(t),192 price=parse_price(price_label),193 price_label=price_label,194 availability=availability,195 area_sqft=self._sqft(sup),196 description=desc[:600],197 amenities=list(amenities),198 details=details,199 images=imgs[: self.max_images + 2],200 ))201 return out202