SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
12.5 KB · 290 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/qresidential.py : connecteur Q Residential (qresidential.ca)5#   Gestionnaire ontarien (~7 000 unités — Toronto, St. Catharines, Hamilton,6#   Oshawa/GTA, Brampton, Barrie). « Corporate site » Yardi RentCafe derrière7#   un Cloudflare STRICT (curl direct = 403) : tout passe par Scrapfly ASP8#   (self.get_scrapfly, asp=true, SANS render_js — le contenu est rendu9#   serveur, vérifié sur la recherche et les fiches).10#   Contrairement à killam.py (même famille RentCafe), il n'y a PAS de blob11#   #available_prop : la page /searchlisting.aspx rend 25 cartes propriétés12#   côté serveur (nom, adresse, ville, province, vignette, URL de fiche).13#   La fiche propriété fournit un JSON-LD ApartmentComplex (description,14#   adresse postale, GPS, commodités, téléphone) et un tableau « Floor Plans »15#   (tbody.floorplan-details : nom, Bed/Bath, pi², loyer — souvent « Call for16#   pricing » —, disponibilité). Une annonce PAR PLAN D'ÉTAGE quand au moins17#   un plan affiche un prix ; sinon repli « une annonce par propriété » sans18#   prix (rien d'inventé). Fiches visitées via le cache BD self.detail(),19#   clé datée du jour : au plus UNE visite Scrapfly par propriété par jour,20#   peu importe le nombre de synchronisations.21#22#   Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le23#   connecteur est `disabled` et exclu du registre (zéro impact prod QC).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import datetime as _dt28import json29import os30import re3132from bs4 import BeautifulSoup3334from ..schema import Listing, normalize_unit_type, parse_price35from .base import BaseConnector3637BASE = "https://www.qresidential.ca"38SEARCH_PAGE = f"{BASE}/searchlisting.aspx"3940# Gate expansion Ontario : le connecteur reste hors registre tant que la41# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.42_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4344# cellules du tableau plans d'étage : « Bed/Bath Studio / 1 », « 2 / 1.5 »45_BEDBATH_RE = re.compile(46    r"Bed/Bath\s+(Studio|\d+)\s*/\s*([\d.]+)", re.I)47_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)48# identifiant stable du plan d'étage (carrousel photo du plan)49_FPID_RE = re.compile(r"fp-myCarousel(\d+)")505152class QResidentialConnector(BaseConnector):53    source_id = "qresidential"54    request_delay = 1.5       # Cloudflare strict : Scrapfly ASP, rester poli55    disabled = not _ONTARIO   # gate expansion Ontario (LOUKA_ONTARIO=1)56    max_properties = 35       # garde-fou (25 propriétés aujourd'hui)57    max_images = 155859    def fetch(self) -> list[Listing]:60        page = self.get_scrapfly(SEARCH_PAGE, asp=True, render_js=False)61        if not page:62            raise RuntimeError("Q Residential : Scrapfly ASP n'a pas "63                               "retourné la page de recherche")64        soup = BeautifulSoup(page, "html.parser")6566        cards = soup.select("div.searchResult")67        listings: list[Listing] = []68        count = 069        seen: set[str] = set()70        for card in cards:71            try:72                a = card.select_one("a.propertyUrl")73                if a is None:74                    continue75                url = (a.get("href") or "").strip()76                if not url or url in seen:77                    continue78                seen.add(url)79                # résidentiel longue durée seulement (structure « Apartment »)80                st = card.select_one(".structure-type")81                if st and st.get_text(" ", strip=True) and \82                        "apartment" not in st.get_text(" ", strip=True).lower():83                    continue84                state = card.select_one(".propertyState")85                if state and state.get_text(strip=True).upper() != "ON":86                    continue87                if count >= self.max_properties:88                    break89                count += 190                listings.extend(self._property_listings(card, url))91            except Exception:92                continue93        return listings9495    # -- annonces d'une propriété (une par plan d'étage tarifé, repli) ----------96    def _property_listings(self, card, url: str) -> list[Listing]:97        # identifiant : slug de la fiche (« queenston-manor »)98        slug = url.rstrip("/").split("/")[-2] if url.endswith("default.aspx") \99            else re.sub(r"[^a-z0-9-]", "", url.rstrip("/").split("/")[-1])100        a = card.select_one("a.propertyUrl")101        name = a.get_text(" ", strip=True)102        city = (card.select_one(".propertyCity") or a) \103            .get_text(" ", strip=True) if card.select_one(".propertyCity") \104            else ""105        # adresse complète de la carte (« 382 Queenston Street St. Catharines106        # ON L2P 3V5 ») — on la reconstruit avec des virgules107        street = ""108        spans = card.select(".propertyAddress")109        if spans:110            street = spans[-1].get_text(" ", strip=True)111        postal = ""112        m = re.search(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d",113                      card.get_text(" ", strip=True))114        if m:115            postal = m.group(0)116        address = ", ".join(x for x in (street, city) if x)117        if address:118            address += f", ON {postal}".rstrip()119120        thumb = ""121        img = card.select_one("img.propertyThumb")122        if img is not None:123            thumb = (img.get("src") or "").strip()124125        # fiche propriété via le cache BD — clé datée : au plus une visite126        # Scrapfly par propriété par jour (les prix « Call for pricing »127        # changent rarement, et jamais entre deux syncs du même jour)128        feed_key = f"{_dt.date.today().isoformat()}|{name}|{thumb}"129        d = self.detail(slug, feed_key, lambda: self._fetch_detail(url))130131        desc = d.get("description") or ""132        amenities = d.get("amenities") or []133        images = [u for u in ([thumb] + (d.get("images") or [])) if u]134        lat, lng = d.get("lat"), d.get("lng")135        details: dict = {}136        if d.get("phone"):137            details["contact"] = {"phone": d["phone"]}138139        common = dict(140            source=self.source_id, url=url, address=address, city=city,141            province="ON", description=desc, amenities=amenities[:25],142            images=images[: self.max_images], lat=lat, lng=lng,143        )144145        # une annonce par plan d'étage TARIFÉ (prix affiché) ; les plans146        # « Call for pricing » ne deviennent pas des annonces individuelles147        out: list[Listing] = []148        priced = [fp for fp in d.get("floorplans") or []149                  if fp.get("price") is not None]150        for fp in priced:151            beds = fp.get("beds")152            price = fp["price"]153            out.append(Listing(154                external_id=f"{slug}-{fp['fpid']}",155                title=f"{name} — {fp['name']}" if fp.get("name") else name,156                unit_type=("Studio" if beds == 0 else normalize_unit_type(157                    f"{int(beds)} chambres") if beds is not None else ""),158                bedrooms=beds,159                bathrooms=fp.get("baths"),160                price=price,161                price_label=f"À partir de {price:.0f} $ /mois",162                availability=fp.get("avail") or "",163                area_sqft=fp.get("sqft"),164                details=dict(details),165                **common,166            ))167        if out:168            return out169170        # repli : une annonce par propriété — gamme de types connue seulement171        # via les plans d'étage non tarifés ; aucun prix inventé172        fps = d.get("floorplans") or []173        beds_set = {fp.get("beds") for fp in fps if fp.get("beds") is not None}174        unit_type = ""175        if len(beds_set) == 1:176            b = beds_set.pop()177            unit_type = "Studio" if b == 0 else normalize_unit_type(178                f"{int(b)} chambres")179        return [Listing(180            external_id=slug,181            title=name,182            unit_type=unit_type,183            availability="Sur demande (contacter la gestion)" if fps else "",184            details=details,185            **common,186        )]187188    # -- fiche propriété : JSON-LD + tableau plans d'étage ----------------------189    def _fetch_detail(self, url: str) -> dict:190        out: dict = {"description": "", "amenities": [], "images": [],191                     "floorplans": [], "lat": None, "lng": None, "phone": ""}192        page = self.get_scrapfly(url, asp=True, render_js=False)193        if not page:194            return out195        soup = BeautifulSoup(page, "html.parser")196197        # JSON-LD ApartmentComplex : description, GPS, commodités, téléphone198        for tag in soup.find_all("script", type="application/ld+json"):199            try:200                ld = json.loads(tag.string or "")201            except (TypeError, ValueError):202                continue203            if not isinstance(ld, dict) or \204                    ld.get("@type") != "ApartmentComplex":205                continue206            out["description"] = BeautifulSoup(207                ld.get("description") or "", "html.parser"208            ).get_text(" ", strip=True)[:600]209            geo = ld.get("geo") or {}210            try:211                out["lat"] = float(geo.get("latitude"))212                out["lng"] = float(geo.get("longitude"))213            except (TypeError, ValueError):214                pass215            out["phone"] = ((ld.get("address") or {})216                            .get("telephone") or "").strip()217            ams = []218            for af in ld.get("amenityFeature") or []:219                t = (af.get("name") or "").strip() if isinstance(af, dict) \220                    else ""221                if t and t not in ams:222                    ams.append(t)223            out["amenities"] = ams[:25]224            break225226        # visuels de la fiche (bannière dmslivecafe) — HORS plans d'étage/logos227        images: list[str] = []228        for img in soup.find_all("img"):229            src = (img.get("src") or "").strip()230            if "cdngeneralcf.rentcafe.com/dmslivecafe" not in src:231                continue232            if re.search(r"logo|icon|floor\s?plan|FloorPlan", src, re.I):233                continue234            if img.find_parent(class_=re.compile("floorplan")):235                continue236            if src not in images:237                images.append(src)238        out["images"] = images[: self.max_images]239240        # tableau plans d'étage : une ligne par variation (nom, Bed/Bath,241        # pi² éventuel, loyer, disponibilité)242        seen: set[str] = set()243        for tr in soup.select("tbody.floorplan-details tr"):244            txt = tr.get_text(" ", strip=True)245            if "Bed/Bath" not in txt:246                continue247            mid = _FPID_RE.search(str(tr))248            name_td = tr.find(string=re.compile(r"Floor Plan\s"))249            name = ""250            if name_td:251                name = re.sub(r"^Floor Plan\s+", "",252                              str(name_td).strip()).strip()253            fpid = mid.group(1) if mid else \254                re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")255            if not fpid or fpid in seen:256                continue257            seen.add(fpid)258            mbb = _BEDBATH_RE.search(txt)259            beds = baths = None260            if mbb:261                beds = 0.0 if mbb.group(1).lower() == "studio" \262                    else float(mbb.group(1))263                try:264                    baths = float(mbb.group(2))265                except ValueError:266                    baths = None267            msq = _SQFT_RE.search(txt)268            sqft = float(msq.group(1).replace(",", "")) if msq else None269            # loyer : montant affiché seulement (« Call for pricing » -> None)270            price = None271            mrent = re.search(r"Rent\s+([^D]*?)(?:Deposit|$)", txt)272            if mrent:273                price = parse_price(mrent.group(1))274            avail = ""275            mav = re.search(r"Deposit\s*(.*?)(?:Read More|$)", txt)276            if mav:277                avail = mav.group(1).strip()278                if avail.lower() in ("contact us", ""):279                    avail = ""280            out["floorplans"].append({281                "fpid": fpid,282                "name": name,283                "beds": beds,284                "baths": baths,285                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,286                "price": price,287                "avail": avail,288            })289        return out290