# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/tri_logis.py : connecteur Société immobilière Tri-Logis inc. # (tri-logis.ca — Rouyn-Noranda, 600+ logements, référence n°1 en # Abitibi-Témiscamingue). Site custom statique, tout rendu serveur. # Liste /espaces-a-louer/logements : un bloc par immeuble (adresse, secteur, # proximité) ; seules les unités disponibles y ont une rangée (type, texte de # disponibilité, prix). Fiche unité /espaces-a-louer/immeuble// # (via cache BD) : adresse complète avec code postal, description (bloc # « Apartment Features » : étage, inclusions, animaux…), galerie pleine # taille, inclusions. Périmètre : logements résidentiels seulement — les # pages /studios (meublés loués à la nuitée : « les prix des locations de # moins de 31 nuitées… ») et /chalets (court terme) sont exclues. # Pas de robots.txt (= tout permis). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://tri-logis.ca" LIST_URL = f"{BASE}/espaces-a-louer/logements" # vignette redimensionnée « /_t282x186/ » ou « /_t600x407/ » -> pleine taille _THUMB_RE = re.compile(r"/_t\d+x\d+/") def _pets_value(raw: str) -> str | None: """Ligne « Pets allowed: … » de la fiche -> oui/non/conditions.""" k = strip_accents((raw or "").strip().lower()) if not k: return None if re.search(r"\bno\b|not allowed|aucun|non\b", k): return "non" if re.search(r"cats and dogs|chats et chiens|\byes\b|allowed", k): return "oui" if re.search(r"cat|chat|dog|chien|small|petit", k): return "conditions" return None class TriLogisConnector(BaseConnector): source_id = "tri_logis" request_delay = 0.6 max_details = 40 # garde-fou fiches unité (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") self._fetched = 0 listings: dict[str, Listing] = {} for bloc in soup.select(".search-results .result.immeuble"): try: self._parse_building(bloc, listings) except Exception: continue return list(listings.values()) # -- bloc immeuble (liste) ------------------------------------------------------ def _parse_building(self, bloc, listings: dict[str, Listing]) -> None: h3 = bloc.select_one(".result-details h3") building = h3.get_text(strip=True) if h3 else "" sector_el = bloc.select_one(".result-details p strong") sector = sector_el.get_text(strip=True) if sector_el else "" # « Proximité : IGA Roy, École…, parc » -> commodités de l'immeuble proximity = "" for h5 in bloc.select(".result-details h5"): if "proximit" in strip_accents(h5.get_text(strip=True).lower()): p = h5.find_next_sibling("p") if p: proximity = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) break # rangées d'unités disponibles (absentes quand 0 espace à louer) for a in bloc.select("a.apartment-details[href]"): url = a["href"] m = re.search(r"/espaces-a-louer/immeuble/([^/]+)/([^/?#]+)", url) if not m: continue ext_id = f"{m.group(1)}--{m.group(2)}" if ext_id in listings: continue type_el = a.find("h4") unit_label = type_el.get_text(strip=True) if type_el else "" # texte de disponibilité (« Disponible dès maintenant », « Disponible # le 24 juil. 2024 »…) — sans le bouton « Planifier une visite » availability = "" p = a.find("p") if p: txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) m_av = re.search(r"(Disponible[^|]*?)(?:Planifier|$)", txt, re.I) if m_av: availability = m_av.group(1).strip() # prix :
1,160 $
mois
price_label = "" price_el = a.select_one(".price") if price_el: price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) price = parse_price(re.sub(r"(\d),(\d{3})", r"\1\2", price_label)) # inclusions annoncées sur la carte (« Inclus dans le prix » + liste) amenities: list[str] = [] for h5 in a.find_all("h5"): if "inclus" in strip_accents(h5.get_text(strip=True).lower()): sib = h5.find_next_sibling("p") if sib: t = re.sub(r"\s+", " ", sib.get_text(" ", strip=True)) if t: amenities.append(t) if proximity: amenities.append(f"À proximité : {proximity}") images = [img["src"] for img in a.select("img.img-responsive[src]") if img["src"].startswith("http")] lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{building} — {unit_label}".strip(" —"), address=building, # affinée par la fiche unité sector=sector, city="Rouyn-Noranda", unit_type=normalize_unit_type(unit_label), price=price, price_label=price_label, availability=availability, amenities=amenities, details={"building": building}, images=[_THUMB_RE.sub("/", u) for u in images], ) key = hashlib.sha1( f"{unit_label}|{price_label}|{availability}".encode("utf-8") ).hexdigest() try: payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u)) self._apply_detail(lst, payload) except Exception: pass listings[ext_id] = lst # -- fiche unité ------------------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: """Adresse complète, description (« Apartment Features »), inclusions, galerie pleine taille.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches unité atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} details_bloc = soup.select_one(".specsheet .details") if details_bloc: # adresse civique complète : « 992 Av. Larivière, Rouyn-Noranda, # QC J9X 4K5 » (premier

contenant la ville) for p in details_bloc.find_all("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if re.search(r"rouyn|noranda|évain|evain|,\s*QC", t, re.I) \ and len(t) < 120 and not t.lower().startswith("address:"): out["address"] = t break # description libre (bloc anglais « Address / Availability / # Apartment Features / Pets allowed… ») — champs bruts fidèles best = "" for p in details_bloc.find_all("p"): t = p.get_text("\n", strip=True) if len(t) > len(best): best = t if len(best) > 60: out["description"] = re.sub(r"\n{2,}", "\n", re.sub(r"[ \t]+", " ", best))[:1500] # galerie pleine taille (liens slick-colorbox) out["images"] = list(dict.fromkeys( a["href"] for a in details_bloc.select("a.slick-colorbox[href]") if a["href"].startswith("http")))[:25] # « Inclus dans le prix » du panneau latéral incl: list[str] = [] for h5 in soup.select(".brown-panel h5"): if "inclus" in strip_accents(h5.get_text(strip=True).lower()): for sib in h5.find_next_siblings(): if sib.name not in ("p", "ul", "li"): break # fin de la section (bouton…) for t in re.split(r"\s*[,;]\s*", sib.get_text(" ", strip=True)): t = re.sub(r"\s+", " ", t).strip() if t and t not in incl: incl.append(t) out["included"] = incl[:10] return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return if d.get("address"): lst.address = d["address"] if d.get("description"): lst.description = d["description"] m = re.search(r"Pets allowed\s*:\s*([^\n]+)", d["description"], re.I) if m: pets = _pets_value(m.group(1)) if pets: lst.pets = pets if d.get("included"): lst.amenities = list(dict.fromkeys(lst.amenities + d["included"])) if d.get("images"): lst.images = d["images"]