# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/heafey.py : connecteur Groupe Heafey (Gatineau, secteur Hull) # Le résidentiel du groupe vit sur un micro-site WordPress par projet, comme # Immostar (immostar.py) : # - Le We 2 et Le We 3 (lewe2.ca) -> sélecteur d'unités « Resimo » # - Loggia sur le Parc (loggiasurleparc.com) -> même sélecteur Resimo # La page /plans/ de chaque site intègre en iframe l'application Resimo # (allinone-corsim-*.prod.resimo.io) dont la configuration publique donne # l'URL d'inventaire, le jeton d'intégration et la liste des immeubles ; # cet endpoint renvoie un JSON par immeuble : n° d'unité, superficie (pi²), # nombre de chambres, statut (available / reserved / sold) et, avec # additional_fields, le plan d'étage (pdf_url) et le lien de location. # La découverte se fait à chaque sync (aucun jeton codé en dur) : 1 requête # /plans/ + 1 requête app + 1 requête inventaire par site. # Une annonce = une unité DISPONIBLE. ⚠ Le paramètre Resimo `showPrice` est # à false sur les trois projets : AUCUN prix n'est publié par unité (les # pages « unités vedettes » n'affichent des prix que par typologie type) — # price reste donc None, rien n'est inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import re from urllib.parse import quote, urljoin from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector # Projets (un micro-site peut porter deux immeubles : We 2 et We 3) PROJECTS = [ {"id": "we2", "name": "Le We 2", "building": "Le We2", "site": "https://lewe2.ca/", "plans": "plans/", "address": "45, rue Eddy, Gatineau, QC J8X 2V8", "sector": "Hull", "pages": ("unites-vedettes/", "espaces-communs/")}, # We 3 : tour voisine, livraison septembre 2026 ; son adresse civique # propre n'est pas publiée sur le site (seul le bureau de location # 45, rue Eddy l'est) -> adresse laissée vide plutôt qu'inventée. {"id": "we3", "name": "Le We 3", "building": "Le We3", "site": "https://lewe2.ca/", "plans": "plans/", "address": "", "sector": "Hull", "pages": ("unites-vedettes/", "espaces-communs/")}, {"id": "loggia", "name": "Loggia sur le Parc", "building": "LOGGIA Sur le Parc", "site": "https://loggiasurleparc.com/", "plans": "plans/", "address": "401, rue Champlain, Gatineau, QC J8X 0B1", "sector": "Hull", "pages": ("unites-vedettes/", "espaces-communs/")}, ] _APP_RE = re.compile(r"https://[a-z0-9.-]*resimo\.io/[^\"']*") _API_RE = re.compile(r"url:\s*'(https://api\.resimo[^']+/apartments/?)'") _TOKEN_RE = re.compile(r"token:\s*'([^']+)'") _BUILDINGS_RE = re.compile(r"buildingList:\s*\[([^\]]*)\]") _FIELDS_RE = re.compile(r"additionalFields:\s*\[([^\]]*)\]") _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|-\d{2,4}x\d{2,4}\.", re.I) class HeafeyConnector(BaseConnector): source_id = "heafey" request_delay = 0.6 max_images = 10 max_units = 400 # garde-fou (We 3 seul compte 342 unités) # -- découverte de l'inventaire Resimo ------------------------------------- def _inventory(self, site: str, plans_path: str) -> dict[str, list[dict]]: """Inventaire par immeuble : {'Le We2': [unités...], ...}. Suit la chaîne publiée par le site : page /plans/ -> iframe de l'application Resimo -> configuration (URL, jeton, immeubles) -> JSON. """ html = self.get(urljoin(site, plans_path)).text soup = BeautifulSoup(html, "html.parser") app_url = "" for frame in soup.find_all("iframe"): for attr in ("data-lazy-src", "src", "data-src"): cand = frame.get(attr) or "" if "resimo.io" in cand: app_url = cand.split("#")[0] break if app_url: break if not app_url: m = _APP_RE.search(html) app_url = m.group(0).split("#")[0] if m else "" if not app_url: return {} cfg = self.get(app_url).text m_api, m_tok = _API_RE.search(cfg), _TOKEN_RE.search(cfg) if not (m_api and m_tok): return {} m_bld = _BUILDINGS_RE.search(cfg) m_fld = _FIELDS_RE.search(cfg) buildings = re.findall(r"'([^']+)'", m_bld.group(1)) if m_bld else [] fields = re.findall(r"'([^']+)'", m_fld.group(1)) if m_fld else [] params = {"token": m_tok.group(1)} if buildings: params["building_list"] = ";".join(buildings) if fields: params["additional_fields"] = ",".join(fields) data = self.get(m_api.group(1), params=params).json() out: dict[str, list[dict]] = {} for bloc in data if isinstance(data, list) else []: num = str(bloc.get("number") or "") if num: out[num] = list(bloc.get("apartments") or []) return out # -- pages du micro-site --------------------------------------------------- def _site_images(self, site: str) -> list[str]: """Photos du projet (uploads jpg/webp de l'accueil et de la galerie).""" imgs: list[str] = [] for page in ("", "galerie/"): try: html = self.get(urljoin(site, page)).text except Exception: continue 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] def _site_amenities(self, site: str, pages: tuple[str, ...]) -> list[str]: """Inclusions et espaces communs tels qu'écrits par le promoteur : phrase « Inclusions : … » des unités vedettes (identique pour tout le projet) + titres des espaces communs.""" items: list[str] = [] for page in pages: try: html = self.get(urljoin(site, page)).text except Exception: continue soup = BeautifulSoup(html, "html.parser") text = soup.get_text("\n", strip=True) for m in re.finditer(r"Inclusions\s*:\s*\n?([^\n]{10,300})", text): for part in re.split(r"[,;]|\bet\b", m.group(1)): t = part.strip(" . ") if 3 <= len(t) <= 80 and t not in items: items.append(t) for h in soup.select("h2, h3"): t = re.sub(r"\s+", " ", h.get_text(" ", strip=True)).strip() # les titres de TYPOLOGIE (« 2 chambres, 1 salle de bain ») # décrivent une unité, pas un espace commun if re.search(r"(?i)chambres?\b|salle de bain|\d\s*½", t): continue if (3 <= len(t) <= 60 and t not in items and re.search(r"(?i)piscine|salle|terrasse|salon|toit" r"|sauna|espace|gym|ascenseur|lounge" r"|yoga|stationnement", t)): items.append(t) return items[:30] # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: inventories: dict[str, dict[str, list[dict]]] = {} images: dict[str, list[str]] = {} amenities: dict[str, list[str]] = {} listings: list[Listing] = [] for proj in PROJECTS: site = proj["site"] try: if site not in inventories: inventories[site] = self._inventory(site, proj["plans"]) images[site] = self._site_images(site) amenities[site] = self._site_amenities(site, proj["pages"]) units = inventories[site].get(proj["building"], []) except Exception: continue plans_url = urljoin(site, proj["plans"]) for unit in units[: self.max_units]: try: if str(unit.get("status") or "").lower() != "available": continue num = str(unit.get("name") or unit.get("mappingName") or "") if not num: continue area = None try: val = float(unit.get("area") or 0) area = val if 80 <= val <= 20000 else None except (TypeError, ValueError): area = None rooms = unit.get("rooms") unit_type = (normalize_unit_type(f"{int(rooms)} chambres") if isinstance(rooms, (int, float)) and rooms else "") imgs = list(images.get(site, [])) plan = str(unit.get("pdfUrl") or "") if plan.startswith("http"): # les noms de fichiers Resimo contiennent des espaces imgs.insert(0, quote(plan, safe=":/?=&%")) desc = " — ".join(x for x in [ f"{int(rooms)} chambre(s)" if isinstance(rooms, (int, float)) and rooms else "", f"{area:.0f} pi²" if area else "", proj["name"]] if x) listings.append(Listing( source=self.source_id, external_id=f"{proj['id']}-{num}", url=f"{plans_url}#unite-{num}", title=f"{proj['name']} — Unité {num}", address=proj["address"], sector=proj["sector"], city="Gatineau", unit_type=unit_type, price=None, # showPrice=false : aucun prix publié price_label="", availability="Disponible", area_sqft=area, description=desc[:600], amenities=list(amenities.get(site, [])), images=imgs[: self.max_images + 1], )) except Exception: continue uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values())