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/halfred.py : connecteur Halfred (halfred.ca — Outaouais)5# Application React Router rendue CÔTÉ SERVEUR : /listings redirige vers6# /listings/list dont le HTML contient déjà toutes les cartes d'annonces7# (aucun JavaScript requis). Chaque carte porte, en éléments structurés :8# - le lien /listings/<slug>/details (ou /listings/project/<uuid>/details9# pour les projets locatifs) -> external_id stable ;10# - l'adresse civique (h1), le secteur « Gatineau (Hull) » (icône map-pin),11# le nombre de chambres, de salles de bain, la superficie, la date de12# disponibilité (icônes lucide) et le prix (« À partir de 1 650$ »,13# ancien prix barré quand il y a une promotion).14# La fiche /details ajoute la description longue rédigée par l'agence, la15# galerie de photos, les scores de mobilité/vélo et le contact du bureau.16# Elle passe par self.detail(...) (cache BD) avec un plafond par sync : le17# robots.txt du site impose « Crawl-delay: 10 » — request_delay = 10 s.18# Le site est bilingue : en-tête Accept-Language fr-CA pour obtenir le19# français (« 2 chambres », « Disponible maintenant »).20# -----------------------------------------------------------------------------21from __future__ import annotations2223import hashlib24import re2526from bs4 import BeautifulSoup2728from ..schema import (Listing, normalize_unit_type, parse_area_sqft,29 parse_price)30from .base import BaseConnector3132BASE = "https://www.halfred.ca"33LIST_URL = f"{BASE}/listings/list"3435# Villes/secteurs de l'agglomération de Gatineau (fusion 2002) : la ville36# affichée reste « Gatineau », le nom d'origine devient le secteur.37_GATINEAU_KEYS = {38 "gatineau", "hull", "aylmer", "buckingham", "angers", "masson-angers",39 "templeton", "east templeton", "touraine", "pointe-gatineau",40 "old gatineau", "plateau", "mont-bleu", "limbour", "val-tetreau",41 "east buckingham",42}43# libellés de secteur sans valeur informative (numéros, doublon de la ville)44_SECTOR_NOISE = re.compile(r"^(?:\d+|gatineau|quebec|québec)$", re.I)4546_ICONS = {47 "bed-double": "beds", "bath": "baths", "ruler": "sqft",48 "calendar": "availability", "map-pin": "location",49}505152def _norm(s: str) -> str:53 return re.sub(r"\s+", " ", (s or "").strip()).lower()545556class HalfredConnector(BaseConnector):57 source_id = "halfred"58 request_delay = 10.0 # robots.txt : Crawl-delay: 1059 max_details = 12 # fiches détail visitées par sync (hors cache)60 max_images = 126162 def __init__(self) -> None:63 super().__init__()64 # site bilingue : forcer le français (libellés et dates)65 self.session.headers["Accept-Language"] = "fr-CA,fr;q=0.9"66 self._detail_calls = 0 # appels réels (le cache BD ne compte pas)6768 # -- helpers ---------------------------------------------------------------69 @staticmethod70 def _external_id(href: str) -> str:71 """/listings/17-rue-de-liverpool/details -> '17-rue-de-liverpool' ;72 /listings/project/<uuid>/details -> 'project-<uuid>'."""73 m = re.match(r"^/listings/project/([0-9a-f-]{8,})/details$", href)74 if m:75 return f"project-{m.group(1)}"76 m = re.match(r"^/listings/([^/]+)/details$", href)77 return m.group(1) if m else ""7879 @staticmethod80 def _city_sector(raw: str) -> tuple[str, str]:81 """« Gatineau (Plateau) » -> ('Gatineau', 'Plateau') ;82 « Buckingham (Buckingham) » -> ('Gatineau', 'Buckingham') ;83 « Chelsea » -> ('Chelsea', '')."""84 txt = re.sub(r"\s+", " ", (raw or "").strip())85 m = re.match(r"^(.*?)\s*\(([^)]*)\)\s*$", txt)86 base = (m.group(1) if m else txt).strip()87 paren = (m.group(2) if m else "").strip()88 if _norm(base) in _GATINEAU_KEYS:89 if _norm(base) == "gatineau":90 sector = "" if _SECTOR_NOISE.match(paren) else paren91 else:92 sector = base93 return "Gatineau", sector94 sector = "" if (not paren or _norm(paren) == _norm(base)) else paren95 return base, sector9697 @staticmethod98 def _features(card) -> dict:99 """Attributs structurés de la carte, repérés par leur icône lucide."""100 out: dict[str, str] = {}101 for feat in card.select("[data-sentry-component='ListingCardFeature']"):102 icon = feat.find("span")103 classes = " ".join(icon.get("class") or []) if icon else ""104 key = ""105 for suffix, name in _ICONS.items():106 if f"icon-lucide-{suffix}" in classes:107 key = name108 break109 txt = re.sub(r"\s+", " ", feat.get_text(" ", strip=True)).strip()110 if key:111 out.setdefault(key, txt)112 elif "price" not in out and "$" in txt:113 # bloc prix : retirer l'ancien prix barré (promotion)114 clone = BeautifulSoup(str(feat), "html.parser")115 for old in clone.select(".line-through"):116 out.setdefault("price_regular",117 re.sub(r"\s+", " ",118 old.get_text(" ", strip=True)))119 old.decompose()120 out["price"] = re.sub(121 r"\s+", " ", clone.get_text(" ", strip=True)).strip()122 return out123124 @staticmethod125 def _unit_type(beds: str) -> str:126 """« 2 chambres » -> 4½ ; « studio - 1 chambres » -> Studio ; on retient127 la BORNE BASSE de la fourchette, cohérente avec le prix « à partir128 de » affiché sur la même carte."""129 low = re.split(r"\s*[-–]\s*", beds or "")[0].strip()130 if re.match(r"(?i)^studio", low):131 return "Studio"132 m = re.match(r"^(\d+)", low)133 if m:134 return normalize_unit_type(f"{m.group(1)} chambres")135 return normalize_unit_type(low)136137 @staticmethod138 def _area(sqft_txt: str) -> float | None:139 """« 1200 - 1260 pieds carrés » -> 1200 : borne BASSE de la fourchette140 (le prix de la carte est lui aussi « à partir de »)."""141 m = re.match(r"^\s*([\d\s,]{2,7})\s*[-–]", sqft_txt or "")142 if m:143 try:144 v = float(m.group(1).replace(" ", "").replace(",", ""))145 except ValueError:146 v = 0.0147 if 80 <= v <= 20000:148 return v149 return parse_area_sqft(sqft_txt)150151 def _html(self, url: str) -> str:152 """HTML d'une page : le serveur ne déclare pas de charset (« text/html »153 sans paramètre) — forcer UTF-8, sinon les accents sont mojibake."""154 resp = self.get(url)155 resp.encoding = "utf-8"156 return resp.text157158 def _abs(self, url: str) -> str:159 if not url:160 return ""161 if url.startswith("http"):162 return url163 return BASE + url if url.startswith("/") else f"{BASE}/{url}"164165 # -- fiche détail ----------------------------------------------------------166 def _fetch_detail(self, href: str) -> dict:167 """Description rédigée, galerie, scores de mobilité, contact."""168 payload: dict = {"description": "", "images": [], "contact": {},169 "scores": {}}170 html = self._html(self._abs(href))171 soup = BeautifulSoup(html, "html.parser")172173 h2 = soup.find("h2", string=re.compile(r"Description de l"))174 if h2 and h2.parent:175 txt = h2.parent.get_text("\n", strip=True)176 txt = re.sub(r"^Description de l['’]annonce\s*", "", txt)177 txt = re.sub(r"\n+", " ", txt)178 payload["description"] = re.sub(r"\s{2,}", " ", txt).strip()[:1500]179180 for img in soup.select("img[src*='/storage/v1/']"):181 src = self._abs(img.get("src", ""))182 if "/logo" in src or src in payload["images"]:183 continue184 payload["images"].append(src)185186 text = soup.get_text(" ", strip=True)187 m = re.search(r"([\w.+-]+@halfred\.ca)", text)188 if m:189 payload["contact"]["email"] = m.group(1)190 m = re.search(r"\((\d{3})\)\s*(\d{3})-(\d{4})", text)191 if m:192 payload["contact"]["phone"] = \193 f"{m.group(1)}-{m.group(2)}-{m.group(3)}"194 for label, key in (("Score de mobilité", "walk"),195 ("Score de vélo", "bike")):196 m = re.search(rf"{label}\s*(\d{{1,3}})", text)197 if m:198 payload["scores"][key] = int(m.group(1))199 return payload200201 # -- fetch -----------------------------------------------------------------202 def fetch(self) -> list[Listing]:203 soup = BeautifulSoup(self._html(LIST_URL), "html.parser")204 cards = soup.select("a[href^='/listings/'][href$='/details']")205206 listings: list[Listing] = []207 seen: set[str] = set()208 details_used = 0209 for card in cards:210 try:211 href = card.get("href", "")212 ext = self._external_id(href)213 if not ext or ext in seen:214 continue215 seen.add(ext)216217 h1 = card.select_one("h1")218 title = h1.get_text(" ", strip=True) if h1 else ""219 feats = self._features(card)220 city, sector = self._city_sector(feats.get("location", ""))221 # l'adresse civique n'est publiée que pour les immeubles dont222 # le titre EST l'adresse (les projets portent un nom)223 address = (f"{title}, {city}"224 if re.match(r"^\d", title) and city else "")225226 price_label = feats.get("price", "")227 availability = feats.get("availability", "")228 sqft_txt = feats.get("sqft", "")229 badges = [b.get_text(" ", strip=True)230 for b in card.select("span")231 if b.get_text(strip=True) in232 ("PROMO", "Dernières unités", "Nouveau")]233234 image = ""235 img = card.select_one("img[src*='/storage/v1/']")236 if img:237 image = self._abs(img.get("src", ""))238239 # fiche détail (cache BD ; clé = contenu de la carte)240 payload: dict = {}241 key = hashlib.sha1(242 re.sub(r"\s+", " ", card.get_text(" ", strip=True))243 .encode("utf-8")).hexdigest()[:20]244 if details_used < self.max_details:245 try:246 before = self._detail_calls247 payload = self.detail(ext, key,248 lambda h=href: self._detail_wrap(h))249 details_used += self._detail_calls - before250 except Exception:251 payload = {}252253 images = [image] if image else []254 for im in (payload.get("images") or []):255 if im not in images:256 images.append(im)257258 desc_bits = [x for x in [259 feats.get("beds", ""), feats.get("baths", ""),260 sqft_txt, " · ".join(badges),261 (f"Prix régulier {feats['price_regular']}"262 if feats.get("price_regular") else "")] if x]263 description = payload.get("description", "")264 if description:265 desc_bits.insert(0, description)266267 details: dict = {}268 if payload.get("contact"):269 details["contact"] = payload["contact"]270 for k, v in (payload.get("scores") or {}).items():271 details[f"{k}_score"] = v272273 listings.append(Listing(274 source=self.source_id,275 external_id=ext,276 url=self._abs(href),277 title=title,278 address=address,279 sector=sector,280 city=city,281 unit_type=self._unit_type(feats.get("beds", "")),282 price=parse_price(price_label),283 price_label=price_label,284 availability=availability,285 area_sqft=self._area(sqft_txt),286 description=" — ".join(desc_bits)[:1500],287 details=details,288 images=images[: self.max_images],289 ))290 except Exception:291 continue292 return listings293294 def _detail_wrap(self, href: str) -> dict:295 self._detail_calls += 1296 return self._fetch_detail(href)297