# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/evoludev.py : connecteur Groupe Evoludev (location.groupeevoludev.com) # Portail locatif maison (Laravel). La page d'accueil liste ~60 immeubles # (cartes : nom, adresse+ville, étiquette Disponible/Complet, prix «à partir # de»). Les pages projet (cache BD, seulement si l'immeuble est «Disponible») # exposent un tableau d'unités (no, type, prix, statut, date, cac/sdb, pi², # plan avec id d'appartement stable) + JSON-LD ApartmentComplex (description, # GPS, animaux, commodités). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import (Listing, normalize_unit_type, parse_area_sqft, parse_price, strip_accents) from .base import BaseConnector BASE = "https://location.groupeevoludev.com" _MODAL_ID = re.compile(r"apartmentPlanModal_(\d+)") def _pets_value(raw: str) -> str | None: """petsAllowed du JSON-LD -> oui/non/conditions (jamais deviné).""" k = strip_accents((raw or "").strip().lower()) if not k: return None if k.startswith("non") or "refus" in k or "aucun" in k: return "non" if "condition" in k or "approbation" in k: return "conditions" if k.startswith("oui") or "accepte" in k: return "oui" return None class EvoludevConnector(BaseConnector): source_id = "evoludev" request_delay = 0.8 max_pages = 1 # tout le parc est sur la page d'accueil du portail max_details = 45 # garde-fou pages projet (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(BASE + "/").text soup = BeautifulSoup(html, "html.parser") # cartes projet (dédupliquées par slug ; versions mobile/desktop en double) projects: dict[str, dict] = {} for hi in soup.select("div.headerImage[onclick]"): m = re.search(r"/projet/([\w-]+)", hi.get("onclick", "")) if not m: continue slug = m.group(1) if slug in projects: continue sect = hi.find_parent("section") tag = hi.select_one(".availabilityTag") title = sect.select_one(".ProjectItem__title") if sect else None addr = sect.select_one(".BuildingItem__address") if sect else None price = sect.select_one(".ProjectItem__price p") if sect else None addr_lines = ([l.strip() for l in addr.get_text("\n", strip=True).split("\n")] if addr else []) projects[slug] = { "slug": slug, "name": title.get_text(strip=True) if title else slug, "tag": tag.get_text(" ", strip=True) if tag else "", "street": addr_lines[0] if addr_lines else "", "city": re.sub(r",?\s*QC.*$", "", addr_lines[1]).strip() if len(addr_lines) > 1 else "", "price": re.sub(r"\s+", " ", price.get_text(" ", strip=True)) if price else "", } # pages projet : uniquement les immeubles étiquetés « Disponible » # (Complet / livraison à venir = aucune unité offerte actuellement) listings: list[Listing] = [] self._fetched = 0 for p in projects.values(): if not p["tag"].lower().startswith("disponible"): continue card_key = hashlib.sha1( f"{p['tag']}|{p['price']}|{p['name']}".encode("utf-8")).hexdigest() try: payload = self.detail(p["slug"], card_key, lambda s=p["slug"]: self._fetch_project(s)) except Exception: continue listings.extend(self._units_to_listings(p, payload)) return listings # -- page projet ------------------------------------------------------------ def _fetch_project(self, slug: str) -> dict: """Tableau des unités + JSON-LD ApartmentComplex (desc, GPS, animaux).""" if self._fetched >= self.max_details: raise RuntimeError("budget de pages projet atteint") self._fetched += 1 html = self.get(f"{BASE}/projet/{slug}").text soup = BeautifulSoup(html, "html.parser") out: dict = {"units": []} # JSON-LD ApartmentComplex : description, GPS, animaux, commodités for sc in soup.select('script[type="application/ld+json"]'): try: data = json.loads(sc.string or "") except Exception: continue if data.get("@type") == "ApartmentComplex": out["description"] = (data.get("description") or "").strip() out["lat"] = data.get("latitude") out["lng"] = data.get("longitude") out["pets"] = data.get("petsAllowed") or "" plan = data.get("accommodationFloorPlan") or {} out["amenities"] = plan.get("amenityFeature") or [] imgs = data.get("image") or [] out["building_image"] = imgs[0] if imgs else "" break # tableau desktop des unités : no | type, prix, statut, date, cac, sdb, pi² seen: set[str] = set() for tr in soup.select(".ApartmentTable tr"): th = tr.find("th") tds = tr.find_all("td") if not th or len(tds) < 6: continue name = th.get_text(" ", strip=True) # « 101 | 4 1/2 » if name in seen: continue seen.add(name) btn = tr.select_one("button[data-target]") m = _MODAL_ID.search(btn["data-target"]) if btn else None plan_img = "" if m: img = soup.select_one(f"#apartmentPlanModal_{m.group(1)} img[src]") plan_img = img["src"] if img else "" out["units"].append({ "name": name, "apt_id": m.group(1) if m else "", "price": tds[0].get_text(" ", strip=True), "status": tds[1].get_text(" ", strip=True), "date": tds[2].get_text(" ", strip=True), "bedrooms": tds[3].get_text(strip=True), "bathrooms": tds[4].get_text(strip=True), "area": tds[5].get_text(" ", strip=True), "plan_image": plan_img, }) return out # -- unités -> Listings ------------------------------------------------------- def _units_to_listings(self, p: dict, d: dict) -> list[Listing]: res: list[Listing] = [] for u in d.get("units", []): # seules les unités affichées « Disponible » (on saute Louée/Réservée) if not u["status"].lower().startswith("disponible"): continue num = u["name"].split("|")[0].strip() ext_id = u["apt_id"] or f"{p['slug']}-{num}" # prix placebo de la plateforme (« 0 $ / m », « N.D. $ / m ») : # aucun prix publié pour cette unité — on n'affiche rien price_label = u["price"] if re.match(r"^\s*(0|N\.?D\.?)\s*\$", price_label): price_label = "" images = [i for i in (u["plan_image"], d.get("building_image", "")) if i] details: dict = {} if u["bedrooms"].isdigit(): details["bedrooms"] = int(u["bedrooms"]) if u["bathrooms"].isdigit(): details["bathrooms"] = int(u["bathrooms"]) res.append(Listing( source=self.source_id, external_id=str(ext_id), url=f"{BASE}/projet/{p['slug']}", title=f"{p['name']} — Unité {num}", address=p["street"], city=p["city"], unit_type=normalize_unit_type(u["name"]), price=parse_price(price_label), price_label=price_label, availability=u["date"], area_sqft=parse_area_sqft(u["area"]), pets=_pets_value(d.get("pets", "")), description=d.get("description", ""), amenities=list(d.get("amenities", []))[:20], details=details, images=images, lat=d.get("lat"), lng=d.get("lng"), )) return res