SPB Git

spb/lou-ka Public

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

HTML 99.7%
22.9 KB · 517 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/edifia.py : connecteur Edifia Groupe Immobilier (edifiaimmobilier.com)5#   Le locatif d'Edifia est réparti sur plusieurs plateformes :6#     - Le Vital (levitalappartements.com, Lévis/Desjardins, 230 unités)7#         -> sélecteur Livya : la page « Plans et prix » pointe vers un module8#            app.livya.com dont le HTML (Next.js/RSC) embarque le JSON complet9#            des unités (prix, superficie, chambres, étage, date de libération)10#     - Capella (capellalevis.com, Lévis, résidence pour aînés, 198 unités)11#         -> sélecteur d'étages « sysc » : JSON `units = [[...]]` dans la page12#     - Le Cardinal Sud (appartementscardinal.com/cardinal-sud/)13#         -> même plateforme « sysc » (unités, dates, plans, inclusions)14#     - Le Cardinal Nord + unités Cardinal Sud absentes du micro-site15#         -> portail BuildingStack edifialocation.com (« Trouvez un logement ») :16#            /Listing/Listings embarque `var units = [...]` (prix, pi², chambres,17#            GPS, contact) et les pages /b/<id> donnent dates et commodités18#   Exclus : HUMĀ phases 1-2 (connecteur dédié huma.py), Le Stellar Chicoutimi19#   (Saguenay, hors région), Solis (condos en vente), Riva Ouest (Lavaltrie,20#   en construction — aucune unité affichée nulle part).21# -----------------------------------------------------------------------------22from __future__ import annotations2324import json25import re2627from bs4 import BeautifulSoup2829from ..schema import Listing, infer_city30from .base import BaseConnector3132EDIFIALOC = "https://edifialocation.com"33VITAL = "https://www.levitalappartements.com"34CAPELLA = "https://capellalevis.com"35CARDINAL = "https://appartementscardinal.com"3637# images à ignorer (logos, icônes, favicons, variantes responsives)38_SKIP_IMG = re.compile(r"logo|favicon|icon|Mapmarker|-\d{2,4}x\d{2,4}\.", re.I)39_IMG_RE = re.compile(40    r'https://[^"\'\s\)]+/(?:app|wp-content)/uploads/[^"\'\s\)]+'41    r'\.(?:jpe?g|webp|png)', re.I)424344def _extract_json(html: str, marker: str):45    """Décode la structure JSON qui suit `marker` dans un script inline."""46    i = html.find(marker)47    if i < 0:48        return None49    try:50        data, _ = json.JSONDecoder().raw_decode(html[i + len(marker):])51    except Exception:52        return None53    return data545556def _typologie(raw) -> str:57    """'4etdemiB' -> '4½', index sysc Capella (0/1/2) -> Studio/3½/4½."""58    if isinstance(raw, int):59        return {0: "Studio", 1: "3½", 2: "4½"}.get(raw, "")60    s = str(raw or "").strip().lower()61    if "studio" in s:62        return "Studio"63    m = re.search(r"(\d)\s*etdemi", s)64    if m:65        return f"{m.group(1)}½"66    m = re.search(r"(\d)\s*(?:½|1/2)", s)67    if m:68        return f"{m.group(1)}½"69    return str(raw or "").strip()707172def _sqft(raw) -> float | None:73    try:74        v = float(str(raw).strip().replace(" ", "").replace(",", "."))75    except (TypeError, ValueError):76        return None77    return v if 80 <= v <= 20000 else None787980class EdifiaConnector(BaseConnector):81    source_id = "edifia"82    request_delay = 0.683    max_images = 128485    # -- helpers ---------------------------------------------------------------86    def _page_images(self, url: str) -> list[str]:87        """Photos (uploads WordPress) d'une page de projet."""88        try:89            html = self.get(url).text90        except Exception:91            return []92        imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))93                if not _SKIP_IMG.search(u)]94        return imgs[: self.max_images]9596    # ===========================================================================97    # 1) Le Vital — sélecteur Livya (module app.livya.com embarqué)98    # ===========================================================================99    def _vital_amenities(self) -> list[str]:100        """Listes « Aires communes » et « Services et commodités » du site."""101        items: list[str] = []102        try:103            html = self.get(f"{VITAL}/espaces-communs-et-services/").text104        except Exception:105            return items106        soup = BeautifulSoup(html, "html.parser")107        # thème Bricks : chaque commodité est un bloc div.icon-box > .brxe-heading108        for el in soup.select(".icon-box .brxe-heading"):109            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))110            if 3 <= len(t) <= 90 and t not in items:111                items.append(t)112        return items[:25]113114    def _vital_units(self) -> list[dict]:115        """JSON des 230 unités : flux RSC (self.__next_f) du module Livya."""116        plans_html = self.get(f"{VITAL}/plans-et-prix/").text117        soup = BeautifulSoup(plans_html, "html.parser")118        cont = soup.select_one(".livya-module-container-plans[data-entity]")119        script = soup.select_one("script[data-client][src*='livya']")120        if cont is None or script is None:121            return []122        client = script.get("data-client")123        project = cont.get("data-project")124        entity = cont.get("data-entity")125        lang = cont.get("data-lang") or "fr"126        livya = self.get(127            f"https://app.livya.com/{lang}/{client}/projects/{project}"128            f"/plans/{entity}", params={"noLayout": "1"}).text129        chunks = re.findall(130            r'self\.__next_f\.push\(\[1,\s*"((?:[^"\\]|\\.)*)"\]\)', livya)131        blob = "".join(json.loads(f'"{c}"') for c in chunks)132        i = blob.find('"units":[')          # le tableau, pas les libellés i18n133        if i < 0:134            return []135        units = _extract_json(blob[i:], '"units":')136        return units if isinstance(units, list) else []137138    def _vital(self) -> list[Listing]:139        amenities = self._vital_amenities()140        images = self._page_images(f"{VITAL}/")141        out: list[Listing] = []142        for u in self._vital_units():143            fut = u.get("futureAvailability") or {}144            now_avail = u.get("availability") == "AVAILABLE"145            future_on = (fut.get("status") == "AVAILABLE"146                         and (fut.get("startsOn") or "")[:10])147            if not now_avail and not future_on:148                continue149            num = str(u.get("number") or "").lstrip("#").strip()150            if not num:151                continue152            if now_avail and not future_on:153                availability = "Disponible — occupation immédiate"154            else:155                availability = f"Libre le {future_on}"156            rooms = u.get("rooms") or 0157            beds = u.get("roomsBed") or 0158            unit_type = ("Studio" if beds == 0 and rooms < 1.5159                         else f"{int(rooms)}½" if float(rooms) % 1 else "")160            price = float(u.get("rentalPrice") or 0) or None161            baths = u.get("roomsBath") or 0162            balcon = u.get("balconySize") or 0163            desc = " — ".join(x for x in [164                f"Type {u['typeName']}" if u.get("typeName") else "",165                str(u.get("floorDisplayName") or ""),166                f"{u.get('unitSize')} pi²" if u.get("unitSize") else "",167                f"balcon {balcon} pi²" if balcon else "",168                f"{beds} chambre(s)" if beds else "",169                f"{baths} salle(s) de bain" if baths else "",170                str(u.get("pricePrecision") or "")] if x)171            addr = ", ".join(x for x in [172                u.get("address"), u.get("city"),173                u.get("postalCode")] if x)174            lat = u.get("latitude")175            lng = u.get("longitude")176            out.append(Listing(177                source=self.source_id,178                external_id=f"vital-{num}",179                url=f"{VITAL}/plans-et-prix/#unite-{num}",180                title=f"Le Vital — Unité {num}",181                address=addr,182                sector="Desjardins",          # Lévis (secteur Desjardins)183                city=infer_city("Desjardins"),184                unit_type=unit_type,185                price=price,186                availability=availability,187                area_sqft=_sqft(u.get("unitSize")),188                description=desc[:600],189                amenities=list(amenities),190                images=list(images),191                lat=float(lat) if lat else None,192                lng=float(lng) if lng else None,193            ))194        return out195196    # ===========================================================================197    # 2) Capella — résidence pour aînés (sélecteur « sysc », prix par unité)198    # ===========================================================================199    def _capella(self) -> list[Listing]:200        html = self.get(f"{CAPELLA}/logements-et-prix/").text201        floors = _extract_json(html, "units = ") or []202        soup = BeautifulSoup(html, "html.parser")203204        # adresse du pied de page (1500, rue Weyman, Lévis)205        address = ""206        addr_el = soup.select_one(".address div")207        if addr_el:208            address = re.sub(r"\s+", " ",209                             addr_el.get_text(", ", strip=True))210211        # inclusions (grille d'icônes sous le titre « Les inclusions »)212        amenities = ["Résidence pour aînés autonomes et semi-autonomes"]213        for h2 in soup.find_all("h2"):214            title = h2.get_text(" ", strip=True).lower()215            if "inclusion" not in title and "options" not in title:216                continue217            section = h2.find_parent("section")218            suffix = " — en option" if "options" in title else ""219            for p in (section or h2.parent).select("p.big-text"):220                t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))221                if 3 <= len(t) <= 90 and t + suffix not in amenities:222                    amenities.append(t + suffix)223        photos = [u for u in dict.fromkeys(_IMG_RE.findall(html))224                  if not _SKIP_IMG.search(u)][: self.max_images]225226        out: list[Listing] = []227        for floor in floors:228            for u in floor:229                if u.get("state") != "Available":230                    continue231                num = str(u.get("number") or "").strip()232                if not num:233                    continue234                dispo = str(u.get("available_date") or "").strip()235                indoor = u.get("indoor_area")236                balcon = u.get("balcony_area")237                desc = " — ".join(x for x in [238                    f"Type {u['type']}" if u.get("type") else "",239                    f"{indoor} pi²" if indoor else "",240                    f"balcon {balcon} pi²" if balcon else ""] if x)241                imgs = list(photos)242                plan = str(u.get("plan_img_src") or "")243                if plan.startswith("http"):244                    imgs.insert(0, plan)245                price = float(u.get("price") or 0) or None246                out.append(Listing(247                    source=self.source_id,248                    external_id=f"capella-{num}",249                    url=f"{CAPELLA}/logements-et-prix/#unite-{num}",250                    title=f"Capella (résidence pour aînés) — Unité {num}",251                    address=address,252                    sector="Desjardins",     # quartier Umano, Lévis253                    city="Lévis",254                    unit_type=_typologie(u.get("typology")),255                    price=price,256                    availability=f"Libre {dispo}" if dispo else "Disponible",257                    area_sqft=_sqft(indoor),258                    description=desc[:600],259                    amenities=list(amenities),260                    images=imgs[: self.max_images + 1],261                ))262        return out263264    # ===========================================================================265    # 3) Cardinal Sud — micro-site (sélecteur « sysc » avec dates et plans)266    # ===========================================================================267    def _cardinal_sud(self, bs_prices: dict) \268            -> tuple[list[Listing], set[str], list[str]]:269        html = self.get(f"{CARDINAL}/cardinal-sud/").text270        floors = _extract_json(html, "units = ") or []271        address, latlng = self._cardinal_map(html, "sud")272        photos = [u for u in dict.fromkeys(_IMG_RE.findall(html))273                  if not _SKIP_IMG.search(u)][: self.max_images]274275        out: list[Listing] = []276        seen: set[str] = set()277        for floor in floors:278            for u in floor:279                if u.get("state") != "Available":280                    continue281                num = str(u.get("number") or "").strip()282                if not num:283                    continue284                seen.add(num)285                dispo = str(u.get("available_date") or "").strip()286                indoor = u.get("indoor_area")287                balcon = u.get("balcony_area")288                incl = re.sub(r"\s+", " ", BeautifulSoup(289                    u.get("description") or "", "html.parser")290                    .get_text(" ", strip=True))291                desc = " — ".join(x for x in [292                    f"Type {u['type']}" if u.get("type") else "",293                    f"{indoor} pi²" if indoor else "",294                    f"balcon {balcon} pi²" if balcon else "",295                    incl] if x)296                imgs = list(photos)297                plan = str(u.get("plan_img_src") or "")298                if plan.startswith("http"):299                    imgs.insert(0, plan)300                # prix : micro-site, sinon portail BuildingStack (même unité)301                price = float(u.get("price") or 0) or None302                if price is None:303                    price = bs_prices.get(("Cardinal sud", num))304                out.append(Listing(305                    source=self.source_id,306                    external_id=f"cardinal-sud-{num}",307                    url=f"{CARDINAL}/cardinal-sud/#unite-{num}",308                    title=f"Le Cardinal Sud — Unité {num}",309                    address=address,310                    sector="Val-Bélair",311                    city="Québec",312                    unit_type=_typologie(u.get("typology")),313                    price=price,314                    availability=f"Libre {dispo}" if dispo else "Disponible",315                    area_sqft=_sqft(indoor),316                    description=desc[:600],317                    amenities=[],            # complétées via BuildingStack318                    images=imgs[: self.max_images + 1],319                    lat=latlng[0] if latlng else None,320                    lng=latlng[1] if latlng else None,321                ))322        return out, seen, photos323324    @staticmethod325    def _cardinal_map(html: str, which: str) -> tuple[str, tuple | None]:326        """Adresse + GPS depuis les marqueurs `var map_points` du micro-site."""327        points = _extract_json(html, "var map_points = ") or []328        for p in points:329            if which.lower() not in str(p.get("nom", "")).lower():330                continue331            addr = re.sub(r"\s+", " ", BeautifulSoup(332                p.get("adresse") or "", "html.parser")333                .get_text(" ", strip=True))334            try:335                lat, lng = [float(x) for x in336                            str(p["latitude_longitude"]).split(",")]337                return addr, (lat, lng)338            except (KeyError, ValueError):339                return addr, None340        return "", None341342    # ===========================================================================343    # 4) Portail BuildingStack (edifialocation.com) — Cardinal Nord + restes344    # ===========================================================================345    def _buildingstack_units(self) -> list[dict]:346        """`var units = [...]` de la page ANNONCES (toutes unités affichées)."""347        html = self.get(f"{EDIFIALOC}/Listing/Listings").text348        return _extract_json(html, "var units = ") or []349350    def _building_page(self, pub_id: str) -> tuple[dict[str, str], list[str]]:351        """Page /b/<id> : date de disponibilité par unité + commodités."""352        dates: dict[str, str] = {}353        amenities: list[str] = []354        try:355            html = self.get(f"{EDIFIALOC}/b/{pub_id}").text356        except Exception:357            return dates, amenities358        soup = BeautifulSoup(html, "html.parser")359        for a in soup.select("ul.wpb-tabs-menu a.apartment-view-potential"):360            spans = [s.get_text(" ", strip=True) for s in a.find_all("span")]361            if len(spans) >= 6 and spans[0]:362                dates[spans[0]] = spans[5]        # Unité -> Disponible363        for li in soup.select(".facilities ul li label"):364            t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))365            if 3 <= len(t) <= 90 and t not in amenities:366                amenities.append(t)367        return dates, amenities368369    def _mk_bs(self, u: dict, num: str, project: str, slug: str, url: str,370               sector: str, availability: str, amenities: list[str],371               images: list[str]) -> Listing:372        apt = u.get("Apartment") or {}373        addr = u.get("Address") or {}374        beds = apt.get("NumberOfBedrooms") or 0375        baths = apt.get("NumberOfBathrooms") or 0376        unit_type = "Studio" if beds == 0 else f"{beds + 2}½"377        desc = " — ".join(x for x in [378            f"{apt.get('Area')} pi²" if apt.get("Area") else "",379            f"{beds} chambre(s)" if beds else "",380            f"{baths} salle(s) de bain" if baths else ""] if x)381        details: dict = {}382        contacts = (u.get("Building") or {}).get("ListingEmployeesContacts") or []383        if contacts:384            c = contacts[0]385            details["contact"] = {386                k: v for k, v in [("name", c.get("FullName")),387                                  ("phone", c.get("FormattedPhoneNumber")),388                                  ("email", c.get("Email"))] if v}389        imgs = list(images)390        prev = u.get("PreviewUrl") or ""391        if prev.startswith("http"):392            imgs.insert(0, prev)393        try:394            lat, lng = float(addr.get("Latitude")), float(addr.get("Longitude"))395        except (TypeError, ValueError):396            lat = lng = None397        # prix présent dans le JSON du portail ; le site affiche398        # « Contactez-nous » (PriceFormatted) — les deux sont conservés399        price = float(apt.get("Price") or 0) or None400        return Listing(401            source=self.source_id,402            external_id=f"{slug}-{num}",403            url=url,404            title=f"{project} — Unité {num}",405            address=addr.get("Full") or "",406            sector=sector,407            city=addr.get("City") or "",408            unit_type=unit_type,409            price=price,410            price_label=str(apt.get("PriceFormatted") or ""),411            availability=availability,412            area_sqft=_sqft(apt.get("Area")),413            description=desc[:600],414            amenities=list(amenities),415            details=details,416            images=imgs[: self.max_images + 1],417            lat=lat,418            lng=lng,419        )420421    # -- fetch -----------------------------------------------------------------422    def fetch(self) -> list[Listing]:423        listings: list[Listing] = []424425        # portail BuildingStack : prix par (immeuble, unité) pour enrichir426        try:427            bs_units = self._buildingstack_units()428        except Exception:429            bs_units = []430        bs_prices: dict[tuple[str, str], float] = {}431        for u in bs_units:432            apt = u.get("Apartment") or {}433            price = float(apt.get("Price") or 0) or None434            if price:435                bs_prices[(str(u.get("BuildingName")),436                           str(apt.get("UnitName")))] = price437438        # 1) Le Vital (Livya)439        try:440            listings.extend(self._vital())441        except Exception:442            pass443444        # 2) Capella (sysc)445        try:446            listings.extend(self._capella())447        except Exception:448            pass449450        # 3) Cardinal Sud (micro-site sysc, prix complétés via le portail)451        cs_seen: set[str] = set()452        cs_photos: list[str] = []453        try:454            cs_listings, cs_seen, cs_photos = self._cardinal_sud(bs_prices)455            listings.extend(cs_listings)456        except Exception:457            cs_listings = []458459        # 4) Portail BuildingStack : Cardinal Nord (aucune unité sur le460        #    micro-site) + unités Cardinal Sud absentes du micro-site.461        #    HUMĀ (connecteur huma.py) et les immeubles hors Québec/Lévis462        #    (Le Stellar à Saguenay, etc.) sont exclus.463        bs_projects = {464            "Cardinal Nord": ("cardinal-nord", "Le Cardinal Nord",465                              f"{CARDINAL}/cardinal-nord/", "Val-Bélair"),466            "Cardinal sud": ("cardinal-sud", "Le Cardinal Sud",467                             f"{CARDINAL}/cardinal-sud/", "Val-Bélair"),468        }469        cardinal_amen: dict[str, list[str]] = {}470        by_building: dict[str, list[dict]] = {}471        for u in bs_units:472            name = str(u.get("BuildingName") or "")473            city = str((u.get("Address") or {}).get("City") or "")474            if "huma" in name.lower() or city not in ("Québec", "Lévis"):475                continue476            if name not in bs_projects:477                continue                      # immeuble inconnu : prudence478            by_building.setdefault(name, []).append(u)479480        cn_images = self._page_images(f"{CARDINAL}/cardinal-nord/") \481            if "Cardinal Nord" in by_building else []482        for name, units in by_building.items():483            slug, project, url, sector = bs_projects[name]484            pub = str((units[0].get("Building") or {})485                      .get("PublicListBuildingName") or "")486            dates, amenities = (self._building_page(pub) if pub487                                else ({}, []))488            cardinal_amen[slug] = amenities489            for u in units:490                num = str((u.get("Apartment") or {}).get("UnitName") or "")491                if not num:492                    continue493                if slug == "cardinal-sud" and num in cs_seen:494                    continue                  # déjà couvert par le micro-site495                dispo = dates.get(num, "")496                if not dispo:497                    availability = "Disponible"498                elif re.search(r"\d", dispo):     # « sept. 01, 2026 »499                    availability = f"Libre {dispo}"500                else:                             # « Disponible dès maintenant! »501                    availability = dispo502                images = cn_images if slug == "cardinal-nord" else cs_photos503                listings.append(self._mk_bs(504                    u, num, project, slug, f"{EDIFIALOC}/b/{pub}",505                    sector, availability, amenities, images))506507        # commodités du portail répercutées sur les unités micro-site Cardinal508        for lst in cs_listings:509            if not lst.amenities and cardinal_amen.get("cardinal-sud"):510                lst.amenities = list(cardinal_amen["cardinal-sud"])511512        # dédup par external_id (sécurité)513        uniq: dict[str, Listing] = {}514        for lst in listings:515            uniq.setdefault(lst.external_id, lst)516        return list(uniq.values())517