SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
10.6 KB · 233 lines python
Raw Blame History
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