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/quanta.py : connecteur Gestion Quanta (gestionquanta.ca)5# Firme de gestion de l'Outaouais (Gatineau, Hull, Aylmer). Vieux site6# ASP.NET rendu serveur : la page d'accueil embarque un carrousel7# (#CAROUSEL) avec TOUTES les fiches de location — titre (« Appartement /8# Maison / Condo à louer »), lien Location.aspx?RentalId=<id>&rented=<0|1>,9# secteur/ville, loyer (« 890$ par mois, non chauffé ni éclairé »),10# description et photo. Les unités louées portent rented=1 et un overlay11# « rented » : elles sont exclues. Les pages Location.aspx elles-mêmes12# répondent 404 (liens morts) : l'accueil est la seule source — l'URL13# d'annonce pointe donc sur l'accueil avec un fragment stable.14# robots.txt : Disallow /Rentals/ (chemin des photos, jamais visité par le15# connecteur — seule la page d'accueil, autorisée, est requêtée).16# external_id = RentalId (H4, G1, CH2…), stable côté site.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re21from urllib.parse import parse_qs, quote, urlparse2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type26from .base import BaseConnector2728BASE = "https://gestionquanta.ca"29HOME_URL = f"{BASE}/"3031# villes de l'Outaouais telles qu'affichées en fin de ligne secteur32_CITIES = ("hull", "gatineau", "aylmer", "buckingham", "masson")333435class QuantaConnector(BaseConnector):36 source_id = "quanta"37 request_delay = 1.038 max_images = 103940 def fetch(self) -> list[Listing]:41 soup = BeautifulSoup(self.get(HOME_URL).text, "html.parser")42 carousel = soup.select_one("#CAROUSEL .items") or soup43 listings: dict[str, Listing] = {}44 for item in carousel.find_all("div", recursive=False):45 try:46 self._parse_item(item, listings)47 except Exception:48 continue49 return list(listings.values())5051 def _parse_item(self, item, listings: dict[str, Listing]) -> None:52 link = item.select_one('a[href*="RentalId="]')53 if not link:54 return55 qs = parse_qs(urlparse(link["href"]).query)56 ext_id = (qs.get("RentalId") or [""])[0]57 if not ext_id or ext_id in listings:58 return59 # unité louée : drapeau rented=1 du lien ou overlay « rented »60 if (qs.get("rented") or ["0"])[0] == "1":61 return62 if item.select_one('img[src*="rented_overlay"]'):63 return6465 h2 = item.find("h2")66 title = h2.get_text(" ", strip=True) if h2 else ""67 title = re.sub(r"\s*\(voir\)\s*$", "", title).strip()6869 # <p>Secteur, Ville<br/><span class="price">890$ par mois, …</span></p>70 price_el = item.select_one("span.price")71 price_label = price_el.get_text(" ", strip=True) if price_el else ""72 location = ""73 p_loc = price_el.find_parent("p") if price_el else item.find("p")74 if p_loc:75 loc_txt = p_loc.get_text("\n", strip=True).split("\n")[0]76 if "$" not in loc_txt:77 location = re.sub(r"\s+", " ", loc_txt).strip()7879 # « Plateau, Hull » -> ville Gatineau (Hull/Aylmer = secteurs fusionnés)80 sector, city = location, ""81 parts = [p.strip() for p in location.split(",") if p.strip()]82 if parts and parts[-1].lower() in _CITIES:83 city = "Gatineau"84 if parts[-1].lower() == "gatineau" and len(parts) > 1:85 sector = ", ".join(parts[:-1])86 elif parts:87 city = parts[-1]88 sector = ", ".join(parts[:-1])8990 # description : les <p> hors ligne secteur/prix91 desc_parts: list[str] = []92 for p in item.find_all("p"):93 txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))94 if not txt or "$" in txt and price_label and price_label in txt:95 continue96 if txt == location or price_el and price_el in p.descendants:97 continue98 if p is p_loc:99 continue100 desc_parts.append(txt)101 description = "\n".join(desc_parts)[:2500]102103 # type d'unité : Maison/Condo du titre ; pour les appartements, le104 # « n chambres à coucher » de la description (jamais deviné sinon)105 unit_type = normalize_unit_type(title)106 if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo",107 unit_type or ""):108 m = re.search(r"(\d+)\s*chambres?\b", description, re.I)109 unit_type = normalize_unit_type(f"{m.group(1)} chambres") if m else ""110111 # photo : chemin Windows « Rentals\263 Atmosphere\S_IMG.jpg » -> URL112 images: list[str] = []113 for im in item.select("img[src]")[: self.max_images]:114 src = (im.get("src") or "").replace("\\", "/")115 if not src or "rented_overlay" in src or src.startswith("Images/"):116 continue117 if not src.startswith("http"):118 src = f"{BASE}/" + quote(src.lstrip("/"), safe="/")119 if src not in images:120 images.append(src)121122 listings[ext_id] = Listing(123 source=self.source_id,124 external_id=ext_id,125 url=f"{HOME_URL}#location-{ext_id}",126 title=title,127 sector=sector,128 city=city or "Gatineau",129 unit_type=unit_type,130 price_label=price_label,131 description=description,132 images=images,133 )134