SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
12.7 KB · 297 lines python
Raw Blame History
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