spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/evoludev.py : connecteur Groupe Evoludev (location.groupeevoludev.com)5# Portail locatif maison (Laravel). La page d'accueil liste ~60 immeubles6# (cartes : nom, adresse+ville, étiquette Disponible/Complet, prix «à partir7# de»). Les pages projet (cache BD, seulement si l'immeuble est «Disponible»)8# exposent un tableau d'unités (no, type, prix, statut, date, cac/sdb, pi²,9# plan avec id d'appartement stable) + JSON-LD ApartmentComplex (description,10# GPS, animaux, commodités).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import json16import re1718from bs4 import BeautifulSoup1920from ..schema import (Listing, normalize_unit_type, parse_area_sqft,21 parse_price, strip_accents)22from .base import BaseConnector2324BASE = "https://location.groupeevoludev.com"2526_MODAL_ID = re.compile(r"apartmentPlanModal_(\d+)")272829def _pets_value(raw: str) -> str | None:30 """petsAllowed du JSON-LD -> oui/non/conditions (jamais deviné)."""31 k = strip_accents((raw or "").strip().lower())32 if not k:33 return None34 if k.startswith("non") or "refus" in k or "aucun" in k:35 return "non"36 if "condition" in k or "approbation" in k:37 return "conditions"38 if k.startswith("oui") or "accepte" in k:39 return "oui"40 return None414243class EvoludevConnector(BaseConnector):44 source_id = "evoludev"45 request_delay = 0.846 max_pages = 1 # tout le parc est sur la page d'accueil du portail47 max_details = 45 # garde-fou pages projet (vraies requêtes)4849 def fetch(self) -> list[Listing]:50 html = self.get(BASE + "/").text51 soup = BeautifulSoup(html, "html.parser")5253 # cartes projet (dédupliquées par slug ; versions mobile/desktop en double)54 projects: dict[str, dict] = {}55 for hi in soup.select("div.headerImage[onclick]"):56 m = re.search(r"/projet/([\w-]+)", hi.get("onclick", ""))57 if not m:58 continue59 slug = m.group(1)60 if slug in projects:61 continue62 sect = hi.find_parent("section")63 tag = hi.select_one(".availabilityTag")64 title = sect.select_one(".ProjectItem__title") if sect else None65 addr = sect.select_one(".BuildingItem__address") if sect else None66 price = sect.select_one(".ProjectItem__price p") if sect else None67 addr_lines = ([l.strip() for l in addr.get_text("\n", strip=True).split("\n")]68 if addr else [])69 projects[slug] = {70 "slug": slug,71 "name": title.get_text(strip=True) if title else slug,72 "tag": tag.get_text(" ", strip=True) if tag else "",73 "street": addr_lines[0] if addr_lines else "",74 "city": re.sub(r",?\s*QC.*$", "", addr_lines[1]).strip()75 if len(addr_lines) > 1 else "",76 "price": re.sub(r"\s+", " ", price.get_text(" ", strip=True))77 if price else "",78 }7980 # pages projet : uniquement les immeubles étiquetés « Disponible »81 # (Complet / livraison à venir = aucune unité offerte actuellement)82 listings: list[Listing] = []83 self._fetched = 084 for p in projects.values():85 if not p["tag"].lower().startswith("disponible"):86 continue87 card_key = hashlib.sha1(88 f"{p['tag']}|{p['price']}|{p['name']}".encode("utf-8")).hexdigest()89 try:90 payload = self.detail(p["slug"], card_key,91 lambda s=p["slug"]: self._fetch_project(s))92 except Exception:93 continue94 listings.extend(self._units_to_listings(p, payload))95 return listings9697 # -- page projet ------------------------------------------------------------98 def _fetch_project(self, slug: str) -> dict:99 """Tableau des unités + JSON-LD ApartmentComplex (desc, GPS, animaux)."""100 if self._fetched >= self.max_details:101 raise RuntimeError("budget de pages projet atteint")102 self._fetched += 1103 html = self.get(f"{BASE}/projet/{slug}").text104 soup = BeautifulSoup(html, "html.parser")105 out: dict = {"units": []}106107 # JSON-LD ApartmentComplex : description, GPS, animaux, commodités108 for sc in soup.select('script[type="application/ld+json"]'):109 try:110 data = json.loads(sc.string or "")111 except Exception:112 continue113 if data.get("@type") == "ApartmentComplex":114 out["description"] = (data.get("description") or "").strip()115 out["lat"] = data.get("latitude")116 out["lng"] = data.get("longitude")117 out["pets"] = data.get("petsAllowed") or ""118 plan = data.get("accommodationFloorPlan") or {}119 out["amenities"] = plan.get("amenityFeature") or []120 imgs = data.get("image") or []121 out["building_image"] = imgs[0] if imgs else ""122 break123124 # tableau desktop des unités : no | type, prix, statut, date, cac, sdb, pi²125 seen: set[str] = set()126 for tr in soup.select(".ApartmentTable tr"):127 th = tr.find("th")128 tds = tr.find_all("td")129 if not th or len(tds) < 6:130 continue131 name = th.get_text(" ", strip=True) # « 101 | 4 1/2 »132 if name in seen:133 continue134 seen.add(name)135 btn = tr.select_one("button[data-target]")136 m = _MODAL_ID.search(btn["data-target"]) if btn else None137 plan_img = ""138 if m:139 img = soup.select_one(f"#apartmentPlanModal_{m.group(1)} img[src]")140 plan_img = img["src"] if img else ""141 out["units"].append({142 "name": name,143 "apt_id": m.group(1) if m else "",144 "price": tds[0].get_text(" ", strip=True),145 "status": tds[1].get_text(" ", strip=True),146 "date": tds[2].get_text(" ", strip=True),147 "bedrooms": tds[3].get_text(strip=True),148 "bathrooms": tds[4].get_text(strip=True),149 "area": tds[5].get_text(" ", strip=True),150 "plan_image": plan_img,151 })152 return out153154 # -- unités -> Listings -------------------------------------------------------155 def _units_to_listings(self, p: dict, d: dict) -> list[Listing]:156 res: list[Listing] = []157 for u in d.get("units", []):158 # seules les unités affichées « Disponible » (on saute Louée/Réservée)159 if not u["status"].lower().startswith("disponible"):160 continue161 num = u["name"].split("|")[0].strip()162 ext_id = u["apt_id"] or f"{p['slug']}-{num}"163 # prix placebo de la plateforme (« 0 $ / m », « N.D. $ / m ») :164 # aucun prix publié pour cette unité — on n'affiche rien165 price_label = u["price"]166 if re.match(r"^\s*(0|N\.?D\.?)\s*\$", price_label):167 price_label = ""168 images = [i for i in (u["plan_image"], d.get("building_image", ""))169 if i]170 details: dict = {}171 if u["bedrooms"].isdigit():172 details["bedrooms"] = int(u["bedrooms"])173 if u["bathrooms"].isdigit():174 details["bathrooms"] = int(u["bathrooms"])175 res.append(Listing(176 source=self.source_id,177 external_id=str(ext_id),178 url=f"{BASE}/projet/{p['slug']}",179 title=f"{p['name']} — Unité {num}",180 address=p["street"],181 city=p["city"],182 unit_type=normalize_unit_type(u["name"]),183 price=parse_price(price_label),184 price_label=price_label,185 availability=u["date"],186 area_sqft=parse_area_sqft(u["area"]),187 pets=_pets_value(d.get("pets", "")),188 description=d.get("description", ""),189 amenities=list(d.get("amenities", []))[:20],190 details=details,191 images=images,192 lat=d.get("lat"),193 lng=d.get("lng"),194 ))195 return res196