# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/quanta.py : connecteur Gestion Quanta (gestionquanta.ca) # Firme de gestion de l'Outaouais (Gatineau, Hull, Aylmer). Vieux site # ASP.NET rendu serveur : la page d'accueil embarque un carrousel # (#CAROUSEL) avec TOUTES les fiches de location — titre (« Appartement / # Maison / Condo à louer »), lien Location.aspx?RentalId=&rented=<0|1>, # secteur/ville, loyer (« 890$ par mois, non chauffé ni éclairé »), # description et photo. Les unités louées portent rented=1 et un overlay # « rented » : elles sont exclues. Les pages Location.aspx elles-mêmes # répondent 404 (liens morts) : l'accueil est la seule source — l'URL # d'annonce pointe donc sur l'accueil avec un fragment stable. # robots.txt : Disallow /Rentals/ (chemin des photos, jamais visité par le # connecteur — seule la page d'accueil, autorisée, est requêtée). # external_id = RentalId (H4, G1, CH2…), stable côté site. # ----------------------------------------------------------------------------- from __future__ import annotations import re from urllib.parse import parse_qs, quote, urlparse from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://gestionquanta.ca" HOME_URL = f"{BASE}/" # villes de l'Outaouais telles qu'affichées en fin de ligne secteur _CITIES = ("hull", "gatineau", "aylmer", "buckingham", "masson") class QuantaConnector(BaseConnector): source_id = "quanta" request_delay = 1.0 max_images = 10 def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(HOME_URL).text, "html.parser") carousel = soup.select_one("#CAROUSEL .items") or soup listings: dict[str, Listing] = {} for item in carousel.find_all("div", recursive=False): try: self._parse_item(item, listings) except Exception: continue return list(listings.values()) def _parse_item(self, item, listings: dict[str, Listing]) -> None: link = item.select_one('a[href*="RentalId="]') if not link: return qs = parse_qs(urlparse(link["href"]).query) ext_id = (qs.get("RentalId") or [""])[0] if not ext_id or ext_id in listings: return # unité louée : drapeau rented=1 du lien ou overlay « rented » if (qs.get("rented") or ["0"])[0] == "1": return if item.select_one('img[src*="rented_overlay"]'): return h2 = item.find("h2") title = h2.get_text(" ", strip=True) if h2 else "" title = re.sub(r"\s*\(voir\)\s*$", "", title).strip() #

Secteur, Ville
890$ par mois, …

price_el = item.select_one("span.price") price_label = price_el.get_text(" ", strip=True) if price_el else "" location = "" p_loc = price_el.find_parent("p") if price_el else item.find("p") if p_loc: loc_txt = p_loc.get_text("\n", strip=True).split("\n")[0] if "$" not in loc_txt: location = re.sub(r"\s+", " ", loc_txt).strip() # « Plateau, Hull » -> ville Gatineau (Hull/Aylmer = secteurs fusionnés) sector, city = location, "" parts = [p.strip() for p in location.split(",") if p.strip()] if parts and parts[-1].lower() in _CITIES: city = "Gatineau" if parts[-1].lower() == "gatineau" and len(parts) > 1: sector = ", ".join(parts[:-1]) elif parts: city = parts[-1] sector = ", ".join(parts[:-1]) # description : les

hors ligne secteur/prix desc_parts: list[str] = [] for p in item.find_all("p"): txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if not txt or "$" in txt and price_label and price_label in txt: continue if txt == location or price_el and price_el in p.descendants: continue if p is p_loc: continue desc_parts.append(txt) description = "\n".join(desc_parts)[:2500] # type d'unité : Maison/Condo du titre ; pour les appartements, le # « n chambres à coucher » de la description (jamais deviné sinon) unit_type = normalize_unit_type(title) if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo", unit_type or ""): m = re.search(r"(\d+)\s*chambres?\b", description, re.I) unit_type = normalize_unit_type(f"{m.group(1)} chambres") if m else "" # photo : chemin Windows « Rentals\263 Atmosphere\S_IMG.jpg » -> URL images: list[str] = [] for im in item.select("img[src]")[: self.max_images]: src = (im.get("src") or "").replace("\\", "/") if not src or "rented_overlay" in src or src.startswith("Images/"): continue if not src.startswith("http"): src = f"{BASE}/" + quote(src.lstrip("/"), safe="/") if src not in images: images.append(src) listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=f"{HOME_URL}#location-{ext_id}", title=title, sector=sector, city=city or "Gatineau", unit_type=unit_type, price_label=price_label, description=description, images=images, )