# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/immostar.py : connecteur Immostar (immostar.ca / immostaralouer.ca)
# Le résidentiel d'Immostar vit sur un micro-site par projet :
# - MU (habitermu.ca) et Le Huppé (lehuppe.ca) -> JSON `var fiches`
# - Kwayaweh (kwayaweh.ca) -> SVG + REST do-selecteur
# - Le 1070 Cap-Rouge (le1070.ca) -> SVG + REST do-selecteur
# - Le Maguire (lemaguire.ca) -> cartes .fiche_unite
# - Loges Saint-Nicolas (loges.ca, Lévis) -> cartes .fiche_unite
# Tous les micro-sites partagent la même plateforme : les pages
# /aires-de-vie/ et /votre-espace/ listent les commodités du projet.
# Exclus : Alo Ste-Foy (livraison 2028, rien à louer), Le Florent
# (Trois-Rivières, hors région Québec/Lévis), immostaralouer.ca (commercial).
# -----------------------------------------------------------------------------
from __future__ import annotations
import hashlib
import json
import re
from bs4 import BeautifulSoup
from ..schema import Listing, infer_city, parse_area_sqft, parse_price
from .base import BaseConnector
_IMG_RE = re.compile(
r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp)',
re.I)
_SKIP_IMG = re.compile(
r"logo|icon|favicon|plan|selecteur|niveau-|-\d{2,3}x\d{2,3}\.", re.I)
def _type_to_demi(raw: str) -> str:
"""'2 chambres (+ bureau)' -> '4½', 'Studio' -> 'Studio'."""
s = (raw or "").strip().lower()
if not s:
return ""
if "studio" in s:
return "Studio"
if "maison" in s or s.startswith("mdv"):
return "Maison"
m = re.search(r"(\d)\s*ch", s)
if m:
return f"{int(m.group(1)) + 2}½"
m = re.search(r"(\d)\s*(?:½|1/2)", s)
if m:
return f"{m.group(1)}½"
return raw.strip()
class ImmostarConnector(BaseConnector):
source_id = "immostar"
request_delay = 0.6
max_images = 15
max_rest_units = 60 # garde-fou REST (le1070)
# -- helpers ---------------------------------------------------------------
def _site_images(self, url: str) -> list[str]:
try:
html = self.get(url).text
except Exception:
return []
imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))
if not _SKIP_IMG.search(u)]
return imgs[: self.max_images]
def _site_amenities(self, site: str) -> list[str]:
"""Commodités du projet : listes à puces des pages /aires-de-vie/ et
/votre-espace/ (plateforme commune aux micro-sites Immostar), sinon
/le-projet/ (Le Maguire). Les
contenant des liens (menus,
pieds de page) sont ignorés."""
items: list[str] = []
for page in ("aires-de-vie/", "votre-espace/", "le-projet/"):
if items and page == "le-projet/":
break # page de repli seulement
try:
html = self.get(site + page).text
except Exception:
continue
soup = BeautifulSoup(html, "html.parser")
for ul in soup.find_all("ul"):
if ul.find_parent(("nav", "header", "footer")):
continue
cls = " ".join(ul.get("class") or []) + " " + (ul.get("id") or "")
if re.search(r"menu|nav|footer|cookie", cls, re.I):
continue
for li in ul.find_all("li"):
if li.find("a"):
continue
t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))
t = t.lstrip("—-– ").strip()
if 3 <= len(t) <= 90 and t not in items:
items.append(t)
# variante Loges : info-boxes WPBakery (h3.aio-icon-title)
for h3 in soup.select("h3.aio-icon-title"):
t = re.sub(r"\s+", " ", h3.get_text(" ", strip=True))
if 3 <= len(t) <= 90 and t not in items:
items.append(t)
return items[:30]
@staticmethod
def _fiches(html: str) -> list[dict]:
"""Extrait `var fiches = {"data": {...}}` (plateforme MU / Le Huppé)."""
i = html.find("var fiches = ")
if i < 0:
return []
try:
data, _ = json.JSONDecoder().raw_decode(html[i + len("var fiches = "):])
except Exception:
return []
return list((data.get("data") or {}).values())
def _mk(self, proj: dict, num: str, unit_type_raw: str, price_label: str,
availability: str, desc: str, images: list[str],
area_sqft: float | None = None, title_num: str = "") -> Listing:
sector = proj["sector"]
return Listing(
source=self.source_id,
external_id=f"{proj['id']}-{num}",
url=f"{proj['plans'][0]}#unite-{num}",
title=f"{proj['name']} — Unité {title_num or num}",
address=proj.get("address", ""),
sector=sector,
city=infer_city(sector),
unit_type=_type_to_demi(unit_type_raw),
price=parse_price(price_label),
price_label=price_label,
availability=availability,
area_sqft=area_sqft,
description=desc[:600],
amenities=list(proj.get("amenities", [])),
images=images,
)
@staticmethod
def _sqft(raw) -> float | None:
"""'630' / '1 102' -> pi² (None si absent ou implausible)."""
s = str(raw or "").strip().replace(" ", "").replace(",", ".")
try:
v = float(s)
except ValueError:
return None
return v if 80 <= v <= 20000 else None
# -- parseurs par plateforme ------------------------------------------------
def _parse_fiches(self, proj: dict, html: str,
images: list[str]) -> list[Listing]:
out = []
for f in self._fiches(html):
dispo = f.get("disponibilite")
if isinstance(dispo, dict): # variante MU
dispo = dispo.get("value")
if dispo != "available":
continue
num = str(f.get("numero") or "").strip()
if not num:
continue
t = f.get("type") or ""
if isinstance(t, dict): # variante MU
t = t.get("label", "")
if str(f.get("bureau")) == "1":
t += " + bureau"
prix = str(f.get("prix") or "").strip()
sup = str(f.get("superficie") or "").strip()
terrasse = str(f.get("superficie_terrasse") or "").strip()
chambres = str(f.get("chambres") or "").strip()
sdb = str(f.get("salles_de_bain") or "").strip()
caract = str(f.get("caracteristiques") or "").strip()
desc = " — ".join(x for x in [
t, f"{sup} pi²" if sup else "",
f"terrasse {terrasse} pi²" if terrasse else "",
f"{chambres} chambre(s)" if chambres else "",
f"{sdb} salle(s) de bain" if sdb else "",
caract if caract and caract.lower() != "regulier" else "",
f"étage {f.get('etage')}" if f.get("etage") else ""] if x)
# plan de l'unité en tête de galerie (si image, pas PDF)
imgs = list(images)
plan = str(f.get("plan") or "")
if re.search(r"\.(?:jpe?g|png|webp)$", plan, re.I):
imgs.insert(0, plan)
out.append(self._mk(
proj, num, t,
f"{prix}$ / mois" if prix else "",
f"Libre {f['date']}" if f.get("date") else "Disponible",
desc, imgs, area_sqft=self._sqft(sup)))
return out
def _parse_do_rest(self, proj: dict, html: str,
images: list[str]) -> list[Listing]:
"""Kwayaweh / Le 1070 : éléments SVG `disponible` data-numero= + REST
/wp-json/do-selecteur-plans/v1/unite?id=N (prix, superficie, type,
date de libération, plan). Les éléments Kwayaweh portent
data-prix/type/promotion : cette carte sert de clé de cache BD
(self.detail) pour ne re-frapper le REST que si l'unité a changé."""
base = re.match(r"https?://[^/]+", proj["plans"][0]).group(0)
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"\b(disponible|available)\b", cls) \
or "indisponible" in cls:
continue
num = (el.get("data-numero") or "").strip()
if not num or num in seen:
continue
seen.add(num)
attrs = {k: str(v) for k, v in el.attrs.items()
if k.startswith("data-")}
attrs["class"] = cls
cards.append((num, attrs))
uid_from_numero = proj.get("uid_field") == "numero"
out = []
for num, attrs in cards[: self.max_rest_units]:
def _fetch(n=num):
if self._rest_fetched >= self.max_rest_units * 2:
raise RuntimeError("budget de requêtes REST atteint")
self._rest_fetched += 1
return self.get(
f"{base}/wp-json/do-selecteur-plans/v1/unite",
params={"id": n}).json()
try:
if uid_from_numero and "data-prix" in attrs:
# uid connu d'avance -> cache BD (clé = attributs SVG)
key = hashlib.sha1(json.dumps(
attrs, sort_keys=True).encode()).hexdigest()[:20]
data = self.detail(f"{proj['id']}-{num}", key, _fetch)
else:
data = _fetch()
except Exception:
continue
if not data or data.get("etat") != "disponible":
continue
unite = str(data.get("unite") or num)
t = str(data.get("type") or "")
sup = str(data.get("superficie_habitable") or "").strip()
terrasse = str(data.get("superficie_terrasse") or "").strip()
chambres = str(data.get("nb_chambres") or "").strip()
style = str(data.get("style") or "").strip()
parking = str(data.get("stationnement") or "").strip()
desc = " — ".join(x for x in [
t, f"{sup} pi²" if sup else "",
f"terrasse {terrasse} pi²" if terrasse else "",
f"{chambres} chambre(s)" if chambres else "",
style,
f"stationnement {parking}" if parking else "",
f"étage {attrs.get('data-etage')}"
if attrs.get("data-etage") else ""] if x)
date_libre = str(data.get("date_libre") or "").strip()
a_partir = str(data.get("a_partir") or "").strip()
avail = (f"Libre {date_libre}" if date_libre
else a_partir or "Disponible")
imgs = list(images)
for g in (data.get("gallery") or []):
gu = g.get("url") if isinstance(g, dict) else str(g or "")
if gu and gu.startswith("http") and gu not in imgs:
imgs.insert(0, gu)
montant = str(data.get("montant") or "").strip()
out.append(self._mk(
proj, num if uid_from_numero else unite,
t or str(data.get("type_demi") or ""),
f"À partir de {montant}" if montant else "",
avail, desc, imgs,
area_sqft=self._sqft(sup), title_num=unite))
return out
def _parse_maguire(self, proj: dict, html: str,
images: list[str]) -> list[Listing]:
"""Le Maguire : cartes `.fiche_unite.available` rendues serveur
(prix, superficies, chambres, plan JPG de l'unité)."""
soup = BeautifulSoup(html, "html.parser")
out = []
for card in soup.select(".fiche_unite"):
cls = card.get("class") or []
if "available" not in cls or "rented" in cls:
continue
num = (card.get("data-unite") or "").strip()
if not num:
continue
prix_el = card.select_one(".prix")
price_label = prix_el.get_text(" ", strip=True) if prix_el else ""
sup_el = card.select_one("p.superficie:not(.terrasse)")
sup_txt = sup_el.get_text(" ", strip=True) if sup_el else ""
terr_el = card.select_one("p.superficie.terrasse")
terr_txt = terr_el.get_text(" ", strip=True) if terr_el else ""
notes_el = card.select_one(".notes")
t = notes_el.get_text(" ", strip=True) if notes_el else ""
dispo = card.select_one(".disponibilite")
etage = (card.get("data-etage") or "").strip()
desc = " — ".join(x for x in [
t, sup_txt, terr_txt,
f"étage {etage}" if etage else ""] if x)
imgs = list(images)
plan = card.select_one("a.pdf[href]")
if plan and re.search(r"\.(?:jpe?g|png|webp)$",
plan.get("href") or "", re.I):
imgs.insert(0, plan["href"])
out.append(self._mk(
proj, num, t, price_label,
dispo.get_text(strip=True) if dispo else "Disponible",
desc, imgs, area_sqft=parse_area_sqft(sup_txt)))
return out
def _parse_loges(self, proj: dict, html: str,
images: list[str]) -> list[Listing]:
"""Loges : cartes `.affichage.liste .fiche_unite` rendues serveur."""
soup = BeautifulSoup(html, "html.parser")
out = []
for card in soup.select(".affichage.liste .fiche_unite"):
num = (card.get("data-unite") or "").strip()
h4 = card.select_one("h4")
head = h4.get_text(" ", strip=True) if h4 else ""
if not num or "$" not in head:
continue # unité louée ou sans prix
info = card.select_one("header p")
info_txt = info.get_text(" ", strip=True) if info else ""
m = re.search(r"(\d)\s*chambre", info_txt)
t = f"{m.group(1)} chambres" if m else ""
dispo = card.select_one(".disponibilite")
desc_el = card.select_one("main .description")
desc_txt = (re.sub(r"\s+", " ", desc_el.get_text(" ", strip=True))
if desc_el else "")
desc = " — ".join(x for x in [info_txt, desc_txt] if x)
price_m = re.search(r"À partir de\s*([\d\s,.]+\$)", head)
price_label = (f"À partir de {price_m.group(1)}/mois"
if price_m else head)
out.append(self._mk(
proj, num, t, price_label,
dispo.get_text(strip=True) if dispo else "Disponible",
desc, images, area_sqft=parse_area_sqft(info_txt)))
return out
# -- fetch -----------------------------------------------------------------
def fetch(self) -> list[Listing]:
projects = [
{"id": "mu", "name": "MU", "sector": "Sainte-Foy",
"address": "2605, boulevard Laurier, Québec",
"site": "https://habitermu.ca/",
"plans": ["https://habitermu.ca/plans/"], "mode": "fiches"},
{"id": "huppe", "name": "Le Huppé", "sector": "Lebourgneuf",
"address": "5055, boulevard des Gradins, Québec",
"site": "https://lehuppe.ca/",
"plans": ["https://lehuppe.ca/plans/"], "mode": "fiches"},
{"id": "1070", "name": "Le 1070 Cap-Rouge", "sector": "Cap-Rouge",
"address": "1070, boulevard de la Chaudière, Québec",
"site": "https://le1070.ca/",
"plans": ["https://le1070.ca/plans/"], "mode": "do_rest"},
{"id": "kwayaweh", "name": "Kwayaweh", "sector": "Wendake",
"address": "Wendake",
"site": "https://kwayaweh.ca/",
"plans": ["https://kwayaweh.ca/selecteur/"], "mode": "do_rest",
"uid_field": "numero"}, # uid historique = data-numero (post id)
{"id": "maguire", "name": "Le Maguire sur l'avenue",
"sector": "Sillery", "address": "Avenue Maguire, Québec",
"site": "https://lemaguire.ca/",
"plans": ["https://lemaguire.ca/les-plans/"], "mode": "maguire"},
{"id": "loges-555", "name": "Loges Saint-Nicolas (Le 555)",
"sector": "Saint-Nicolas", "address": "555, rue Marie-Victorin, Lévis",
"site": "https://loges.ca/",
"plans": ["https://loges.ca/plans/le-555/"], "mode": "loges"},
{"id": "loges-550", "name": "Loges Saint-Nicolas (Le 550)",
"sector": "Saint-Nicolas", "address": "550, rue Jérôme-Demers, Lévis",
"site": "https://loges.ca/",
"plans": ["https://loges.ca/plans/le-550/"], "mode": "loges"},
{"id": "loges-600", "name": "Loges Saint-Nicolas (Le 600)",
"sector": "Saint-Nicolas", "address": "600, rue Pierre-Perrault, Lévis",
"site": "https://loges.ca/",
"plans": ["https://loges.ca/plans/le-600/"], "mode": "loges"},
]
parsers = {
"fiches": self._parse_fiches,
"do_rest": self._parse_do_rest,
"maguire": self._parse_maguire,
"loges": self._parse_loges,
}
self._rest_fetched = 0
listings: list[Listing] = []
site_imgs: dict[str, list[str]] = {}
site_amen: dict[str, list[str]] = {}
for proj in projects:
try:
site = proj["site"]
if site not in site_imgs:
site_imgs[site] = self._site_images(site)
if site not in site_amen:
site_amen[site] = self._site_amenities(site)
images = site_imgs[site]
proj["amenities"] = site_amen[site]
for plans_url in proj["plans"]:
html = self.get(plans_url).text
listings.extend(parsers[proj["mode"]](proj, html, images))
except Exception:
continue
# dédup par external_id (sécurité)
uniq: dict[str, Listing] = {}
for lst in listings:
uniq.setdefault(lst.external_id, lst)
return list(uniq.values())