SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.7 KB · 181 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/quartier_oree.py : connecteur Quartier Orée (quartieroree.com)5#   Route Chassé, Sainte-Marie (Chaudière-Appalaches) — 4 immeubles locatifs6#   neufs (Orée-A à Orée-D, 279 unités au total).7#   Site WordPress + FacetWP : la page /plans-et-prix/ est rendue CÔTÉ SERVEUR8#   et ne contient QUE les unités disponibles (les facettes FacetWP9#   « disponibilite / loyer / batiment » ne servent qu'au filtrage client) :10#     - carte `.unite` : n° d'unité, loyer mensuel, superficie, nombre de11#       chambres, immeuble (Orée-A…D) et disponibilité (« Immédiate »,12#       « Juillet 2026 ») ;13#     - lien « Plan de l'unité » (PDF) ;14#     - lien « Demande d'info » portant les attributs structurés15#       unite="Unité 101" et batiment="Orée-B".16#   L'adresse civique de chaque immeuble est publiée sur la même page17#   (`div.infos.OREE-B` -> « 485 Route Chassé, Ste-Marie-de-Beauce… »), et les18#   commodités du projet sur l'accueil (`.box-icone`).19#   ⚠ Cloudflare renvoie 403 aux robots sans en-tête de navigateur : le20#   User-Agent de base.py (Chrome + LouKaBot) passe. robots.txt autorise21#   /plans-et-prix/ (seuls /unite/* et /batiment/* sont interdits).22# -----------------------------------------------------------------------------23from __future__ import annotations2425import re2627from bs4 import BeautifulSoup2829from ..schema import Listing, normalize_unit_type, parse_area_sqft, parse_price30from .base import BaseConnector3132BASE = "https://quartieroree.com"33PLANS_URL = f"{BASE}/plans-et-prix/"34CITY = "Sainte-Marie"3536_PRICE_RE = re.compile(r"[\d\s]+\$\s*/\s*mois")37_AREA_RE = re.compile(r"[\d\s]+pi[²2]")38_ROOMS_RE = re.compile(r"(?i)\bstudio\b|\d+\s*chambres?")39_BUILDING_RE = re.compile(r"(?i)or[ée]e[\s-]*[A-D]\b")40_IMG_RE = re.compile(41    r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+'42    r'\.(?:jpg|jpeg|png|webp)', re.I)43_SKIP_IMG = re.compile(44    r"logo|icon|favicon|gdpr|plan-prix-bg|-\d{2,4}x\d{2,4}\.", re.I)454647def _slug(txt: str) -> str:48    """« Orée-B » -> « oree-b »."""49    s = txt.lower().replace("é", "e").replace("è", "e")50    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")515253class QuartierOreeConnector(BaseConnector):54    source_id = "quartier_oree"55    request_delay = 0.656    max_images = 105758    # -- helpers ---------------------------------------------------------------59    @staticmethod60    def _addresses(soup) -> dict[str, str]:61        """Adresse civique par immeuble : {'oree-b': '485 Route Chassé, …'}."""62        out: dict[str, str] = {}63        for div in soup.select("div.infos"):64            classes = [c for c in (div.get("class") or [])65                       if re.match(r"(?i)^oree-[a-d]$", c)]66            if not classes:67                continue68            txt = re.sub(r"\s+", " ",69                         div.get_text(", ", strip=True)).strip(" ,")70            txt = re.sub(r"(?i)\s*,?\s*Canada\s*$", "", txt)71            txt = re.sub(r"\s*,\s*", ", ", txt)72            if txt:73                out[_slug(classes[0])] = txt74        return out7576    def _project_pages(self) -> tuple[list[str], list[str]]:77        """Commodités (accueil, `.box-icone`) et photos du projet."""78        amenities: list[str] = []79        images: list[str] = []80        try:81            html = self.get(f"{BASE}/").text82        except Exception:83            return amenities, images84        soup = BeautifulSoup(html, "html.parser")85        for el in soup.select(".box-icone h4, .box-icone h3"):86            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()87            if 3 <= len(t) <= 90 and t not in amenities:88                amenities.append(t)89        for u in dict.fromkeys(_IMG_RE.findall(html)):90            if not _SKIP_IMG.search(u) and u not in images:91                images.append(u)92        return amenities[:30], images[: self.max_images]9394    # -- fetch -----------------------------------------------------------------95    def fetch(self) -> list[Listing]:96        soup = BeautifulSoup(self.get(PLANS_URL).text, "html.parser")97        addresses = self._addresses(soup)98        amenities, images = self._project_pages()99100        listings: list[Listing] = []101        for card in soup.select(".unite"):102            try:103                h2 = card.select_one("h2")104                unit_label = h2.get_text(" ", strip=True) if h2 else ""105                info = card.select_one(".infos")106                if not (unit_label and info):107                    continue108                info_txt = re.sub(r"\s+", " ", info.get_text(" · ", strip=True))109110                # lien « Demande d'info » : n° d'unité et immeuble structurés111                nav = card.select_one("a.nav-info")112                building = (nav.get("batiment") if nav else "") or ""113                if not building:114                    m = _BUILDING_RE.search(info_txt)115                    building = m.group(0) if m else ""116                num = re.sub(r"(?i)^unit[ée]\s*", "",117                             (nav.get("unite") if nav else unit_label)).strip()118                if not num:119                    continue120121                m = _PRICE_RE.search(info_txt)122                price_label = (re.sub(r"\s*/\s*", "/", m.group(0)).strip()123                               if m else "")124                m = _AREA_RE.search(info_txt)125                area_txt = m.group(0).strip() if m else ""126                m = _ROOMS_RE.search(info_txt)127                rooms_txt = m.group(0).strip() if m else ""128129                # disponibilité : dernier segment de la carte (après l'immeuble)130                segs = [s.strip() for s in info_txt.split("·") if s.strip()]131                availability = ""132                for seg in reversed(segs):133                    if seg in (price_label, area_txt, rooms_txt):134                        continue135                    if building and _slug(seg) == _slug(building):136                        continue137                    availability = seg138                    break139140                plan_pdf = ""141                for a in card.select("a[href$='.pdf']"):142                    plan_pdf = a.get("href", "")143                    break144145                b_slug = _slug(building) if building else ""146                ext = f"{b_slug}-{num}" if b_slug else num147                desc = " — ".join(x for x in [148                    rooms_txt, area_txt, building] if x)149150                details: dict = {}151                if plan_pdf.startswith("http"):152                    details["plan_pdf"] = plan_pdf153154                listings.append(Listing(155                    source=self.source_id,156                    external_id=ext,157                    url=f"{PLANS_URL}#{ext}",158                    title=(f"Quartier Orée {re.sub(r'(?i)^or[ée]e[\\s-]*', '', building)}"159                           f" — Unité {num}" if building160                           else f"Quartier Orée — Unité {num}"),161                    address=addresses.get(b_slug, ""),162                    sector="",163                    city=CITY,164                    unit_type=normalize_unit_type(rooms_txt),165                    price=parse_price(price_label),166                    price_label=price_label,167                    availability=availability,168                    area_sqft=parse_area_sqft(area_txt),169                    description=desc[:600],170                    amenities=list(amenities),171                    details=details,172                    images=list(images),173                ))174            except Exception:175                continue176177        uniq: dict[str, Listing] = {}178        for lst in listings:179            uniq.setdefault(lst.external_id, lst)180        return list(uniq.values())181