# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/halfred.py : connecteur Halfred (halfred.ca — Outaouais) # Application React Router rendue CÔTÉ SERVEUR : /listings redirige vers # /listings/list dont le HTML contient déjà toutes les cartes d'annonces # (aucun JavaScript requis). Chaque carte porte, en éléments structurés : # - le lien /listings//details (ou /listings/project//details # pour les projets locatifs) -> external_id stable ; # - l'adresse civique (h1), le secteur « Gatineau (Hull) » (icône map-pin), # le nombre de chambres, de salles de bain, la superficie, la date de # disponibilité (icônes lucide) et le prix (« À partir de 1 650$ », # ancien prix barré quand il y a une promotion). # La fiche /details ajoute la description longue rédigée par l'agence, la # galerie de photos, les scores de mobilité/vélo et le contact du bureau. # Elle passe par self.detail(...) (cache BD) avec un plafond par sync : le # robots.txt du site impose « Crawl-delay: 10 » — request_delay = 10 s. # Le site est bilingue : en-tête Accept-Language fr-CA pour obtenir le # français (« 2 chambres », « Disponible maintenant »). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import (Listing, normalize_unit_type, parse_area_sqft, parse_price) from .base import BaseConnector BASE = "https://www.halfred.ca" LIST_URL = f"{BASE}/listings/list" # Villes/secteurs de l'agglomération de Gatineau (fusion 2002) : la ville # affichée reste « Gatineau », le nom d'origine devient le secteur. _GATINEAU_KEYS = { "gatineau", "hull", "aylmer", "buckingham", "angers", "masson-angers", "templeton", "east templeton", "touraine", "pointe-gatineau", "old gatineau", "plateau", "mont-bleu", "limbour", "val-tetreau", "east buckingham", } # libellés de secteur sans valeur informative (numéros, doublon de la ville) _SECTOR_NOISE = re.compile(r"^(?:\d+|gatineau|quebec|québec)$", re.I) _ICONS = { "bed-double": "beds", "bath": "baths", "ruler": "sqft", "calendar": "availability", "map-pin": "location", } def _norm(s: str) -> str: return re.sub(r"\s+", " ", (s or "").strip()).lower() class HalfredConnector(BaseConnector): source_id = "halfred" request_delay = 10.0 # robots.txt : Crawl-delay: 10 max_details = 12 # fiches détail visitées par sync (hors cache) max_images = 12 def __init__(self) -> None: super().__init__() # site bilingue : forcer le français (libellés et dates) self.session.headers["Accept-Language"] = "fr-CA,fr;q=0.9" self._detail_calls = 0 # appels réels (le cache BD ne compte pas) # -- helpers --------------------------------------------------------------- @staticmethod def _external_id(href: str) -> str: """/listings/17-rue-de-liverpool/details -> '17-rue-de-liverpool' ; /listings/project//details -> 'project-'.""" m = re.match(r"^/listings/project/([0-9a-f-]{8,})/details$", href) if m: return f"project-{m.group(1)}" m = re.match(r"^/listings/([^/]+)/details$", href) return m.group(1) if m else "" @staticmethod def _city_sector(raw: str) -> tuple[str, str]: """« Gatineau (Plateau) » -> ('Gatineau', 'Plateau') ; « Buckingham (Buckingham) » -> ('Gatineau', 'Buckingham') ; « Chelsea » -> ('Chelsea', '').""" txt = re.sub(r"\s+", " ", (raw or "").strip()) m = re.match(r"^(.*?)\s*\(([^)]*)\)\s*$", txt) base = (m.group(1) if m else txt).strip() paren = (m.group(2) if m else "").strip() if _norm(base) in _GATINEAU_KEYS: if _norm(base) == "gatineau": sector = "" if _SECTOR_NOISE.match(paren) else paren else: sector = base return "Gatineau", sector sector = "" if (not paren or _norm(paren) == _norm(base)) else paren return base, sector @staticmethod def _features(card) -> dict: """Attributs structurés de la carte, repérés par leur icône lucide.""" out: dict[str, str] = {} for feat in card.select("[data-sentry-component='ListingCardFeature']"): icon = feat.find("span") classes = " ".join(icon.get("class") or []) if icon else "" key = "" for suffix, name in _ICONS.items(): if f"icon-lucide-{suffix}" in classes: key = name break txt = re.sub(r"\s+", " ", feat.get_text(" ", strip=True)).strip() if key: out.setdefault(key, txt) elif "price" not in out and "$" in txt: # bloc prix : retirer l'ancien prix barré (promotion) clone = BeautifulSoup(str(feat), "html.parser") for old in clone.select(".line-through"): out.setdefault("price_regular", re.sub(r"\s+", " ", old.get_text(" ", strip=True))) old.decompose() out["price"] = re.sub( r"\s+", " ", clone.get_text(" ", strip=True)).strip() return out @staticmethod def _unit_type(beds: str) -> str: """« 2 chambres » -> 4½ ; « studio - 1 chambres » -> Studio ; on retient la BORNE BASSE de la fourchette, cohérente avec le prix « à partir de » affiché sur la même carte.""" low = re.split(r"\s*[-–]\s*", beds or "")[0].strip() if re.match(r"(?i)^studio", low): return "Studio" m = re.match(r"^(\d+)", low) if m: return normalize_unit_type(f"{m.group(1)} chambres") return normalize_unit_type(low) @staticmethod def _area(sqft_txt: str) -> float | None: """« 1200 - 1260 pieds carrés » -> 1200 : borne BASSE de la fourchette (le prix de la carte est lui aussi « à partir de »).""" m = re.match(r"^\s*([\d\s,]{2,7})\s*[-–]", sqft_txt or "") if m: try: v = float(m.group(1).replace(" ", "").replace(",", "")) except ValueError: v = 0.0 if 80 <= v <= 20000: return v return parse_area_sqft(sqft_txt) def _html(self, url: str) -> str: """HTML d'une page : le serveur ne déclare pas de charset (« text/html » sans paramètre) — forcer UTF-8, sinon les accents sont mojibake.""" resp = self.get(url) resp.encoding = "utf-8" return resp.text def _abs(self, url: str) -> str: if not url: return "" if url.startswith("http"): return url return BASE + url if url.startswith("/") else f"{BASE}/{url}" # -- fiche détail ---------------------------------------------------------- def _fetch_detail(self, href: str) -> dict: """Description rédigée, galerie, scores de mobilité, contact.""" payload: dict = {"description": "", "images": [], "contact": {}, "scores": {}} html = self._html(self._abs(href)) soup = BeautifulSoup(html, "html.parser") h2 = soup.find("h2", string=re.compile(r"Description de l")) if h2 and h2.parent: txt = h2.parent.get_text("\n", strip=True) txt = re.sub(r"^Description de l['’]annonce\s*", "", txt) txt = re.sub(r"\n+", " ", txt) payload["description"] = re.sub(r"\s{2,}", " ", txt).strip()[:1500] for img in soup.select("img[src*='/storage/v1/']"): src = self._abs(img.get("src", "")) if "/logo" in src or src in payload["images"]: continue payload["images"].append(src) text = soup.get_text(" ", strip=True) m = re.search(r"([\w.+-]+@halfred\.ca)", text) if m: payload["contact"]["email"] = m.group(1) m = re.search(r"\((\d{3})\)\s*(\d{3})-(\d{4})", text) if m: payload["contact"]["phone"] = \ f"{m.group(1)}-{m.group(2)}-{m.group(3)}" for label, key in (("Score de mobilité", "walk"), ("Score de vélo", "bike")): m = re.search(rf"{label}\s*(\d{{1,3}})", text) if m: payload["scores"][key] = int(m.group(1)) return payload # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: soup = BeautifulSoup(self._html(LIST_URL), "html.parser") cards = soup.select("a[href^='/listings/'][href$='/details']") listings: list[Listing] = [] seen: set[str] = set() details_used = 0 for card in cards: try: href = card.get("href", "") ext = self._external_id(href) if not ext or ext in seen: continue seen.add(ext) h1 = card.select_one("h1") title = h1.get_text(" ", strip=True) if h1 else "" feats = self._features(card) city, sector = self._city_sector(feats.get("location", "")) # l'adresse civique n'est publiée que pour les immeubles dont # le titre EST l'adresse (les projets portent un nom) address = (f"{title}, {city}" if re.match(r"^\d", title) and city else "") price_label = feats.get("price", "") availability = feats.get("availability", "") sqft_txt = feats.get("sqft", "") badges = [b.get_text(" ", strip=True) for b in card.select("span") if b.get_text(strip=True) in ("PROMO", "Dernières unités", "Nouveau")] image = "" img = card.select_one("img[src*='/storage/v1/']") if img: image = self._abs(img.get("src", "")) # fiche détail (cache BD ; clé = contenu de la carte) payload: dict = {} key = hashlib.sha1( re.sub(r"\s+", " ", card.get_text(" ", strip=True)) .encode("utf-8")).hexdigest()[:20] if details_used < self.max_details: try: before = self._detail_calls payload = self.detail(ext, key, lambda h=href: self._detail_wrap(h)) details_used += self._detail_calls - before except Exception: payload = {} images = [image] if image else [] for im in (payload.get("images") or []): if im not in images: images.append(im) desc_bits = [x for x in [ feats.get("beds", ""), feats.get("baths", ""), sqft_txt, " · ".join(badges), (f"Prix régulier {feats['price_regular']}" if feats.get("price_regular") else "")] if x] description = payload.get("description", "") if description: desc_bits.insert(0, description) details: dict = {} if payload.get("contact"): details["contact"] = payload["contact"] for k, v in (payload.get("scores") or {}).items(): details[f"{k}_score"] = v listings.append(Listing( source=self.source_id, external_id=ext, url=self._abs(href), title=title, address=address, sector=sector, city=city, unit_type=self._unit_type(feats.get("beds", "")), price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=self._area(sqft_txt), description=" — ".join(desc_bits)[:1500], details=details, images=images[: self.max_images], )) except Exception: continue return listings def _detail_wrap(self, href: str) -> dict: self._detail_calls += 1 return self._fetch_detail(href)