# ----------------------------------------------------------------------------- # 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())