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/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# Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le23# connecteur est `disabled` et exclu du registre (zéro impact prod QC).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import datetime as _dt28import json29import os30import re3132from bs4 import BeautifulSoup3334from ..schema import Listing, normalize_unit_type, parse_price35from .base import BaseConnector3637BASE = "https://www.qresidential.ca"38SEARCH_PAGE = f"{BASE}/searchlisting.aspx"3940# Gate expansion Ontario : le connecteur reste hors registre tant que la41# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.42_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4344# cellules du tableau plans d'étage : « Bed/Bath Studio / 1 », « 2 / 1.5 »45_BEDBATH_RE = re.compile(46 r"Bed/Bath\s+(Studio|\d+)\s*/\s*([\d.]+)", re.I)47_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)48# identifiant stable du plan d'étage (carrousel photo du plan)49_FPID_RE = re.compile(r"fp-myCarousel(\d+)")505152class QResidentialConnector(BaseConnector):53 source_id = "qresidential"54 request_delay = 1.5 # Cloudflare strict : Scrapfly ASP, rester poli55 disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)56 max_properties = 35 # garde-fou (25 propriétés aujourd'hui)57 max_images = 155859 def fetch(self) -> list[Listing]:60 page = self.get_scrapfly(SEARCH_PAGE, asp=True, render_js=False)61 if not page:62 raise RuntimeError("Q Residential : Scrapfly ASP n'a pas "63 "retourné la page de recherche")64 soup = BeautifulSoup(page, "html.parser")6566 cards = soup.select("div.searchResult")67 listings: list[Listing] = []68 count = 069 seen: set[str] = set()70 for card in cards:71 try:72 a = card.select_one("a.propertyUrl")73 if a is None:74 continue75 url = (a.get("href") or "").strip()76 if not url or url in seen:77 continue78 seen.add(url)79 # résidentiel longue durée seulement (structure « Apartment »)80 st = card.select_one(".structure-type")81 if st and st.get_text(" ", strip=True) and \82 "apartment" not in st.get_text(" ", strip=True).lower():83 continue84 state = card.select_one(".propertyState")85 if state and state.get_text(strip=True).upper() != "ON":86 continue87 if count >= self.max_properties:88 break89 count += 190 listings.extend(self._property_listings(card, url))91 except Exception:92 continue93 return listings9495 # -- annonces d'une propriété (une par plan d'étage tarifé, repli) ----------96 def _property_listings(self, card, url: str) -> list[Listing]:97 # identifiant : slug de la fiche (« queenston-manor »)98 slug = url.rstrip("/").split("/")[-2] if url.endswith("default.aspx") \99 else re.sub(r"[^a-z0-9-]", "", url.rstrip("/").split("/")[-1])100 a = card.select_one("a.propertyUrl")101 name = a.get_text(" ", strip=True)102 city = (card.select_one(".propertyCity") or a) \103 .get_text(" ", strip=True) if card.select_one(".propertyCity") \104 else ""105 # adresse complète de la carte (« 382 Queenston Street St. Catharines106 # ON L2P 3V5 ») — on la reconstruit avec des virgules107 street = ""108 spans = card.select(".propertyAddress")109 if spans:110 street = spans[-1].get_text(" ", strip=True)111 postal = ""112 m = re.search(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d",113 card.get_text(" ", strip=True))114 if m:115 postal = m.group(0)116 address = ", ".join(x for x in (street, city) if x)117 if address:118 address += f", ON {postal}".rstrip()119120 thumb = ""121 img = card.select_one("img.propertyThumb")122 if img is not None:123 thumb = (img.get("src") or "").strip()124125 # fiche propriété via le cache BD — clé datée : au plus une visite126 # Scrapfly par propriété par jour (les prix « Call for pricing »127 # changent rarement, et jamais entre deux syncs du même jour)128 feed_key = f"{_dt.date.today().isoformat()}|{name}|{thumb}"129 d = self.detail(slug, feed_key, lambda: self._fetch_detail(url))130131 desc = d.get("description") or ""132 amenities = d.get("amenities") or []133 images = [u for u in ([thumb] + (d.get("images") or [])) if u]134 lat, lng = d.get("lat"), d.get("lng")135 details: dict = {}136 if d.get("phone"):137 details["contact"] = {"phone": d["phone"]}138139 common = dict(140 source=self.source_id, url=url, address=address, city=city,141 province="ON", description=desc, amenities=amenities[:25],142 images=images[: self.max_images], lat=lat, lng=lng,143 )144145 # une annonce par plan d'étage TARIFÉ (prix affiché) ; les plans146 # « Call for pricing » ne deviennent pas des annonces individuelles147 out: list[Listing] = []148 priced = [fp for fp in d.get("floorplans") or []149 if fp.get("price") is not None]150 for fp in priced:151 beds = fp.get("beds")152 price = fp["price"]153 out.append(Listing(154 external_id=f"{slug}-{fp['fpid']}",155 title=f"{name} — {fp['name']}" if fp.get("name") else name,156 unit_type=("Studio" if beds == 0 else normalize_unit_type(157 f"{int(beds)} chambres") if beds is not None else ""),158 bedrooms=beds,159 bathrooms=fp.get("baths"),160 price=price,161 price_label=f"À partir de {price:.0f} $ /mois",162 availability=fp.get("avail") or "",163 area_sqft=fp.get("sqft"),164 details=dict(details),165 **common,166 ))167 if out:168 return out169170 # repli : une annonce par propriété — gamme de types connue seulement171 # via les plans d'étage non tarifés ; aucun prix inventé172 fps = d.get("floorplans") or []173 beds_set = {fp.get("beds") for fp in fps if fp.get("beds") is not None}174 unit_type = ""175 if len(beds_set) == 1:176 b = beds_set.pop()177 unit_type = "Studio" if b == 0 else normalize_unit_type(178 f"{int(b)} chambres")179 return [Listing(180 external_id=slug,181 title=name,182 unit_type=unit_type,183 availability="Sur demande (contacter la gestion)" if fps else "",184 details=details,185 **common,186 )]187188 # -- fiche propriété : JSON-LD + tableau plans d'étage ----------------------189 def _fetch_detail(self, url: str) -> dict:190 out: dict = {"description": "", "amenities": [], "images": [],191 "floorplans": [], "lat": None, "lng": None, "phone": ""}192 page = self.get_scrapfly(url, asp=True, render_js=False)193 if not page:194 return out195 soup = BeautifulSoup(page, "html.parser")196197 # JSON-LD ApartmentComplex : description, GPS, commodités, téléphone198 for tag in soup.find_all("script", type="application/ld+json"):199 try:200 ld = json.loads(tag.string or "")201 except (TypeError, ValueError):202 continue203 if not isinstance(ld, dict) or \204 ld.get("@type") != "ApartmentComplex":205 continue206 out["description"] = BeautifulSoup(207 ld.get("description") or "", "html.parser"208 ).get_text(" ", strip=True)[:600]209 geo = ld.get("geo") or {}210 try:211 out["lat"] = float(geo.get("latitude"))212 out["lng"] = float(geo.get("longitude"))213 except (TypeError, ValueError):214 pass215 out["phone"] = ((ld.get("address") or {})216 .get("telephone") or "").strip()217 ams = []218 for af in ld.get("amenityFeature") or []:219 t = (af.get("name") or "").strip() if isinstance(af, dict) \220 else ""221 if t and t not in ams:222 ams.append(t)223 out["amenities"] = ams[:25]224 break225226 # visuels de la fiche (bannière dmslivecafe) — HORS plans d'étage/logos227 images: list[str] = []228 for img in soup.find_all("img"):229 src = (img.get("src") or "").strip()230 if "cdngeneralcf.rentcafe.com/dmslivecafe" not in src:231 continue232 if re.search(r"logo|icon|floor\s?plan|FloorPlan", src, re.I):233 continue234 if img.find_parent(class_=re.compile("floorplan")):235 continue236 if src not in images:237 images.append(src)238 out["images"] = images[: self.max_images]239240 # tableau plans d'étage : une ligne par variation (nom, Bed/Bath,241 # pi² éventuel, loyer, disponibilité)242 seen: set[str] = set()243 for tr in soup.select("tbody.floorplan-details tr"):244 txt = tr.get_text(" ", strip=True)245 if "Bed/Bath" not in txt:246 continue247 mid = _FPID_RE.search(str(tr))248 name_td = tr.find(string=re.compile(r"Floor Plan\s"))249 name = ""250 if name_td:251 name = re.sub(r"^Floor Plan\s+", "",252 str(name_td).strip()).strip()253 fpid = mid.group(1) if mid else \254 re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")255 if not fpid or fpid in seen:256 continue257 seen.add(fpid)258 mbb = _BEDBATH_RE.search(txt)259 beds = baths = None260 if mbb:261 beds = 0.0 if mbb.group(1).lower() == "studio" \262 else float(mbb.group(1))263 try:264 baths = float(mbb.group(2))265 except ValueError:266 baths = None267 msq = _SQFT_RE.search(txt)268 sqft = float(msq.group(1).replace(",", "")) if msq else None269 # loyer : montant affiché seulement (« Call for pricing » -> None)270 price = None271 mrent = re.search(r"Rent\s+([^D]*?)(?:Deposit|$)", txt)272 if mrent:273 price = parse_price(mrent.group(1))274 avail = ""275 mav = re.search(r"Deposit\s*(.*?)(?:Read More|$)", txt)276 if mav:277 avail = mav.group(1).strip()278 if avail.lower() in ("contact us", ""):279 avail = ""280 out["floorplans"].append({281 "fpid": fpid,282 "name": name,283 "beds": beds,284 "baths": baths,285 "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,286 "price": price,287 "avail": avail,288 })289 return out290