Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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