spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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