Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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