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/qresidential.py : connecteur Q Residential (qresidential.ca)5# Gestionnaire ontarien (~7 000 unités — Toronto, St. Catharines, Hamilton,6# Oshawa/GTA, Brampton, Barrie). « Corporate site » Yardi RentCafe derrière7# un Cloudflare STRICT (curl direct = 403) : tout passe par Scrapfly ASP8# (self.get_scrapfly, asp=true, SANS render_js — le contenu est rendu9# serveur, vérifié sur la recherche et les fiches).10# Contrairement à killam.py (même famille RentCafe), il n'y a PAS de blob11# #available_prop : la page /searchlisting.aspx rend 25 cartes propriétés12# côté serveur (nom, adresse, ville, province, vignette, URL de fiche).13# La fiche propriété fournit un JSON-LD ApartmentComplex (description,14# adresse postale, GPS, commodités, téléphone) et un tableau « Floor Plans »15# (tbody.floorplan-details : nom, Bed/Bath, pi², loyer — souvent « Call for16# pricing » —, disponibilité). Une annonce PAR PLAN D'ÉTAGE quand au moins17# un plan affiche un prix ; sinon repli « une annonce par propriété » sans18# prix (rien d'inventé). Fiches visitées via le cache BD self.detail(),19# clé datée du jour : au plus UNE visite Scrapfly par propriété par jour,20# peu importe le nombre de synchronisations.21#22# connecteur est `disabled` et exclu du registre (zéro impact prod QC).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import datetime as _dt27import json28import os29import re3031from bs4 import BeautifulSoup3233from ..schema import Listing, normalize_unit_type, parse_price34from .base import BaseConnector3536BASE = "https://www.qresidential.ca"37SEARCH_PAGE = f"{BASE}/searchlisting.aspx"3839# Gate expansion Ontario : le connecteur reste hors registre tant que la40_ONTARIO = True # Rent-Ka: always on (ROC scope)4142# cellules du tableau plans d'étage : « Bed/Bath Studio / 1 », « 2 / 1.5 »43_BEDBATH_RE = re.compile(44 r"Bed/Bath\s+(Studio|\d+)\s*/\s*([\d.]+)", re.I)45_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)46# identifiant stable du plan d'étage (carrousel photo du plan)47_FPID_RE = re.compile(r"fp-myCarousel(\d+)")484950class QResidentialConnector(BaseConnector):51 source_id = "qresidential"52 request_delay = 1.5 # Cloudflare strict : Scrapfly ASP, rester poli53 disabled = False54 max_properties = 35 # garde-fou (25 propriétés aujourd'hui)55 max_images = 155657 def fetch(self) -> list[Listing]:58 page = self.get_scrapfly(SEARCH_PAGE, asp=True, render_js=False)59 if not page:60 raise RuntimeError("Q Residential : Scrapfly ASP n'a pas "61 "retourné la page de recherche")62 soup = BeautifulSoup(page, "html.parser")6364 cards = soup.select("div.searchResult")65 listings: list[Listing] = []66 count = 067 seen: set[str] = set()68 for card in cards:69 try:70 a = card.select_one("a.propertyUrl")71 if a is None:72 continue73 url = (a.get("href") or "").strip()74 if not url or url in seen:75 continue76 seen.add(url)77 # résidentiel longue durée seulement (structure « Apartment »)78 st = card.select_one(".structure-type")79 if st and st.get_text(" ", strip=True) and \80 "apartment" not in st.get_text(" ", strip=True).lower():81 continue82 state = card.select_one(".propertyState")83 if state and state.get_text(strip=True).upper() != "ON":84 continue85 if count >= self.max_properties:86 break87 count += 188 listings.extend(self._property_listings(card, url))89 except Exception:90 continue91 return listings9293 # -- annonces d'une propriété (une par plan d'étage tarifé, repli) ----------94 def _property_listings(self, card, url: str) -> list[Listing]:95 # identifiant : slug de la fiche (« queenston-manor »)96 slug = url.rstrip("/").split("/")[-2] if url.endswith("default.aspx") \97 else re.sub(r"[^a-z0-9-]", "", url.rstrip("/").split("/")[-1])98 a = card.select_one("a.propertyUrl")99 name = a.get_text(" ", strip=True)100 city = (card.select_one(".propertyCity") or a) \101 .get_text(" ", strip=True) if card.select_one(".propertyCity") \102 else ""103 # adresse complète de la carte (« 382 Queenston Street St. Catharines104 # ON L2P 3V5 ») — on la reconstruit avec des virgules105 street = ""106 spans = card.select(".propertyAddress")107 if spans:108 street = spans[-1].get_text(" ", strip=True)109 postal = ""110 m = re.search(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d",111 card.get_text(" ", strip=True))112 if m:113 postal = m.group(0)114 address = ", ".join(x for x in (street, city) if x)115 if address:116 address += f", ON {postal}".rstrip()117118 thumb = ""119 img = card.select_one("img.propertyThumb")120 if img is not None:121 thumb = (img.get("src") or "").strip()122123 # fiche propriété via le cache BD — clé datée : au plus une visite124 # Scrapfly par propriété par jour (les prix « Call for pricing »125 # changent rarement, et jamais entre deux syncs du même jour)126 feed_key = f"{_dt.date.today().isoformat()}|{name}|{thumb}"127 d = self.detail(slug, feed_key, lambda: self._fetch_detail(url))128129 desc = d.get("description") or ""130 amenities = d.get("amenities") or []131 images = [u for u in ([thumb] + (d.get("images") or [])) if u]132 lat, lng = d.get("lat"), d.get("lng")133 details: dict = {}134 if d.get("phone"):135 details["contact"] = {"phone": d["phone"]}136137 common = dict(138 source=self.source_id, url=url, address=address, city=city,139 province="ON", description=desc, amenities=amenities[:25],140 images=images[: self.max_images], lat=lat, lng=lng,141 )142143 # une annonce par plan d'étage TARIFÉ (prix affiché) ; les plans144 # « Call for pricing » ne deviennent pas des annonces individuelles145 out: list[Listing] = []146 priced = [fp for fp in d.get("floorplans") or []147 if fp.get("price") is not None]148 for fp in priced:149 beds = fp.get("beds")150 price = fp["price"]151 out.append(Listing(152 external_id=f"{slug}-{fp['fpid']}",153 title=f"{name} — {fp['name']}" if fp.get("name") else name,154 unit_type=("Studio" if beds == 0 else normalize_unit_type(155 f"{int(beds)} chambres") if beds is not None else ""),156 bedrooms=beds,157 bathrooms=fp.get("baths"),158 price=price,159 price_label=f"À partir de {price:.0f} $ /mois",160 availability=fp.get("avail") or "",161 area_sqft=fp.get("sqft"),162 details=dict(details),163 **common,164 ))165 if out:166 return out167168 # repli : une annonce par propriété — gamme de types connue seulement169 # via les plans d'étage non tarifés ; aucun prix inventé170 fps = d.get("floorplans") or []171 beds_set = {fp.get("beds") for fp in fps if fp.get("beds") is not None}172 unit_type = ""173 if len(beds_set) == 1:174 b = beds_set.pop()175 unit_type = "Studio" if b == 0 else normalize_unit_type(176 f"{int(b)} chambres")177 return [Listing(178 external_id=slug,179 title=name,180 unit_type=unit_type,181 availability="Sur demande (contacter la gestion)" if fps else "",182 details=details,183 **common,184 )]185186 # -- fiche propriété : JSON-LD + tableau plans d'étage ----------------------187 def _fetch_detail(self, url: str) -> dict:188 out: dict = {"description": "", "amenities": [], "images": [],189 "floorplans": [], "lat": None, "lng": None, "phone": ""}190 page = self.get_scrapfly(url, asp=True, render_js=False)191 if not page:192 return out193 soup = BeautifulSoup(page, "html.parser")194195 # JSON-LD ApartmentComplex : description, GPS, commodités, téléphone196 for tag in soup.find_all("script", type="application/ld+json"):197 try:198 ld = json.loads(tag.string or "")199 except (TypeError, ValueError):200 continue201 if not isinstance(ld, dict) or \202 ld.get("@type") != "ApartmentComplex":203 continue204 out["description"] = BeautifulSoup(205 ld.get("description") or "", "html.parser"206 ).get_text(" ", strip=True)[:600]207 geo = ld.get("geo") or {}208 try:209 out["lat"] = float(geo.get("latitude"))210 out["lng"] = float(geo.get("longitude"))211 except (TypeError, ValueError):212 pass213 out["phone"] = ((ld.get("address") or {})214 .get("telephone") or "").strip()215 ams = []216 for af in ld.get("amenityFeature") or []:217 t = (af.get("name") or "").strip() if isinstance(af, dict) \218 else ""219 if t and t not in ams:220 ams.append(t)221 out["amenities"] = ams[:25]222 break223224 # visuels de la fiche (bannière dmslivecafe) — HORS plans d'étage/logos225 images: list[str] = []226 for img in soup.find_all("img"):227 src = (img.get("src") or "").strip()228 if "cdngeneralcf.rentcafe.com/dmslivecafe" not in src:229 continue230 if re.search(r"logo|icon|floor\s?plan|FloorPlan", src, re.I):231 continue232 if img.find_parent(class_=re.compile("floorplan")):233 continue234 if src not in images:235 images.append(src)236 out["images"] = images[: self.max_images]237238 # tableau plans d'étage : une ligne par variation (nom, Bed/Bath,239 # pi² éventuel, loyer, disponibilité)240 seen: set[str] = set()241 for tr in soup.select("tbody.floorplan-details tr"):242 txt = tr.get_text(" ", strip=True)243 if "Bed/Bath" not in txt:244 continue245 mid = _FPID_RE.search(str(tr))246 name_td = tr.find(string=re.compile(r"Floor Plan\s"))247 name = ""248 if name_td:249 name = re.sub(r"^Floor Plan\s+", "",250 str(name_td).strip()).strip()251 fpid = mid.group(1) if mid else \252 re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")253 if not fpid or fpid in seen:254 continue255 seen.add(fpid)256 mbb = _BEDBATH_RE.search(txt)257 beds = baths = None258 if mbb:259 beds = 0.0 if mbb.group(1).lower() == "studio" \260 else float(mbb.group(1))261 try:262 baths = float(mbb.group(2))263 except ValueError:264 baths = None265 msq = _SQFT_RE.search(txt)266 sqft = float(msq.group(1).replace(",", "")) if msq else None267 # loyer : montant affiché seulement (« Call for pricing » -> None)268 price = None269 mrent = re.search(r"Rent\s+([^D]*?)(?:Deposit|$)", txt)270 if mrent:271 price = parse_price(mrent.group(1))272 avail = ""273 mav = re.search(r"Deposit\s*(.*?)(?:Read More|$)", txt)274 if mav:275 avail = mav.group(1).strip()276 if avail.lower() in ("contact us", ""):277 avail = ""278 out["floorplans"].append({279 "fpid": fpid,280 "name": name,281 "beds": beds,282 "baths": baths,283 "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,284 "price": price,285 "avail": avail,286 })287 return out288