SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
13.2 KB · 303 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/appfolio.py : connecteur GÉNÉRIQUE AppFolio (appfolio.com)5#   Certains gestionnaires ontariens publient leur parc sur le portail AppFolio :6#   `https://<sous-domaine>.appfolio.com/listings` — HTML rendu serveur (cartes7#   `.js-listing-item`), page unique « Showing all available listings », aucun8#   rendu JS ni pagination nécessaires. Plutôt que N modules copiés-collés, ce9#   module lit le registre data/appfolio_clients.json ({id, name, subdomain…},10#   validés en live le 2026-08-27) et GÉNÈRE une sous-classe de BaseConnector11#   par client (source_id = "apf_<id>"), déposée dans les globals du module12#   pour le registre auto-découvrant (connectors/__init__.py). Même gabarit13#   que liftsystem.py / buildium.py.14#   NB recherche 2026-08-27 : AppFolio est RARE en Ontario (longue traîne ON15#   massivement sur Buildium) — un seul client validé (Fuse, Barrie) après16#   balayage Serper + scan des ~240 gestionnaires cartographiés ; le connecteur17#   est prêt pour les suivants. Les portails servent tout le Canada/US et les18#   adresses sont en texte libre : la province est déduite du CODE POSTAL19#   canadien (première lettre K/L/M/N/P = ON, G/H/J = QC), tout le reste est20#   ignoré. Filtre résidentiel longue durée (exclusion parking, entreposage,21#   commercial, court terme). Galerie photo de la fiche via le cache BD22#   self.detail() (images.cdn.appfolio.com/<client>/images/<uuid>/large.jpg).23#24#   (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les25#   classes générées sont `disabled` et donc exclues du registre / de la prod.26# -----------------------------------------------------------------------------27from __future__ import annotations2829import hashlib30import json31import os32import re33from pathlib import Path34from urllib.parse import urljoin3536from bs4 import BeautifulSoup3738from ..schema import Listing, normalize_unit_type, parse_price39from .base import BaseConnector4041# Rent-Ka: connectors always active.42_ONTARIO = True  # Rent-Ka: always on (ROC scope)4344REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \45    "appfolio_clients.json"4647# Code postal canadien dans l'adresse libre — la 1re lettre (district de tri)48# donne la province sans ambiguïté : K/L/M/N/P = Ontario, G/H/J = Québec.49# Les adresses US (« Chicago, IL 60649 ») ne matchent pas -> ignorées.50_POSTAL_RE = re.compile(r"\b([A-Za-z])(\d[A-Za-z])\s?(\d[A-Za-z]\d)\b")51# Québec (G/H/J) EXCLUDED (returns None) — Rent-Ka covers Canada outside52# Québec. X = NT or NU: NT kept as default (X0A/X0B/X0C = Nunavut).53_POSTAL_PROVINCE = {54    **{c: "ON" for c in "KLMNP"},55    "A": "NL", "B": "NS", "C": "PE", "E": "NB", "R": "MB",56    "S": "SK", "T": "AB", "V": "BC", "X": "NT", "Y": "YT",57}5859# Résidentiel longue durée seulement : stationnements, entreposage, commerces60# et court terme sont hors sujet pour Rent-Ka (logements au mois).61_NON_RESIDENTIAL_RE = re.compile(62    r"\b(parking|stationnement|storage|entreposage|locker|garage only|"63    r"commercial|office space|retail|warehouse)\b", re.I)64_SHORT_TERM_RE = re.compile(65    r"\b(short[\s-]?term|nightly|per night|par nuit|vacation rental|"66    r"weekly rate|airbnb|court terme|campsites?|campground|nature camp)\b",67    re.I)6869# « 3 bd / 1.5 ba » ou « Studio / 1 ba » des cartes AppFolio70_BED_BATH_RE = re.compile(71    r"(studio|\d+(?:\.\d+)?)\s*bd?\s*/\s*(\d+(?:\.\d+)?)\s*ba", re.I)7273# galerie de la fiche détail (préférer les variantes large.jpg)74_GALLERY_IMG_RE = re.compile(75    r"https://images\.cdn\.appfolio\.com/[^\"'\s\\)]+/large\.[a-z]{3,4}", re.I)7677# mots des segments d'adresse qui ne sont PAS une ville (voir _parse_address)78_NOT_CITY = {"", "on", "ontario", "ca", "canada", "qc", "quebec", "québec"}798081def _load_registry() -> list[dict]:82    """Entrées validées du registre (subdomain présent, status == valide)."""83    try:84        data = json.loads(REGISTRY_PATH.read_text("utf-8"))85    except (OSError, ValueError):86        return []87    return [c for c in data.get("clients") or []88            if c.get("subdomain") and c.get("status") == "valide"]899091def _parse_address(raw: str) -> tuple[str, str, str] | None:92    """(ville, province, code postal) depuis l'adresse libre AppFolio, ou None93    si hors ON/QC. Formats vus en live : « 241 Nottawasaga Street - 2 (B),94    Orillia L3V 3K2 », « 300 Cundles St West - Upper, Barrie, ON, CA L4N 7C9 »,95    « 37 Peel St - Apt - 808, Barrie, CA L4M 3L1 »."""96    m = _POSTAL_RE.search(raw)97    if not m:98        return None                       # pas de code postal canadien -> US99    prov = _POSTAL_PROVINCE.get(m.group(1).upper())100    if not prov:101        return None                       # autre province canadienne102    postal = f"{m.group(1)}{m.group(2)} {m.group(3)}".upper()103    # ville = dernier segment (virgules) qui n'est ni ON/CA/Canada ni le postal104    before = raw[: m.start()].strip()105    for seg in reversed([s.strip() for s in before.split(",")]):106        seg = re.sub(r"\b(ON|QC|CA)\b\.?$", "", seg, flags=re.I).strip()107        if seg.lower() not in _NOT_CITY and not any(ch.isdigit() for ch in seg):108            return seg, prov, postal109    return "", prov, postal110111112class AppfolioConnector(BaseConnector):113    """Base commune des connecteurs AppFolio générés — non enregistrée114    elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de115    registre dans l'attribut de classe `client`."""116117    source_id = ""            # les sous-classes générées le définissent118    client: dict = {}         # entrée du registre (name, subdomain, site…)119    request_delay = 1.0       # politesse — petits gestionnaires, on y va mollo120    max_listings = 300        # garde-fou (client validé le plus gros : 25)121    max_images = 20122123    @property124    def base_url(self) -> str:125        return f"https://{self.client['subdomain']}.appfolio.com"126127    def fetch(self) -> list[Listing]:128        soup = BeautifulSoup(self.get(self.base_url + "/listings").text,129                             "html.parser")130        listings: list[Listing] = []131        for item in soup.select(".js-listing-item"):132            if len(listings) >= self.max_listings:  # garde-fou APRÈS filtres133                break134            lst = self._listing(item)135            if lst is not None:136                listings.append(lst)137        return listings138139    # -- une annonce par carte .js-listing-item --------------------------------140    def _listing(self, item) -> Listing | None:141        try:142            link = item.select_one("a.js-link-to-detail") or \143                item.select_one('a[href*="/listings/detail/"]')144            if link is None:145                return None146            href = link.get("href") or ""147            m = re.search(r"/listings/detail/([0-9a-f-]{16,})", href)148            if not m:149                return None150            uid = m.group(1)151            url = urljoin(self.base_url, href)152153            def txt(sel: str) -> str:154                el = item.select_one(sel)155                return el.get_text(" ", strip=True) if el else ""156157            # adresse libre -> ville/province/postal via code postal canadien158            raw_addr = txt(".js-listing-address")159            parsed = _parse_address(raw_addr)160            if parsed is None:161                return None               # US ou autre province : hors sujet162            city, prov, postal = parsed163164            title = txt(".js-listing-title")165            description = txt(".js-listing-description")166            if _NON_RESIDENTIAL_RE.search(f"{title} {description[:200]}") or \167                    _SHORT_TERM_RE.search(f"{title} {description[:200]}"):168                return None169170            # loyer : bandeau mobile, sinon <dd> sous l'étiquette RENT171            price = parse_price(txt(".js-listing-blurb-rent"))172            area = None173            for box in item.select(".detail-box__item"):174                label = (box.select_one("dt") or box).get_text(" ", strip=True)175                value = box.select_one("dd")176                value = value.get_text(" ", strip=True) if value else ""177                low = label.lower()178                if "rent" in low and price is None:179                    price = parse_price(value)180                elif "square" in low:181                    try:182                        v = float(value.replace(",", "").strip() or 0)183                        if 80 <= v <= 20000:184                            area = v185                    except ValueError:186                        pass187188            # « 3 bd / 1.5 ba » / « Studio / 1 ba » — sinon None (inconnu)189            bedrooms = bathrooms = None190            unit_type = ""191            bb = _BED_BATH_RE.search(txt(".js-listing-blurb-bed-bath") or192                                     item.get_text(" ", strip=True))193            if bb:194                if bb.group(1).lower() == "studio":195                    bedrooms, unit_type = 0.0, "Studio"196                else:197                    bedrooms = float(bb.group(1))198                    unit_type = normalize_unit_type(f"{bedrooms:g} chambres")199                bathrooms = float(bb.group(2))200201            availability = txt(".js-listing-available")202203            # paragraphe « Utilities Included: … / Appliances: … » de la carte204            details: dict = {}205            for p in item.select(".listing-item__body p"):206                for b in p.find_all("span", style=re.compile("bold")):207                    key = b.get_text(strip=True).rstrip(":").strip()208                    val = (b.next_sibling or "")209                    val = str(val).strip() if isinstance(val, str) else ""210                    if key and val:211                        details[key] = val212213            # aperçu photo de la carte (lazy-load : data-original)214            preview = ""215            img = item.select_one("img.js-listing-image")216            if img:217                preview = (img.get("data-original") or "").strip()218                if "place_holder" in preview:219                    preview = ""220221            # fiche détail (description complète + galerie large.jpg) via le222            # cache BD : revisitée seulement quand la carte de la liste change223            feed_key = hashlib.sha1("|".join(str(x) for x in (224                price, availability, preview, raw_addr, description[:80],225            )).encode("utf-8")).hexdigest()226            d = self.detail(uid, feed_key, lambda: self._fetch_detail(url))227            full_desc = (d.get("description") or description).strip()228            images = [u for u in [preview] + list(d.get("images") or []) if u]229            images = list(dict.fromkeys(images))    # dédup en gardant l'ordre230231            address = raw_addr232            return Listing(233                source=self.source_id,234                external_id=uid,235                url=url,236                title=title or address,237                address=address,238                city=city,239                province=prov,240                unit_type=unit_type,241                bedrooms=bedrooms,242                bathrooms=bathrooms,243                price=price,244                price_label=f"{price:.0f} $ /mois" if price else "",245                availability=availability,246                area_sqft=area,247                description=full_desc[:1200],248                details=details,249                images=images[: self.max_images],250            )251        except Exception:252            return None253254    def _fetch_detail(self, url: str) -> dict:255        """Description complète + galerie (large.jpg) de la fiche détail."""256        out: dict = {"images": [], "description": ""}257        if not url:258            return out259        try:260            page = self.get(url).text261        except Exception:262            return out263        soup = BeautifulSoup(page, "html.parser")264        desc = soup.select_one(".listing-detail__description")265        if desc:266            out["description"] = desc.get_text("\n", strip=True)267        images: list[str] = []268        for u in _GALLERY_IMG_RE.findall(page):269            if u not in images:270                images.append(u)271        out["images"] = images[: self.max_images]272        return out273274275# -----------------------------------------------------------------------------276# Génération : une sous-classe par client du registre, déposée dans les globals277# du module — connectors/__init__.py (scan de vars(module)) les découvre alors278# comme n'importe quel connecteur écrit à la main.279# -----------------------------------------------------------------------------280def _make_connector(entry: dict) -> type[AppfolioConnector]:281    cls = type(282        f"Apf{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",283        (AppfolioConnector,),284        {285            "source_id": f"apf_{entry['id']}",286            "client": entry,287            # (voir gestion-immobiliere-ontario.md)288            "disabled": False,289            "__doc__": f"Connecteur AppFolio généré — {entry.get('name')} "290                       f"({entry.get('subdomain')}.appfolio.com).",291        },292    )293    return cls294295296def _register_all() -> None:297    for entry in _load_registry():298        cls = _make_connector(entry)299        globals()[cls.__name__] = cls300301302_register_all()303