spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/heafey.py : connecteur Groupe Heafey (Gatineau, secteur Hull)5# Le résidentiel du groupe vit sur un micro-site WordPress par projet, comme6# Immostar (immostar.py) :7# - Le We 2 et Le We 3 (lewe2.ca) -> sélecteur d'unités « Resimo »8# - Loggia sur le Parc (loggiasurleparc.com) -> même sélecteur Resimo9# La page /plans/ de chaque site intègre en iframe l'application Resimo10# (allinone-corsim-*.prod.resimo.io) dont la configuration publique donne11# l'URL d'inventaire, le jeton d'intégration et la liste des immeubles ;12# cet endpoint renvoie un JSON par immeuble : n° d'unité, superficie (pi²),13# nombre de chambres, statut (available / reserved / sold) et, avec14# additional_fields, le plan d'étage (pdf_url) et le lien de location.15# La découverte se fait à chaque sync (aucun jeton codé en dur) : 1 requête16# /plans/ + 1 requête app + 1 requête inventaire par site.17# Une annonce = une unité DISPONIBLE. ⚠ Le paramètre Resimo `showPrice` est18# à false sur les trois projets : AUCUN prix n'est publié par unité (les19# pages « unités vedettes » n'affichent des prix que par typologie type) —20# price reste donc None, rien n'est inventé.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import re25from urllib.parse import quote, urljoin2627from bs4 import BeautifulSoup2829from ..schema import Listing, normalize_unit_type30from .base import BaseConnector3132# Projets (un micro-site peut porter deux immeubles : We 2 et We 3)33PROJECTS = [34 {"id": "we2", "name": "Le We 2", "building": "Le We2",35 "site": "https://lewe2.ca/", "plans": "plans/",36 "address": "45, rue Eddy, Gatineau, QC J8X 2V8", "sector": "Hull",37 "pages": ("unites-vedettes/", "espaces-communs/")},38 # We 3 : tour voisine, livraison septembre 2026 ; son adresse civique39 # propre n'est pas publiée sur le site (seul le bureau de location40 # 45, rue Eddy l'est) -> adresse laissée vide plutôt qu'inventée.41 {"id": "we3", "name": "Le We 3", "building": "Le We3",42 "site": "https://lewe2.ca/", "plans": "plans/",43 "address": "", "sector": "Hull",44 "pages": ("unites-vedettes/", "espaces-communs/")},45 {"id": "loggia", "name": "Loggia sur le Parc",46 "building": "LOGGIA Sur le Parc",47 "site": "https://loggiasurleparc.com/", "plans": "plans/",48 "address": "401, rue Champlain, Gatineau, QC J8X 0B1", "sector": "Hull",49 "pages": ("unites-vedettes/", "espaces-communs/")},50]5152_APP_RE = re.compile(r"https://[a-z0-9.-]*resimo\.io/[^\"']*")53_API_RE = re.compile(r"url:\s*'(https://api\.resimo[^']+/apartments/?)'")54_TOKEN_RE = re.compile(r"token:\s*'([^']+)'")55_BUILDINGS_RE = re.compile(r"buildingList:\s*\[([^\]]*)\]")56_FIELDS_RE = re.compile(r"additionalFields:\s*\[([^\]]*)\]")57_IMG_RE = re.compile(58 r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp)',59 re.I)60_SKIP_IMG = re.compile(61 r"logo|icon|favicon|plan|selecteur|-\d{2,4}x\d{2,4}\.", re.I)626364class HeafeyConnector(BaseConnector):65 source_id = "heafey"66 request_delay = 0.667 max_images = 1068 max_units = 400 # garde-fou (We 3 seul compte 342 unités)6970 # -- découverte de l'inventaire Resimo -------------------------------------71 def _inventory(self, site: str, plans_path: str) -> dict[str, list[dict]]:72 """Inventaire par immeuble : {'Le We2': [unités...], ...}.7374 Suit la chaîne publiée par le site : page /plans/ -> iframe de75 l'application Resimo -> configuration (URL, jeton, immeubles) -> JSON.76 """77 html = self.get(urljoin(site, plans_path)).text78 soup = BeautifulSoup(html, "html.parser")79 app_url = ""80 for frame in soup.find_all("iframe"):81 for attr in ("data-lazy-src", "src", "data-src"):82 cand = frame.get(attr) or ""83 if "resimo.io" in cand:84 app_url = cand.split("#")[0]85 break86 if app_url:87 break88 if not app_url:89 m = _APP_RE.search(html)90 app_url = m.group(0).split("#")[0] if m else ""91 if not app_url:92 return {}9394 cfg = self.get(app_url).text95 m_api, m_tok = _API_RE.search(cfg), _TOKEN_RE.search(cfg)96 if not (m_api and m_tok):97 return {}98 m_bld = _BUILDINGS_RE.search(cfg)99 m_fld = _FIELDS_RE.search(cfg)100 buildings = re.findall(r"'([^']+)'", m_bld.group(1)) if m_bld else []101 fields = re.findall(r"'([^']+)'", m_fld.group(1)) if m_fld else []102 params = {"token": m_tok.group(1)}103 if buildings:104 params["building_list"] = ";".join(buildings)105 if fields:106 params["additional_fields"] = ",".join(fields)107108 data = self.get(m_api.group(1), params=params).json()109 out: dict[str, list[dict]] = {}110 for bloc in data if isinstance(data, list) else []:111 num = str(bloc.get("number") or "")112 if num:113 out[num] = list(bloc.get("apartments") or [])114 return out115116 # -- pages du micro-site ---------------------------------------------------117 def _site_images(self, site: str) -> list[str]:118 """Photos du projet (uploads jpg/webp de l'accueil et de la galerie)."""119 imgs: list[str] = []120 for page in ("", "galerie/"):121 try:122 html = self.get(urljoin(site, page)).text123 except Exception:124 continue125 for u in dict.fromkeys(_IMG_RE.findall(html)):126 if not _SKIP_IMG.search(u) and u not in imgs:127 imgs.append(u)128 return imgs[: self.max_images]129130 def _site_amenities(self, site: str, pages: tuple[str, ...]) -> list[str]:131 """Inclusions et espaces communs tels qu'écrits par le promoteur :132 phrase « Inclusions : … » des unités vedettes (identique pour tout le133 projet) + titres des espaces communs."""134 items: list[str] = []135 for page in pages:136 try:137 html = self.get(urljoin(site, page)).text138 except Exception:139 continue140 soup = BeautifulSoup(html, "html.parser")141 text = soup.get_text("\n", strip=True)142 for m in re.finditer(r"Inclusions\s*:\s*\n?([^\n]{10,300})", text):143 for part in re.split(r"[,;]|\bet\b", m.group(1)):144 t = part.strip(" . ")145 if 3 <= len(t) <= 80 and t not in items:146 items.append(t)147 for h in soup.select("h2, h3"):148 t = re.sub(r"\s+", " ", h.get_text(" ", strip=True)).strip()149 # les titres de TYPOLOGIE (« 2 chambres, 1 salle de bain »)150 # décrivent une unité, pas un espace commun151 if re.search(r"(?i)chambres?\b|salle de bain|\d\s*½", t):152 continue153 if (3 <= len(t) <= 60 and t not in items154 and re.search(r"(?i)piscine|salle|terrasse|salon|toit"155 r"|sauna|espace|gym|ascenseur|lounge"156 r"|yoga|stationnement", t)):157 items.append(t)158 return items[:30]159160 # -- fetch -----------------------------------------------------------------161 def fetch(self) -> list[Listing]:162 inventories: dict[str, dict[str, list[dict]]] = {}163 images: dict[str, list[str]] = {}164 amenities: dict[str, list[str]] = {}165166 listings: list[Listing] = []167 for proj in PROJECTS:168 site = proj["site"]169 try:170 if site not in inventories:171 inventories[site] = self._inventory(site, proj["plans"])172 images[site] = self._site_images(site)173 amenities[site] = self._site_amenities(site, proj["pages"])174 units = inventories[site].get(proj["building"], [])175 except Exception:176 continue177178 plans_url = urljoin(site, proj["plans"])179 for unit in units[: self.max_units]:180 try:181 if str(unit.get("status") or "").lower() != "available":182 continue183 num = str(unit.get("name") or unit.get("mappingName") or "")184 if not num:185 continue186 area = None187 try:188 val = float(unit.get("area") or 0)189 area = val if 80 <= val <= 20000 else None190 except (TypeError, ValueError):191 area = None192 rooms = unit.get("rooms")193 unit_type = (normalize_unit_type(f"{int(rooms)} chambres")194 if isinstance(rooms, (int, float)) and rooms195 else "")196197 imgs = list(images.get(site, []))198 plan = str(unit.get("pdfUrl") or "")199 if plan.startswith("http"):200 # les noms de fichiers Resimo contiennent des espaces201 imgs.insert(0, quote(plan, safe=":/?=&%"))202203 desc = " — ".join(x for x in [204 f"{int(rooms)} chambre(s)"205 if isinstance(rooms, (int, float)) and rooms else "",206 f"{area:.0f} pi²" if area else "",207 proj["name"]] if x)208209 listings.append(Listing(210 source=self.source_id,211 external_id=f"{proj['id']}-{num}",212 url=f"{plans_url}#unite-{num}",213 title=f"{proj['name']} — Unité {num}",214 address=proj["address"],215 sector=proj["sector"],216 city="Gatineau",217 unit_type=unit_type,218 price=None, # showPrice=false : aucun prix publié219 price_label="",220 availability="Disponible",221 area_sqft=area,222 description=desc[:600],223 amenities=list(amenities.get(site, [])),224 images=imgs[: self.max_images + 1],225 ))226 except Exception:227 continue228229 uniq: dict[str, Listing] = {}230 for lst in listings:231 uniq.setdefault(lst.external_id, lst)232 return list(uniq.values())233