SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.0 KB · 244 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/terater.py : connecteur Les Constructions Tèratèr (terater.ca)5#   Constructeur-locateur de l'Estrie : Val-Joli/Windsor, Sherbrooke (Nord,6#   Fleurimont, Seyval), Orford, Cookshire-Eaton. WordPress + thème enfant7#   Avada avec blocs maison (même famille de balisage que morin.py) : archive8#   /a-louer/ rendue serveur — cartes div.landing_modele_block_item complètes9#   (bandeau de disponibilité coloré, type « 3 ½ », adresse, secteur/ville,10#   chambres/salle de bain/étage/stationnement/animaux, prix « par mois »).11#   La fiche (via self.detail, cache BD) ajoute la superficie, la liste des12#   inclusions et la galerie photos. Les logements loués n'apparaissent pas13#   dans l'archive (bandeau « Loué » absent au moment de l'écriture) mais on14#   filtre par prudence.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://terater.ca"27LIST_URL = f"{BASE}/a-louer/"2829# variantes redimensionnées WordPress (-600x397.jpg) -> pleine taille30_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)3132# villes réelles desservies (jamais devinées) + secteurs de Sherbrooke connus33_KNOWN_CITIES = [34    "Sherbrooke", "Windsor", "Val-Joli", "Orford", "Cookshire-Eaton",35    "East Angus", "Magog", "Richmond", "Ascot Corner", "Saint-Denis-de-Brompton",36]37_SHERBROOKE_SECTORS = [38    "Fleurimont", "Rock Forest", "Lennoxville", "Mont-Bellevue",39    "Jacques-Cartier", "Brompton", "Saint-Élie", "St-Élie",40]4142# espaces non résidentiels dans le champ « type » de la carte43_NON_RESIDENTIAL_RE = re.compile(44    r"commercial|bureau|local|garage|entrep[oô]t|rangement", re.I)454647def _city_sector(raw: str) -> tuple[str, str]:48    """« Val-Joli » -> (Val-Joli, '') ; « Nord de Sherbrooke » ->49    (Sherbrooke, Nord de Sherbrooke) ; « Fleurimont » -> (Sherbrooke,50    Fleurimont). Ville vide si le secteur est inconnu (rien d'inventé)."""51    t = re.sub(r"\s+", " ", raw or "").strip()52    if not t:53        return "", ""54    for city in _KNOWN_CITIES:55        if t.lower() == city.lower():56            return city, ""57    if re.search(r"\bsherbrooke\b", t, re.I):58        return "Sherbrooke", t59    for sec in _SHERBROOKE_SECTORS:60        if t.lower() == sec.lower():61            return "Sherbrooke", t62    return "", t636465def _clean_unit_type(raw: str) -> str:66    """normalize_unit_type seulement si le motif est net (« 3 ½ », studio…)."""67    ut = normalize_unit_type(raw or "")68    if re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison", ut or ""):69        return ut70    return ""717273class TeraterConnector(BaseConnector):74    source_id = "terater"75    request_delay = 0.776    max_details = 30       # garde-fou fiches détail (vraies requêtes)7778    def fetch(self) -> list[Listing]:79        html = self.get(LIST_URL).text80        soup = BeautifulSoup(html, "html.parser")8182        listings: dict[str, Listing] = {}83        for card in soup.select("div.landing_modele_block_item"):84            try:85                self._parse_card(card, listings)86            except Exception:87                continue8889        # fiches détail (cache BD) : superficie, inclusions, galerie90        self._fetched = 091        for lst in listings.values():92            card_key = hashlib.sha1(93                f"{lst.title}|{lst.price_label}|{lst.availability}"94                .encode("utf-8")).hexdigest()95            try:96                payload = self.detail(lst.external_id, card_key,97                                      lambda u=lst.url: self._fetch_detail(u))98            except Exception:99                continue100            self._apply_detail(lst, payload)101        return list(listings.values())102103    # -- carte de l'archive /a-louer/ ------------------------------------------104    def _parse_card(self, card, listings: dict[str, Listing]) -> None:105        link = card.select_one("a.landing_modele_block_item_link[href]")106        if not link:107            return108        url = link["href"]109        m = re.search(r"/a-louer/([^/]+)/?", url)110        if not m:111            return112        ext_id = m.group(1)113        if ext_id in listings or "formulaire" in ext_id:114            return115116        # bandeau(x) de disponibilité : « Libre immédiatement », « Juillet117        # 2026 »… — on écarte tout logement marqué loué118        tags = [re.sub(r"\s+", " ", t.get_text(" ", strip=True)).strip()119                for t in card.select(".landing_modele_item_tag_item")]120        tags = [t for t in tags if t]121        if any(re.search(r"\blou[ée]s?\b", t, re.I) for t in tags):122            return123        availability = tags[0] if tags else ""124125        # type d'espace (« Appartement », « Maison »…) : écarter le non126        # résidentiel (locaux commerciaux, garages…)127        category = ""128        cat_el = card.select_one(".landing_modele_block_item_type")129        if cat_el:130            category = cat_el.get_text(" ", strip=True)131            if _NON_RESIDENTIAL_RE.search(category):132                return133134        piece_el = card.select_one(".landing_modele_piece")135        piece = piece_el.get_text(" ", strip=True) if piece_el else ""136137        addr_el = card.select_one(".landing_modele_block_item_adresse")138        address = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)).strip() \139            if addr_el else ""140141        sect_el = card.select_one(".landing_modele_block_item_secteur div")142        city, sector = _city_sector(143            sect_el.get_text(" ", strip=True) if sect_el else "")144145        # prix : « 1 075$ » + « par mois »146        price_el = card.select_one(".landing_modele_price")147        after_el = card.select_one(".landing_modele_price_after")148        price_label = ""149        if price_el:150            price_label = price_el.get_text(" ", strip=True)151            if after_el:152                price_label += " " + after_el.get_text(" ", strip=True)153154        # pictos : chambres / salle de bain / étage / stationnement / animaux155        amenities: list[str] = []156        for cls, label in [("landing_modele_item_bed", "Chambres"),157                           ("landing_modele_item_bath", "Salle(s) de bain"),158                           ("landing_modele_etage", "Étage"),159                           ("landing_modele_stationnement", "Stationnement")]:160            el = card.select_one(f".{cls} span")161            if el:162                val = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()163                if val:164                    amenities.append(f"{label} : {val}")165        animals_el = card.select_one(".landing_modele_animaux .animaux_note")166        if animals_el:167            note = animals_el.get_text(" ", strip=True)168            if note:169                amenities.append(note)   # « Animaux accepter » (texte du site)170171        # visuel de la carte (background-image)172        images: list[str] = []173        img_el = card.select_one(".landing_modele_block_item_img_bg[style]")174        if img_el:175            m_img = re.search(r"url\('([^']+)'\)", img_el.get("style") or "")176            if m_img:177                images = [_SIZE_SUFFIX.sub("", m_img.group(1))]178179        title = " ".join(p for p in (piece, category, "—", address) if p) \180            if address else (piece or category)181        details = {"category": category} if category else {}182183        listings[ext_id] = Listing(184            source=self.source_id,185            external_id=ext_id,186            url=url,187            title=re.sub(r"\s+", " ", title).strip(),188            address=address,189            sector=sector,190            city=city,191            unit_type=_clean_unit_type(piece),192            price=parse_price(price_label),193            price_label=price_label,194            availability=availability,195            amenities=amenities,196            details=details,197            images=images,198        )199200    # -- fiche détail -----------------------------------------------------------201    def _fetch_detail(self, url: str) -> dict:202        """Superficie, inclusions et galerie de la fiche single_modele_*."""203        if self._fetched >= self.max_details:204            raise RuntimeError("budget de fiches détail atteint")205        self._fetched += 1206        html = self.get(url).text207        soup = BeautifulSoup(html, "html.parser")208        out: dict = {}209210        sup_el = soup.select_one(".single_modele_superficie")211        if sup_el:212            out["superficie"] = re.sub(213                r"\s+", " ", sup_el.get_text(" ", strip=True)).strip()214215        out["inclusions"] = [216            re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()217            for el in soup.select(".single_modele_inclus_item_name")218            if el.get_text(strip=True)][:30]219220        # galerie : photos du bloc .single_modele_galerie seulement — les221        # blocs slider_modele_* en pied de page sont les AUTRES logements222        images: list[str] = []223        gal = soup.select_one(".single_modele_galerie")224        if gal:225            for u in re.findall(226                    r"https://terater\.ca/wp-content/uploads/[^\"'()\s]+"227                    r"\.(?:jpg|jpeg|webp)", str(gal), re.I):228                u = _SIZE_SUFFIX.sub("", u)229                if u not in images:230                    images.append(u)231        out["images"] = images[:30]232        return out233234    def _apply_detail(self, lst: Listing, d: dict) -> None:235        if not d:236            return237        if d.get("superficie"):     # « Superficie : 1375 pi² »238            lst.amenities = list(dict.fromkeys(lst.amenities + [d["superficie"]]))239        if d.get("inclusions"):240            lst.amenities = list(dict.fromkeys(lst.amenities + d["inclusions"]))241        if d.get("images"):242            merged = list(dict.fromkeys(d["images"] + lst.images))243            lst.images = merged[:30]244