SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.6 KB · 226 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/tri_logis.py : connecteur Société immobilière Tri-Logis inc.5#   (tri-logis.ca — Rouyn-Noranda, 600+ logements, référence n°1 en6#   Abitibi-Témiscamingue). Site custom statique, tout rendu serveur.7#   Liste /espaces-a-louer/logements : un bloc par immeuble (adresse, secteur,8#   proximité) ; seules les unités disponibles y ont une rangée (type, texte de9#   disponibilité, prix). Fiche unité /espaces-a-louer/immeuble/<imm>/<unité>10#   (via cache BD) : adresse complète avec code postal, description (bloc11#   « Apartment Features » : étage, inclusions, animaux…), galerie pleine12#   taille, inclusions. Périmètre : logements résidentiels seulement — les13#   pages /studios (meublés loués à la nuitée : « les prix des locations de14#   moins de 31 nuitées… ») et /chalets (court terme) sont exclues.15#   Pas de robots.txt (= tout permis).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price, strip_accents25from .base import BaseConnector2627BASE = "https://tri-logis.ca"28LIST_URL = f"{BASE}/espaces-a-louer/logements"2930# vignette redimensionnée « /_t282x186/ » ou « /_t600x407/ » -> pleine taille31_THUMB_RE = re.compile(r"/_t\d+x\d+/")323334def _pets_value(raw: str) -> str | None:35    """Ligne « Pets allowed: … » de la fiche -> oui/non/conditions."""36    k = strip_accents((raw or "").strip().lower())37    if not k:38        return None39    if re.search(r"\bno\b|not allowed|aucun|non\b", k):40        return "non"41    if re.search(r"cats and dogs|chats et chiens|\byes\b|allowed", k):42        return "oui"43    if re.search(r"cat|chat|dog|chien|small|petit", k):44        return "conditions"45    return None464748class TriLogisConnector(BaseConnector):49    source_id = "tri_logis"50    request_delay = 0.651    max_details = 40     # garde-fou fiches unité (vraies requêtes par sync)5253    def fetch(self) -> list[Listing]:54        html = self.get(LIST_URL).text55        soup = BeautifulSoup(html, "html.parser")5657        self._fetched = 058        listings: dict[str, Listing] = {}59        for bloc in soup.select(".search-results .result.immeuble"):60            try:61                self._parse_building(bloc, listings)62            except Exception:63                continue64        return list(listings.values())6566    # -- bloc immeuble (liste) ------------------------------------------------------67    def _parse_building(self, bloc, listings: dict[str, Listing]) -> None:68        h3 = bloc.select_one(".result-details h3")69        building = h3.get_text(strip=True) if h3 else ""7071        sector_el = bloc.select_one(".result-details p strong")72        sector = sector_el.get_text(strip=True) if sector_el else ""7374        # « Proximité : IGA Roy, École…, parc » -> commodités de l'immeuble75        proximity = ""76        for h5 in bloc.select(".result-details h5"):77            if "proximit" in strip_accents(h5.get_text(strip=True).lower()):78                p = h5.find_next_sibling("p")79                if p:80                    proximity = re.sub(r"\s+", " ", p.get_text(" ", strip=True))81                break8283        # rangées d'unités disponibles (absentes quand 0 espace à louer)84        for a in bloc.select("a.apartment-details[href]"):85            url = a["href"]86            m = re.search(r"/espaces-a-louer/immeuble/([^/]+)/([^/?#]+)", url)87            if not m:88                continue89            ext_id = f"{m.group(1)}--{m.group(2)}"90            if ext_id in listings:91                continue9293            type_el = a.find("h4")94            unit_label = type_el.get_text(strip=True) if type_el else ""9596            # texte de disponibilité (« Disponible dès maintenant », « Disponible97            # le 24 juil. 2024 »…) — sans le bouton « Planifier une visite »98            availability = ""99            p = a.find("p")100            if p:101                txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))102                m_av = re.search(r"(Disponible[^|]*?)(?:Planifier|$)", txt, re.I)103                if m_av:104                    availability = m_av.group(1).strip()105106            # prix : <div class="price"><div>1,160 $</div><div>mois</div></div>107            price_label = ""108            price_el = a.select_one(".price")109            if price_el:110                price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True))111            price = parse_price(re.sub(r"(\d),(\d{3})", r"\1\2", price_label))112113            # inclusions annoncées sur la carte (« Inclus dans le prix » + liste)114            amenities: list[str] = []115            for h5 in a.find_all("h5"):116                if "inclus" in strip_accents(h5.get_text(strip=True).lower()):117                    sib = h5.find_next_sibling("p")118                    if sib:119                        t = re.sub(r"\s+", " ", sib.get_text(" ", strip=True))120                        if t:121                            amenities.append(t)122            if proximity:123                amenities.append(f"À proximité : {proximity}")124125            images = [img["src"] for img in a.select("img.img-responsive[src]")126                      if img["src"].startswith("http")]127128            lst = Listing(129                source=self.source_id,130                external_id=ext_id,131                url=url,132                title=f"{building}{unit_label}".strip(" —"),133                address=building,             # affinée par la fiche unité134                sector=sector,135                city="Rouyn-Noranda",136                unit_type=normalize_unit_type(unit_label),137                price=price,138                price_label=price_label,139                availability=availability,140                amenities=amenities,141                details={"building": building},142                images=[_THUMB_RE.sub("/", u) for u in images],143            )144145            key = hashlib.sha1(146                f"{unit_label}|{price_label}|{availability}".encode("utf-8")147            ).hexdigest()148            try:149                payload = self.detail(ext_id, key,150                                      lambda u=url: self._fetch_detail(u))151                self._apply_detail(lst, payload)152            except Exception:153                pass154            listings[ext_id] = lst155156    # -- fiche unité ------------------------------------------------------------------157    def _fetch_detail(self, url: str) -> dict:158        """Adresse complète, description (« Apartment Features »), inclusions,159        galerie pleine taille."""160        if self._fetched >= self.max_details:161            raise RuntimeError("budget de fiches unité atteint")162        self._fetched += 1163        html = self.get(url).text164        soup = BeautifulSoup(html, "html.parser")165        out: dict = {}166167        details_bloc = soup.select_one(".specsheet .details")168        if details_bloc:169            # adresse civique complète : « 992 Av. Larivière, Rouyn-Noranda,170            # QC J9X 4K5 » (premier <p> contenant la ville)171            for p in details_bloc.find_all("p"):172                t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))173                if re.search(r"rouyn|noranda|évain|evain|,\s*QC", t, re.I) \174                        and len(t) < 120 and not t.lower().startswith("address:"):175                    out["address"] = t176                    break177178            # description libre (bloc anglais « Address / Availability /179            # Apartment Features / Pets allowed… ») — champs bruts fidèles180            best = ""181            for p in details_bloc.find_all("p"):182                t = p.get_text("\n", strip=True)183                if len(t) > len(best):184                    best = t185            if len(best) > 60:186                out["description"] = re.sub(r"\n{2,}", "\n",187                                            re.sub(r"[ \t]+", " ", best))[:1500]188189            # galerie pleine taille (liens slick-colorbox)190            out["images"] = list(dict.fromkeys(191                a["href"] for a in details_bloc.select("a.slick-colorbox[href]")192                if a["href"].startswith("http")))[:25]193194        # « Inclus dans le prix » du panneau latéral195        incl: list[str] = []196        for h5 in soup.select(".brown-panel h5"):197            if "inclus" in strip_accents(h5.get_text(strip=True).lower()):198                for sib in h5.find_next_siblings():199                    if sib.name not in ("p", "ul", "li"):200                        break                     # fin de la section (bouton…)201                    for t in re.split(r"\s*[,;]\s*",202                                      sib.get_text(" ", strip=True)):203                        t = re.sub(r"\s+", " ", t).strip()204                        if t and t not in incl:205                            incl.append(t)206        out["included"] = incl[:10]207        return out208209    def _apply_detail(self, lst: Listing, d: dict) -> None:210        """Reporte le payload (frais ou en cache) sur l'annonce."""211        if not d:212            return213        if d.get("address"):214            lst.address = d["address"]215        if d.get("description"):216            lst.description = d["description"]217            m = re.search(r"Pets allowed\s*:\s*([^\n]+)", d["description"], re.I)218            if m:219                pets = _pets_value(m.group(1))220                if pets:221                    lst.pets = pets222        if d.get("included"):223            lst.amenities = list(dict.fromkeys(lst.amenities + d["included"]))224        if d.get("images"):225            lst.images = d["images"]226