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%
13.5 KB · 304 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/apartments_com.py : Apartments.com (CoStar) — réseau US avec un5#   vrai parc québécois (~500 propriétés / 8 400 unités via /qc/, surtout de6#   grands gestionnaires). Le site est verrouillé par Akamai (403 en direct,7#   via Scrapfly ET via Oxylabs — sondé 2026-08-18/22) : le scraping est8#   délégué à l'ACTEUR APIFY MAISON gorgeous_thistle/ka-apartments-com9#   (source : actors/ka-apartments-com de ce repo) qui passe par le Web10#   Unlocker Bright Data — HTML server-rendered, pas de rendu JS.11#   UNE annonce = UNE PROPRIÉTÉ (immeuble) : prix « à partir de » (minimum12#   des plans d'unités), tous les plans dans details["plans"]. Les fiches13#   détail sont lues en FRANÇAIS (/fr/) : description, GPS, commodités,14#   galerie. Cache détail 30 j + CADENCE : l'inventaire CoStar bouge15#   lentement, l'acteur ne tourne qu'aux LOUKA_APTS_INTERVAL_H heures ;16#   entre deux runs on ré-émet les annonces actives depuis la BD (coût 0).17# -----------------------------------------------------------------------------18from __future__ import annotations1920import json21import os22import re23import time2425import requests as _requests2627from ..schema import Listing28from .base import BaseConnector29from . import _detailutil as du3031APIFY_API = "https://api.apify.com/v2"32ACTOR = os.environ.get("LOUKA_APTS_ACTOR", "gorgeous_thistle~ka-apartments-com")3334SEARCH_URLS = ["https://www.apartments.com/qc/"]     # toute la province35MAX_PAGES = int(os.environ.get("LOUKA_APTS_MAX_PAGES", "25"))36DETAIL_LIMIT = int(os.environ.get("LOUKA_APTS_DETAIL_LIMIT", "250"))37TTL_DAYS = float(os.environ.get("LOUKA_APTS_TTL_DAYS", "30"))38INTERVAL_H = float(os.environ.get("LOUKA_APTS_INTERVAL_H", "12"))39CONCURRENCY = int(os.environ.get("LOUKA_APTS_CONCURRENCY", "4"))40RUN_TIMEOUT = int(os.environ.get("LOUKA_APTS_RUN_TIMEOUT", "3600"))  # s4142DETAIL_KEY = "v1"43PRICE_MIN, PRICE_MAX = 300, 12000     # garde-fous de loyer mensuel4445_MONEY_RX = re.compile(r"\$\s*([\d\s,  ]+)")46_BEDS_LABEL_RX = re.compile(r"(\d+)\s*(?:bed|lit|c\.?\s*à\.?\s*c)", re.I)47_SQFT_RX = re.compile(r"([\d\s,  ]+)\s*(?:pi²|sq\s*ft)", re.I)484950def _money(text: str | None) -> float | None:51    m = _MONEY_RX.search(text or "")52    if not m:53        return None54    try:55        return float(re.sub(r"[^\d]", "", m.group(1)))56    except ValueError:57        return None585960def _beds_count(label: str) -> float | None:61    low = (label or "").casefold()62    if "studio" in low or "bachelor" in low:63        return 0.064    m = _BEDS_LABEL_RX.search(low)65    return float(m.group(1)) if m else None666768def _unit_type(beds: float | None) -> str:69    if beds is None:70        return ""71    if beds <= 0:72        return "Studio"73    return "6½+" if beds >= 4 else f"{int(beds) + 2}½"747576def _fmt_price(price: float) -> str:77    return f"{price:,.0f} $/mois".replace(",", " ")787980def _split_address(addr: str) -> tuple[str, str]:81    """« 1411 St Du Fort, Montréal, QC H3H 2N7 » -> (rue, ville)."""82    parts = [p.strip() for p in (addr or "").split(",") if p.strip()]83    if len(parts) >= 3:84        return ", ".join(parts[:-2]), parts[-2]85    return addr or "", ""868788class ApartmentsComConnector(BaseConnector):89    source_id = "apartments_com"90    request_delay = 1.09192    # -- orchestration de l'acteur Apify --------------------------------------93    def _run_actor(self, payload: dict, token: str) -> list[dict]:94        """Lance l'acteur, attend la fin, retourne les items du dataset."""95        r = _requests.post(96            f"{APIFY_API}/acts/{ACTOR}/runs?waitForFinish=120",97            json=payload, timeout=180,98            headers={"Authorization": f"Bearer {token}"})99        r.raise_for_status()100        run = r.json()["data"]101        deadline = time.time() + RUN_TIMEOUT102        while run["status"] in ("READY", "RUNNING") and time.time() < deadline:103            time.sleep(10)104            run = _requests.get(105                f"{APIFY_API}/actor-runs/{run['id']}", timeout=60,106                headers={"Authorization": f"Bearer {token}"}).json()["data"]107        if run["status"] != "SUCCEEDED":108            raise RuntimeError(f"acteur {ACTOR} : run {run['id']} "109                               f"terminé en {run['status']}")110        items: list[dict] = []111        offset = 0112        while True:113            batch = _requests.get(114                f"{APIFY_API}/datasets/{run['defaultDatasetId']}/items"115                f"?limit=1000&offset={offset}", timeout=120,116                headers={"Authorization": f"Bearer {token}"}).json()117            items.extend(batch)118            if len(batch) < 1000:119                return items120            offset += 1000121122    # -- cadence / ré-émission -------------------------------------------------123    def _last_ok_sync(self, cache: du.TtlDetailCache) -> float:124        row = cache.con.execute(125            "SELECT MAX(ts) AS t FROM sync_log WHERE source=? AND ok=1"126            " AND found > 0", (self.source_id,)).fetchone()127        return row["t"] or 0.0128129    def _active_rows(self, cache: du.TtlDetailCache) -> list:130        return cache.con.execute(131            "SELECT external_id, url, title, address, sector, city, unit_type,"132            " bedrooms, bathrooms, price, price_label, availability,"133            " availability_date, area_sqft, pets, furnished, description,"134            " amenities, details, images, lat, lng"135            " FROM listings WHERE source=? AND active=1", (self.source_id,)136        ).fetchall()137138    def _listing_from_row(self, r) -> Listing:139        def js(s, default):140            try:141                return json.loads(s) if s else default142            except ValueError:143                return default144        return Listing(145            source=self.source_id, external_id=r["external_id"],146            url=r["url"], title=r["title"] or "", address=r["address"] or "",147            sector=r["sector"] or "", city=r["city"] or "",148            unit_type=r["unit_type"] or "", bedrooms=r["bedrooms"],149            bathrooms=r["bathrooms"], price=r["price"],150            price_label=r["price_label"] or "",151            availability=r["availability"] or "",152            availability_date=r["availability_date"],153            area_sqft=r["area_sqft"], pets=r["pets"],154            furnished=(None if r["furnished"] is None else bool(r["furnished"])),155            description=r["description"] or "",156            amenities=js(r["amenities"], []), details=js(r["details"], {}),157            images=js(r["images"], []), lat=r["lat"], lng=r["lng"],158        )159160    # -- payload détail -> champs Listing ---------------------------------------161    @staticmethod162    def _std_detail(d: dict) -> dict:163        std = {k: d[k] for k in ("description", "images", "lat", "lng",164                                 "amenities") if d.get(k)}165        if d.get("street"):166            std["address"] = d["street"]167        if d.get("city"):168            std["city"] = d["city"]169        plans, seen = [], set()170        for pl in d.get("plans") or []:      # grille dupliquée desktop/mobile171            sig = (pl.get("key"), pl.get("name"), pl.get("rent"))172            if sig not in seen:173                seen.add(sig)174                plans.append(pl)175        rents = [p for p in (_money(pl.get("rent")) for pl in plans)176                 if p is not None and PRICE_MIN <= p <= PRICE_MAX]177        if rents:178            std["price"] = min(rents)179            std["price_label"] = (f"à partir de {_fmt_price(min(rents))}"180                                  if len(plans) > 1 else _fmt_price(min(rents)))181        # immeuble multi-plans : minimum, cohérent avec le prix « à partir182        # de » — et ça évite que finalize() devine n'importe quoi dans la183        # description marketing (« appartements de 1 à 4 chambres »)184        beds = {pl.get("beds") for pl in plans if pl.get("beds") is not None}185        baths = {pl.get("baths") for pl in plans if pl.get("baths") is not None}186        if beds:187            std["bedrooms"] = min(beds)188            if len(beds) == 1:189                std["unit_type"] = _unit_type(std["bedrooms"])190        if baths:191            std["bathrooms"] = min(baths)192        if len(plans) == 1:193            m = _SQFT_RX.search(" ".join(plans[0].get("details") or []))194            if m:195                try:196                    std["area_sqft"] = float(re.sub(r"[^\d]", "", m.group(1)))197                except ValueError:198                    pass199        avails = [pl.get("availability") or "" for pl in plans]200        now = next((a for a in avails201                    if "maintenant" in a.casefold() or "now" in a.casefold()),202                   "")203        if now or any(avails):204            std["availability"] = now or next(a for a in avails if a)205        if plans:206            std["details"] = {"plans": [207                {k: pl[k] for k in ("name", "rent", "beds", "baths",208                                    "details", "availability") if k in pl}209                for pl in plans]}210        return std211212    # -- pipeline principal ------------------------------------------------------213    def fetch(self) -> list[Listing]:214        token = os.environ.get("APIFY_TOKEN")215        bd_token = os.environ.get("BRIGHTDATA_API_KEY")216        if not token:217            raise RuntimeError("APIFY_TOKEN manquant (voir .env)")218        if not bd_token:219            raise RuntimeError("BRIGHTDATA_API_KEY manquant (voir .env)")220221        cache = du.TtlDetailCache(self, budget=0, ttl_days=TTL_DAYS,222                                  key=DETAIL_KEY, fetch_html=lambda _u: "")223        try:224            # inventaire lent + acteur payant : entre deux vrais runs on225            # ré-émet les actives telles quelles (aucune requête réseau)226            age_h = (time.time() - self._last_ok_sync(cache)) / 3600227            actives = self._active_rows(cache)228            if actives and age_h < INTERVAL_H:229                return [self._listing_from_row(r) for r in actives]230231            fresh = {r["external_id"] for r in cache.con.execute(232                "SELECT external_id FROM detail_cache"233                " WHERE source=? AND key=? AND fetched_at > ?",234                (self.source_id, DETAIL_KEY,235                 time.time() - TTL_DAYS * 86400)).fetchall()}236            # rattrapage : actives jamais enrichies (sans GPS ou description)237            extra = [f'{r["external_id"]}|{r["url"]}' for r in actives238                     if r["external_id"] not in fresh239                     and (r["lat"] is None or not (r["description"] or "").strip())]240241            items = self._run_actor({242                "searchUrls": SEARCH_URLS,243                "maxPages": MAX_PAGES,244                "getDetails": True,245                "maxDetails": DETAIL_LIMIT,246                "skipDetailIds": sorted(fresh),247                "extraDetailIds": extra[:DETAIL_LIMIT],248                "brightdataToken": bd_token,249                "brightdataZone": os.environ.get("BRIGHTDATA_ZONE",250                                                 "web_unlocker1"),251                "concurrency": CONCURRENCY,252            }, token)253254            for it in items:255                if it.get("kind") == "detail" and it.get("id"):256                    payload = {k: v for k, v in it.items()257                               if k not in ("kind",) and v is not None}258                    cache.put(str(it["id"]), payload)259260            out: dict[str, Listing] = {}261            for it in items:262                if it.get("kind") != "listing":263                    continue264                pid = str(it["id"])265                street, city = _split_address(it.get("address") or "")266                rents = [(r.get("beds") or "", _money(r.get("price")))267                         for r in it.get("rents") or []]268                prices = [p for _b, p in rents269                          if p is not None and PRICE_MIN <= p <= PRICE_MAX]270                from_price = any("+" in (r.get("price") or "")271                                 for r in it.get("rents") or [])272                beds = {b for b in (_beds_count(lbl) for lbl, _p in rents)273                        if b is not None}274                lst = Listing(275                    source=self.source_id,276                    external_id=pid,277                    url=(it.get("url") or "").replace(278                        "www.apartments.com/", "www.apartments.com/fr/", 1),279                    title=(it.get("title") or street)[:200],280                    address=street,281                    city=city,282                    price=min(prices) if prices else None,283                    price_label=((f"à partir de {_fmt_price(min(prices))}"284                                  if from_price or len(prices) > 1285                                  else _fmt_price(min(prices)))286                                 if prices else ""),287                    unit_type=_unit_type(beds.pop() if len(beds) == 1288                                         else None),289                    amenities=it.get("amenities") or [],290                    images=[it["image"]] if it.get("image") else [],291                )292                detail, _f = cache.peek(pid)293                if detail:294                    du.apply_detail(lst, self._std_detail(detail))295                    if detail.get("name"):296                        lst.title = detail["name"][:200]297                if lst.price is None or not (PRICE_MIN <= lst.price298                                             <= PRICE_MAX):299                    continue          # « Call for Rent » : pas affichable300                out[pid] = lst301            return list(out.values())302        finally:303            cache.close()304