# ----------------------------------------------------------------------------- # Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/appfolio.py : connecteur GÉNÉRIQUE AppFolio (appfolio.com) # Certains gestionnaires ontariens publient leur parc sur le portail AppFolio : # `https://.appfolio.com/listings` — HTML rendu serveur (cartes # `.js-listing-item`), page unique « Showing all available listings », aucun # rendu JS ni pagination nécessaires. Plutôt que N modules copiés-collés, ce # module lit le registre data/appfolio_clients.json ({id, name, subdomain…}, # validés en live le 2026-08-27) et GÉNÈRE une sous-classe de BaseConnector # par client (source_id = "apf_"), déposée dans les globals du module # pour le registre auto-découvrant (connectors/__init__.py). Même gabarit # que liftsystem.py / buildium.py. # NB recherche 2026-08-27 : AppFolio est RARE en Ontario (longue traîne ON # massivement sur Buildium) — un seul client validé (Fuse, Barrie) après # balayage Serper + scan des ~240 gestionnaires cartographiés ; le connecteur # est prêt pour les suivants. Les portails servent tout le Canada/US et les # adresses sont en texte libre : la province est déduite du CODE POSTAL # canadien (première lettre K/L/M/N/P = ON, G/H/J = QC), tout le reste est # ignoré. Filtre résidentiel longue durée (exclusion parking, entreposage, # commercial, court terme). Galerie photo de la fiche via le cache BD # self.detail() (images.cdn.appfolio.com//images//large.jpg). # # (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les # classes générées sont `disabled` et donc exclues du registre / de la prod. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import os import re from pathlib import Path from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector # Rent-Ka: connectors always active. _ONTARIO = True # Rent-Ka: always on (ROC scope) REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \ "appfolio_clients.json" # Code postal canadien dans l'adresse libre — la 1re lettre (district de tri) # donne la province sans ambiguïté : K/L/M/N/P = Ontario, G/H/J = Québec. # Les adresses US (« Chicago, IL 60649 ») ne matchent pas -> ignorées. _POSTAL_RE = re.compile(r"\b([A-Za-z])(\d[A-Za-z])\s?(\d[A-Za-z]\d)\b") # Québec (G/H/J) EXCLUDED (returns None) — Rent-Ka covers Canada outside # Québec. X = NT or NU: NT kept as default (X0A/X0B/X0C = Nunavut). _POSTAL_PROVINCE = { **{c: "ON" for c in "KLMNP"}, "A": "NL", "B": "NS", "C": "PE", "E": "NB", "R": "MB", "S": "SK", "T": "AB", "V": "BC", "X": "NT", "Y": "YT", } # Résidentiel longue durée seulement : stationnements, entreposage, commerces # et court terme sont hors sujet pour Rent-Ka (logements au mois). _NON_RESIDENTIAL_RE = re.compile( r"\b(parking|stationnement|storage|entreposage|locker|garage only|" r"commercial|office space|retail|warehouse)\b", re.I) _SHORT_TERM_RE = re.compile( r"\b(short[\s-]?term|nightly|per night|par nuit|vacation rental|" r"weekly rate|airbnb|court terme|campsites?|campground|nature camp)\b", re.I) # « 3 bd / 1.5 ba » ou « Studio / 1 ba » des cartes AppFolio _BED_BATH_RE = re.compile( r"(studio|\d+(?:\.\d+)?)\s*bd?\s*/\s*(\d+(?:\.\d+)?)\s*ba", re.I) # galerie de la fiche détail (préférer les variantes large.jpg) _GALLERY_IMG_RE = re.compile( r"https://images\.cdn\.appfolio\.com/[^\"'\s\\)]+/large\.[a-z]{3,4}", re.I) # mots des segments d'adresse qui ne sont PAS une ville (voir _parse_address) _NOT_CITY = {"", "on", "ontario", "ca", "canada", "qc", "quebec", "québec"} def _load_registry() -> list[dict]: """Entrées validées du registre (subdomain présent, status == valide).""" try: data = json.loads(REGISTRY_PATH.read_text("utf-8")) except (OSError, ValueError): return [] return [c for c in data.get("clients") or [] if c.get("subdomain") and c.get("status") == "valide"] def _parse_address(raw: str) -> tuple[str, str, str] | None: """(ville, province, code postal) depuis l'adresse libre AppFolio, ou None si hors ON/QC. Formats vus en live : « 241 Nottawasaga Street - 2 (B), Orillia L3V 3K2 », « 300 Cundles St West - Upper, Barrie, ON, CA L4N 7C9 », « 37 Peel St - Apt - 808, Barrie, CA L4M 3L1 ».""" m = _POSTAL_RE.search(raw) if not m: return None # pas de code postal canadien -> US prov = _POSTAL_PROVINCE.get(m.group(1).upper()) if not prov: return None # autre province canadienne postal = f"{m.group(1)}{m.group(2)} {m.group(3)}".upper() # ville = dernier segment (virgules) qui n'est ni ON/CA/Canada ni le postal before = raw[: m.start()].strip() for seg in reversed([s.strip() for s in before.split(",")]): seg = re.sub(r"\b(ON|QC|CA)\b\.?$", "", seg, flags=re.I).strip() if seg.lower() not in _NOT_CITY and not any(ch.isdigit() for ch in seg): return seg, prov, postal return "", prov, postal class AppfolioConnector(BaseConnector): """Base commune des connecteurs AppFolio générés — non enregistrée elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de registre dans l'attribut de classe `client`.""" source_id = "" # les sous-classes générées le définissent client: dict = {} # entrée du registre (name, subdomain, site…) request_delay = 1.0 # politesse — petits gestionnaires, on y va mollo max_listings = 300 # garde-fou (client validé le plus gros : 25) max_images = 20 @property def base_url(self) -> str: return f"https://{self.client['subdomain']}.appfolio.com" def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(self.base_url + "/listings").text, "html.parser") listings: list[Listing] = [] for item in soup.select(".js-listing-item"): if len(listings) >= self.max_listings: # garde-fou APRÈS filtres break lst = self._listing(item) if lst is not None: listings.append(lst) return listings # -- une annonce par carte .js-listing-item -------------------------------- def _listing(self, item) -> Listing | None: try: link = item.select_one("a.js-link-to-detail") or \ item.select_one('a[href*="/listings/detail/"]') if link is None: return None href = link.get("href") or "" m = re.search(r"/listings/detail/([0-9a-f-]{16,})", href) if not m: return None uid = m.group(1) url = urljoin(self.base_url, href) def txt(sel: str) -> str: el = item.select_one(sel) return el.get_text(" ", strip=True) if el else "" # adresse libre -> ville/province/postal via code postal canadien raw_addr = txt(".js-listing-address") parsed = _parse_address(raw_addr) if parsed is None: return None # US ou autre province : hors sujet city, prov, postal = parsed title = txt(".js-listing-title") description = txt(".js-listing-description") if _NON_RESIDENTIAL_RE.search(f"{title} {description[:200]}") or \ _SHORT_TERM_RE.search(f"{title} {description[:200]}"): return None # loyer : bandeau mobile, sinon
sous l'étiquette RENT price = parse_price(txt(".js-listing-blurb-rent")) area = None for box in item.select(".detail-box__item"): label = (box.select_one("dt") or box).get_text(" ", strip=True) value = box.select_one("dd") value = value.get_text(" ", strip=True) if value else "" low = label.lower() if "rent" in low and price is None: price = parse_price(value) elif "square" in low: try: v = float(value.replace(",", "").strip() or 0) if 80 <= v <= 20000: area = v except ValueError: pass # « 3 bd / 1.5 ba » / « Studio / 1 ba » — sinon None (inconnu) bedrooms = bathrooms = None unit_type = "" bb = _BED_BATH_RE.search(txt(".js-listing-blurb-bed-bath") or item.get_text(" ", strip=True)) if bb: if bb.group(1).lower() == "studio": bedrooms, unit_type = 0.0, "Studio" else: bedrooms = float(bb.group(1)) unit_type = normalize_unit_type(f"{bedrooms:g} chambres") bathrooms = float(bb.group(2)) availability = txt(".js-listing-available") # paragraphe « Utilities Included: … / Appliances: … » de la carte details: dict = {} for p in item.select(".listing-item__body p"): for b in p.find_all("span", style=re.compile("bold")): key = b.get_text(strip=True).rstrip(":").strip() val = (b.next_sibling or "") val = str(val).strip() if isinstance(val, str) else "" if key and val: details[key] = val # aperçu photo de la carte (lazy-load : data-original) preview = "" img = item.select_one("img.js-listing-image") if img: preview = (img.get("data-original") or "").strip() if "place_holder" in preview: preview = "" # fiche détail (description complète + galerie large.jpg) via le # cache BD : revisitée seulement quand la carte de la liste change feed_key = hashlib.sha1("|".join(str(x) for x in ( price, availability, preview, raw_addr, description[:80], )).encode("utf-8")).hexdigest() d = self.detail(uid, feed_key, lambda: self._fetch_detail(url)) full_desc = (d.get("description") or description).strip() images = [u for u in [preview] + list(d.get("images") or []) if u] images = list(dict.fromkeys(images)) # dédup en gardant l'ordre address = raw_addr return Listing( source=self.source_id, external_id=uid, url=url, title=title or address, address=address, city=city, province=prov, unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price=price, price_label=f"{price:.0f} $ /mois" if price else "", availability=availability, area_sqft=area, description=full_desc[:1200], details=details, images=images[: self.max_images], ) except Exception: return None def _fetch_detail(self, url: str) -> dict: """Description complète + galerie (large.jpg) de la fiche détail.""" out: dict = {"images": [], "description": ""} if not url: return out try: page = self.get(url).text except Exception: return out soup = BeautifulSoup(page, "html.parser") desc = soup.select_one(".listing-detail__description") if desc: out["description"] = desc.get_text("\n", strip=True) images: list[str] = [] for u in _GALLERY_IMG_RE.findall(page): if u not in images: images.append(u) out["images"] = images[: self.max_images] return out # ----------------------------------------------------------------------------- # Génération : une sous-classe par client du registre, déposée dans les globals # du module — connectors/__init__.py (scan de vars(module)) les découvre alors # comme n'importe quel connecteur écrit à la main. # ----------------------------------------------------------------------------- def _make_connector(entry: dict) -> type[AppfolioConnector]: cls = type( f"Apf{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector", (AppfolioConnector,), { "source_id": f"apf_{entry['id']}", "client": entry, # (voir gestion-immobiliere-ontario.md) "disabled": False, "__doc__": f"Connecteur AppFolio généré — {entry.get('name')} " f"({entry.get('subdomain')}.appfolio.com).", }, ) return cls def _register_all() -> None: for entry in _load_registry(): cls = _make_connector(entry) globals()[cls.__name__] = cls _register_all()