# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/imvest.py : connecteur Société Imvest (societeimvest.ca)
# Le Méribel, 195 logements en 2 phases au 1191, rue de Courchevel à Lévis
# (secteur Saint-Romuald). WordPress (thème TeamPress) avec plans d'étage
# interactifs : endpoints AJAX POST maison —
# /ajax/recherche-etage/ (id=immeuble) -> liste des étages (li.Etage)
# /ajax/load-etage/ (id=étage) -> par unité (no, type,
# superficie dans data-tooltip) + JSON `areas` dont fillColor code le
# statut : cbcbcb = loué, 940723 = réservé, autre = disponible
# /ajax/recherche-unite/ (id=unité) -> détail (disponibilité, plan,
# caractéristiques)
# Granularité : unité. Pas de prix publié (Tier B).
# -----------------------------------------------------------------------------
from __future__ import annotations
import hashlib
import json
import re
from ..schema import Listing, normalize_unit_type
from .base import BaseConnector
BASE = "https://www.societeimvest.ca"
AJAX_ETAGES = f"{BASE}/ajax/recherche-etage/"
AJAX_PLAN = f"{BASE}/ajax/load-etage/"
AJAX_UNITE = f"{BASE}/ajax/recherche-unite/"
# phases (slug de page « immeubles ») — l'id AJAX est lu sur la page
PHASES = [("le-meribel", "Le Méribel — Phase 1"),
("le-meribel-phase-2", "Le Méribel — Phase 2")]
ADDRESS = "1191, rue de Courchevel, Lévis"
LAT, LNG = 46.732973, -71.262941
IMMEUBLE_ID_RE = re.compile(r"immeuble_id = '(\d+)'")
FLOOR_RE = re.compile(r'
]*'
r'data-key="(\d+)"')
AREAS_JSON_RE = re.compile(r"areas:\s*(\[.*?\])", re.S)
TYPE_RE = re.compile(r"([^<]*)")
SQFT_RE = re.compile(r"Superficie\s*:\s*([\d\s]+)\s*p\.?c\.?", re.I)
DISPO_RE = re.compile(r"Disponibilit[^:]*:\s*"
r"([^<]*)", re.I)
PLAN_RE = re.compile(r'href="(https://www\.societeimvest\.ca/wp-content/'
r'uploads/[^"]+\.pdf)"')
IMG_RE = re.compile(r'
]+src="([^"]+phpThumb[^"]+)"')
CARAC_RE = re.compile(r"Caractéristiques du logement\s*",
re.S)
LI_RE = re.compile(r"(.*?)", re.S)
# statuts non offerts en location (gris = loué, rouge foncé = réservé)
TAKEN_COLORS = {"cbcbcb", "940723"}
class ImvestConnector(BaseConnector):
source_id = "imvest"
request_delay = 0.6
def fetch(self) -> list[Listing]:
listings: list[Listing] = []
for slug, phase_name in PHASES:
page_url = f"{BASE}/immeubles/{slug}/"
try:
page = self.get(page_url).text
except Exception:
continue
m = IMMEUBLE_ID_RE.search(page)
if not m:
continue
try:
floors_html = self.post(AJAX_ETAGES,
data={"id": m.group(1)}).text
except Exception:
continue
for floor_id, floor_name in FLOOR_RE.findall(floors_html):
try:
listings.extend(self._parse_floor(
slug, phase_name, page_url, floor_id, floor_name))
except Exception:
continue
return listings
def _parse_floor(self, slug: str, phase_name: str, page_url: str,
floor_id: str, floor_name: str) -> list[Listing]:
out: list[Listing] = []
plan_html = self.post(AJAX_PLAN, data={"id": floor_id}).text
# statut de chaque unité (JSON mapster : key = no d'unité)
status: dict[str, str] = {}
m = AREAS_JSON_RE.search(plan_html)
if m:
try:
for a in json.loads(m.group(1)):
status[str(a.get("key"))] = a.get("fillColor", "")
except ValueError:
pass
for tooltip, unit_id, unit_no in AREA_RE.findall(plan_html):
color = status.get(unit_no, "")
if not color or color.lower() in TAKEN_COLORS:
continue # loué / réservé / inconnu
tm = TYPE_RE.search(tooltip)
unit_type = normalize_unit_type(tm.group(1)) if tm else ""
sm = SQFT_RE.search(tooltip)
area = float(sm.group(1).replace(" ", "").replace(" ", "")) \
if sm else None
key = hashlib.sha1(f"{color}|{tooltip}".encode()).hexdigest()
d = self.detail(f"{slug}-{unit_no}", key,
lambda uid=unit_id: self._fetch_unit(uid))
out.append(Listing(
source=self.source_id,
external_id=f"{slug}-{unit_no}",
url=page_url,
title=f"Unité {unit_no} — {phase_name}",
address=ADDRESS,
sector="Saint-Romuald",
city="Lévis",
unit_type=unit_type,
area_sqft=area,
price_label="Prix sur demande",
availability=d.get("availability", ""),
description=f"Unité {unit_no} ({unit_type}), {floor_name} — "
f"{phase_name}, Société Imvest, Lévis.",
amenities=d.get("amenities") or [],
details={"floor": floor_name,
**({"floor_plan": d["plan"]} if d.get("plan")
else {})},
images=d.get("images") or [],
lat=LAT,
lng=LNG,
))
return out
def _fetch_unit(self, unit_id: str) -> dict:
"""Détail d'une unité via /ajax/recherche-unite/ (POST id=…)."""
out: dict = {}
try:
html = self.post(AJAX_UNITE, data={"id": unit_id}).text
except Exception:
return out
m = DISPO_RE.search(html)
if m:
dispo = m.group(1).strip()
out["availability"] = dispo if re.match(r"^\d|^libre|^immédiat",
dispo, re.I) \
else f"Disponibilité : {dispo}"
m = PLAN_RE.search(html)
if m:
out["plan"] = m.group(1)
m = CARAC_RE.search(html)
if m:
out["amenities"] = [
re.sub(r"<[^>]+>", "", li).strip()
for li in LI_RE.findall(m.group(1))
if re.sub(r"<[^>]+>", "", li).strip()][:20]
imgs = [u.replace("&", "&") for u in IMG_RE.findall(html)
if "ajax_loading" not in u]
if imgs:
out["images"] = imgs[:5]
return out