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.1 KB · 299 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/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#   Expansion Ontario — activer via LOUKA_ONTARIO=125#   (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les26#   classes générées sont `disabled` et donc exclues du registre / de la prod.27# -----------------------------------------------------------------------------28from __future__ import annotations2930import hashlib31import json32import os33import re34from pathlib import Path35from urllib.parse import urljoin3637from bs4 import BeautifulSoup3839from ..schema import Listing, normalize_unit_type, parse_price40from .base import BaseConnector4142# Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=143_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4445REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \46    "appfolio_clients.json"4748# Code postal canadien dans l'adresse libre — la 1re lettre (district de tri)49# donne la province sans ambiguïté : K/L/M/N/P = Ontario, G/H/J = Québec.50# Les adresses US (« Chicago, IL 60649 ») ne matchent pas -> ignorées.51_POSTAL_RE = re.compile(r"\b([A-Za-z])(\d[A-Za-z])\s?(\d[A-Za-z]\d)\b")52_POSTAL_PROVINCE = {**{c: "ON" for c in "KLMNP"}, **{c: "QC" for c in "GHJ"}}5354# Résidentiel longue durée seulement : stationnements, entreposage, commerces55# et court terme sont hors sujet pour Lou-Ka (logements au mois).56_NON_RESIDENTIAL_RE = re.compile(57    r"\b(parking|stationnement|storage|entreposage|locker|garage only|"58    r"commercial|office space|retail|warehouse)\b", re.I)59_SHORT_TERM_RE = re.compile(60    r"\b(short[\s-]?term|nightly|per night|par nuit|vacation rental|"61    r"weekly rate|airbnb|court terme|campsites?|campground|nature camp)\b",62    re.I)6364# « 3 bd / 1.5 ba » ou « Studio / 1 ba » des cartes AppFolio65_BED_BATH_RE = re.compile(66    r"(studio|\d+(?:\.\d+)?)\s*bd?\s*/\s*(\d+(?:\.\d+)?)\s*ba", re.I)6768# galerie de la fiche détail (préférer les variantes large.jpg)69_GALLERY_IMG_RE = re.compile(70    r"https://images\.cdn\.appfolio\.com/[^\"'\s\\)]+/large\.[a-z]{3,4}", re.I)7172# mots des segments d'adresse qui ne sont PAS une ville (voir _parse_address)73_NOT_CITY = {"", "on", "ontario", "ca", "canada", "qc", "quebec", "québec"}747576def _load_registry() -> list[dict]:77    """Entrées validées du registre (subdomain présent, status == valide)."""78    try:79        data = json.loads(REGISTRY_PATH.read_text("utf-8"))80    except (OSError, ValueError):81        return []82    return [c for c in data.get("clients") or []83            if c.get("subdomain") and c.get("status") == "valide"]848586def _parse_address(raw: str) -> tuple[str, str, str] | None:87    """(ville, province, code postal) depuis l'adresse libre AppFolio, ou None88    si hors ON/QC. Formats vus en live : « 241 Nottawasaga Street - 2 (B),89    Orillia L3V 3K2 », « 300 Cundles St West - Upper, Barrie, ON, CA L4N 7C9 »,90    « 37 Peel St - Apt - 808, Barrie, CA L4M 3L1 »."""91    m = _POSTAL_RE.search(raw)92    if not m:93        return None                       # pas de code postal canadien -> US94    prov = _POSTAL_PROVINCE.get(m.group(1).upper())95    if not prov:96        return None                       # autre province canadienne97    postal = f"{m.group(1)}{m.group(2)} {m.group(3)}".upper()98    # ville = dernier segment (virgules) qui n'est ni ON/CA/Canada ni le postal99    before = raw[: m.start()].strip()100    for seg in reversed([s.strip() for s in before.split(",")]):101        seg = re.sub(r"\b(ON|QC|CA)\b\.?$", "", seg, flags=re.I).strip()102        if seg.lower() not in _NOT_CITY and not any(ch.isdigit() for ch in seg):103            return seg, prov, postal104    return "", prov, postal105106107class AppfolioConnector(BaseConnector):108    """Base commune des connecteurs AppFolio générés — non enregistrée109    elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de110    registre dans l'attribut de classe `client`."""111112    source_id = ""            # les sous-classes générées le définissent113    client: dict = {}         # entrée du registre (name, subdomain, site…)114    request_delay = 1.0       # politesse — petits gestionnaires, on y va mollo115    max_listings = 300        # garde-fou (client validé le plus gros : 25)116    max_images = 20117118    @property119    def base_url(self) -> str:120        return f"https://{self.client['subdomain']}.appfolio.com"121122    def fetch(self) -> list[Listing]:123        soup = BeautifulSoup(self.get(self.base_url + "/listings").text,124                             "html.parser")125        listings: list[Listing] = []126        for item in soup.select(".js-listing-item"):127            if len(listings) >= self.max_listings:  # garde-fou APRÈS filtres128                break129            lst = self._listing(item)130            if lst is not None:131                listings.append(lst)132        return listings133134    # -- une annonce par carte .js-listing-item --------------------------------135    def _listing(self, item) -> Listing | None:136        try:137            link = item.select_one("a.js-link-to-detail") or \138                item.select_one('a[href*="/listings/detail/"]')139            if link is None:140                return None141            href = link.get("href") or ""142            m = re.search(r"/listings/detail/([0-9a-f-]{16,})", href)143            if not m:144                return None145            uid = m.group(1)146            url = urljoin(self.base_url, href)147148            def txt(sel: str) -> str:149                el = item.select_one(sel)150                return el.get_text(" ", strip=True) if el else ""151152            # adresse libre -> ville/province/postal via code postal canadien153            raw_addr = txt(".js-listing-address")154            parsed = _parse_address(raw_addr)155            if parsed is None:156                return None               # US ou autre province : hors sujet157            city, prov, postal = parsed158159            title = txt(".js-listing-title")160            description = txt(".js-listing-description")161            if _NON_RESIDENTIAL_RE.search(f"{title} {description[:200]}") or \162                    _SHORT_TERM_RE.search(f"{title} {description[:200]}"):163                return None164165            # loyer : bandeau mobile, sinon <dd> sous l'étiquette RENT166            price = parse_price(txt(".js-listing-blurb-rent"))167            area = None168            for box in item.select(".detail-box__item"):169                label = (box.select_one("dt") or box).get_text(" ", strip=True)170                value = box.select_one("dd")171                value = value.get_text(" ", strip=True) if value else ""172                low = label.lower()173                if "rent" in low and price is None:174                    price = parse_price(value)175                elif "square" in low:176                    try:177                        v = float(value.replace(",", "").strip() or 0)178                        if 80 <= v <= 20000:179                            area = v180                    except ValueError:181                        pass182183            # « 3 bd / 1.5 ba » / « Studio / 1 ba » — sinon None (inconnu)184            bedrooms = bathrooms = None185            unit_type = ""186            bb = _BED_BATH_RE.search(txt(".js-listing-blurb-bed-bath") or187                                     item.get_text(" ", strip=True))188            if bb:189                if bb.group(1).lower() == "studio":190                    bedrooms, unit_type = 0.0, "Studio"191                else:192                    bedrooms = float(bb.group(1))193                    unit_type = normalize_unit_type(f"{bedrooms:g} chambres")194                bathrooms = float(bb.group(2))195196            availability = txt(".js-listing-available")197198            # paragraphe « Utilities Included: … / Appliances: … » de la carte199            details: dict = {}200            for p in item.select(".listing-item__body p"):201                for b in p.find_all("span", style=re.compile("bold")):202                    key = b.get_text(strip=True).rstrip(":").strip()203                    val = (b.next_sibling or "")204                    val = str(val).strip() if isinstance(val, str) else ""205                    if key and val:206                        details[key] = val207208            # aperçu photo de la carte (lazy-load : data-original)209            preview = ""210            img = item.select_one("img.js-listing-image")211            if img:212                preview = (img.get("data-original") or "").strip()213                if "place_holder" in preview:214                    preview = ""215216            # fiche détail (description complète + galerie large.jpg) via le217            # cache BD : revisitée seulement quand la carte de la liste change218            feed_key = hashlib.sha1("|".join(str(x) for x in (219                price, availability, preview, raw_addr, description[:80],220            )).encode("utf-8")).hexdigest()221            d = self.detail(uid, feed_key, lambda: self._fetch_detail(url))222            full_desc = (d.get("description") or description).strip()223            images = [u for u in [preview] + list(d.get("images") or []) if u]224            images = list(dict.fromkeys(images))    # dédup en gardant l'ordre225226            address = raw_addr227            return Listing(228                source=self.source_id,229                external_id=uid,230                url=url,231                title=title or address,232                address=address,233                city=city,234                province=prov,235                unit_type=unit_type,236                bedrooms=bedrooms,237                bathrooms=bathrooms,238                price=price,239                price_label=f"{price:.0f} $ /mois" if price else "",240                availability=availability,241                area_sqft=area,242                description=full_desc[:1200],243                details=details,244                images=images[: self.max_images],245            )246        except Exception:247            return None248249    def _fetch_detail(self, url: str) -> dict:250        """Description complète + galerie (large.jpg) de la fiche détail."""251        out: dict = {"images": [], "description": ""}252        if not url:253            return out254        try:255            page = self.get(url).text256        except Exception:257            return out258        soup = BeautifulSoup(page, "html.parser")259        desc = soup.select_one(".listing-detail__description")260        if desc:261            out["description"] = desc.get_text("\n", strip=True)262        images: list[str] = []263        for u in _GALLERY_IMG_RE.findall(page):264            if u not in images:265                images.append(u)266        out["images"] = images[: self.max_images]267        return out268269270# -----------------------------------------------------------------------------271# Génération : une sous-classe par client du registre, déposée dans les globals272# du module — connectors/__init__.py (scan de vars(module)) les découvre alors273# comme n'importe quel connecteur écrit à la main.274# -----------------------------------------------------------------------------275def _make_connector(entry: dict) -> type[AppfolioConnector]:276    cls = type(277        f"Apf{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",278        (AppfolioConnector,),279        {280            "source_id": f"apf_{entry['id']}",281            "client": entry,282            # Expansion Ontario — activer via LOUKA_ONTARIO=1283            # (voir gestion-immobiliere-ontario.md)284            "disabled": not _ONTARIO,285            "__doc__": f"Connecteur AppFolio généré — {entry.get('name')} "286                       f"({entry.get('subdomain')}.appfolio.com).",287        },288    )289    return cls290291292def _register_all() -> None:293    for entry in _load_registry():294        cls = _make_connector(entry)295        globals()[cls.__name__] = cls296297298_register_all()299