# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/place_florimay.py : connecteur Place Florimay (placeflorimay.ca) # Boulevard Taché Ouest, Montmagny (Chaudière-Appalaches) — 170 condos # locatifs en 3 phases ; seule la phase 2 (60 unités, 5 étages) est exposée # dans le sélecteur du site. # Site WordPress (Elementor/Qode) avec le sélecteur d'unités « do1011 » # (variante du plugin do-selecteur-plans utilisé par La Cour / AULA / # Le St-Georges), rendu côté serveur : # - polygones SVG par unité : class disponible / indisponible, # data-numero (id du contenu WP), data-etage, data-type (« 4½ coin »), # data-prix, data-promotion ; # - REST /wp-json/do-selecteur-plans/v1/unite?id= : # n° d'unité, état (« disponible » / « indisponible » + libellé # « Louée »), type, superficie habitable et de balcon, plan PDF, galerie. # Cette variante du plugin ne renvoie PAS de champ `montant` : le loyer # publié est celui du polygone (data-prix) — aucune autre valeur n'est # supposée. La carte SVG sert de clé de cache BD (self.detail) : le REST # n'est rappelé que si l'unité a changé. # ⚠ Cloudflare renvoie 403 aux robots sans en-tête de navigateur : le # User-Agent de base.py (Chrome + LouKaBot) passe. robots.txt : Allow: / # pour les agents génériques (blocage ciblé des robots d'entraînement IA) ; # signal de contenu « use=reference », compatible avec un agrégateur qui # renvoie vers la source. # ⚠ État du marché au 2026-08-09 : les phases 1 et 3 sont complètes et les # 60 unités de la phase 2 sont toutes « Louée » -> le connecteur retourne # 0 annonce tant qu'aucune unité n'est remise en location (vérifié aussi # unité par unité via le REST). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://placeflorimay.ca" SELECTOR_PATH = "/" # le sélecteur d'unités est sur l'accueil REST_UNIT = f"{BASE}/wp-json/do-selecteur-plans/v1/unite" NAME = "Place Florimay" # seule adresse publiée par la source (bureau de location du projet) : # les adresses civiques par phase ne sont pas affichées sur le site. ADDRESS = "136, boulevard Taché Ouest, Montmagny, QC G5V 3A5" CITY = "Montmagny" _IMG_RE = re.compile( r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+' r'\.(?:jpg|jpeg|png|webp)', re.I) _SKIP_IMG = re.compile( r"logo|icon|favicon|flavicon|cropped-|plan|selecteur" r"|-\d{2,4}x\d{2,4}\.", re.I) class PlaceFlorimayConnector(BaseConnector): source_id = "place_florimay" request_delay = 0.7 max_rest_units = 70 # garde-fou : appels REST max par sync max_images = 10 # -- helpers --------------------------------------------------------------- @staticmethod def _svg_cards(html: str) -> list[tuple[str, dict]]: """Unités DISPONIBLES du sélecteur : (data-numero, attributs data-*). Chaque unité apparaît deux fois (polygone + bouton mobile) : dédup par data-numero. Les classes « indisponible » (unité louée) sont exclues. """ soup = BeautifulSoup(html, "html.parser") cards: list[tuple[str, dict]] = [] seen: set[str] = set() for el in soup.select("[data-numero]"): cls = " ".join(el.get("class") or []) if not re.search(r"\bdisponible\b", cls) \ or re.search(r"indisponible|attente", cls): continue pid = (el.get("data-numero") or "").strip() if not pid or pid in seen: continue seen.add(pid) attrs = {k: str(v) for k, v in el.attrs.items() if k.startswith("data-")} attrs["class"] = cls cards.append((pid, attrs)) return cards @staticmethod def _amenities(soup) -> list[str]: """Commodités de l'immeuble : items d'icônes du site (structure béton, ascenseur, lockers…).""" items: list[str] = [] for el in soup.select(".qodef-icon-list-item .qodef-e-title-text"): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if 3 <= len(t) <= 90 and t not in items: items.append(t) return items[:30] def _images(self, html: str) -> list[str]: imgs: list[str] = [] for u in dict.fromkeys(_IMG_RE.findall(html)): if not _SKIP_IMG.search(u) and u not in imgs: imgs.append(u) return imgs[: self.max_images] @staticmethod def _sqft(raw) -> float | None: s = str(raw or "").strip().replace(" ", "").replace(",", ".") try: v = float(s) except ValueError: return None return v if 80 <= v <= 20000 else None # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: html = self.get(f"{BASE}{SELECTOR_PATH}").text soup = BeautifulSoup(html, "html.parser") cards = self._svg_cards(html) amenities = self._amenities(soup) images = self._images(html) rest_calls = 0 out: list[Listing] = [] for pid, attrs in cards[: self.max_rest_units]: def _fetch(p=pid): nonlocal rest_calls if rest_calls >= self.max_rest_units: raise RuntimeError("budget de requêtes REST atteint") rest_calls += 1 return self.get(REST_UNIT, params={"id": p}).json() try: # clé de cache BD = attributs du polygone (état, prix, type) : # le REST n'est appelé que si l'unité a changé key = hashlib.sha1(json.dumps( attrs, sort_keys=True).encode()).hexdigest()[:20] data = self.detail(pid, key, _fetch) except Exception: continue if not data or data.get("etat") != "disponible": continue num = str(data.get("unite") or "").strip() or pid t = str(data.get("type") or attrs.get("data-type") or "") sup = str(data.get("superficie_habitable") or "").strip() terrasse = str(data.get("superficie_terrasse") or "").strip() etage = (attrs.get("data-etage") or "").strip() style = str(data.get("style") or "").strip() chambres = str(data.get("nb_chambres") or "").strip() # prix : cette variante du plugin n'expose pas `montant` -> le # loyer publié est celui du polygone du sélecteur prix = (attrs.get("data-prix") or "").strip() price_label = f"{prix} $ par mois" if prix else "" date_libre = str(data.get("date_libre") or "").strip(" |") availability = (f"Libre {date_libre}" if date_libre else str(data.get("etat_titre") or "Disponible")) desc = " — ".join(x for x in [ t, f"{sup} pi²" if sup else "", f"balcon {terrasse} pi²" if terrasse else "", f"{chambres} chambre(s)" if chambres else "", style, f"étage {etage}" if etage else ""] if x) details: dict = {} if etage.isdigit(): details["floor"] = int(etage) pdf = str(data.get("pdf") or "") if pdf.startswith("http"): details["plan_pdf"] = pdf imgs = list(images) for g in reversed(data.get("gallery") or []): gu = g.get("url") if isinstance(g, dict) else str(g or "") if gu.startswith("http") and gu not in imgs: imgs.insert(0, gu) out.append(Listing( source=self.source_id, external_id=num, url=f"{BASE}{SELECTOR_PATH}#unite-{num}", title=f"{NAME} — Unité {num}", address=ADDRESS, sector="", city=CITY, unit_type=normalize_unit_type(t), price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=self._sqft(sup), description=desc[:600], amenities=list(amenities), details=details, images=imgs[: self.max_images + 2], )) return out