# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/roomies.py : Roomies.ca — chambres et colocation (segment que les # gestionnaires ne couvrent pas). Site Laravel/Livewire server-rendered, # robots.txt entièrement ouvert, aucun anti-bot (curl simple = 200). # Découverte par les pages villes `/{ville}-qc?page=N` (les sitemaps # `sitemap/rooms.xml` répondent 504 par intermittence — non fiables). # Fiche `/rooms/` : JSON-LD `Room` complet (offers.price mensuel, # availabilityStarts, lat/lng, petsAllowed, chambres/SDB de l'appartement, # PostalAddress, amenityFeature) + description intégrale dans un div # `prose` + galerie Cloudinary. On ignore les profils de chercheurs de # colocataires (/profiles) : seules les chambres offertes sont des annonces. # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import os import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector from . import _detailutil as du BASE = "https://www.roomies.ca" # villes québécoises servies (slug -qc) ; une page absente (404) est ignorée CITIES = { "montreal-qc": "Montréal", "quebec-qc": "Québec", "laval-qc": "Laval", "gatineau-qc": "Gatineau", "longueuil-qc": "Longueuil", "sherbrooke-qc": "Sherbrooke", "trois-rivieres-qc": "Trois-Rivières", "levis-qc": "Lévis", "brossard-qc": "Brossard", "terrebonne-qc": "Terrebonne", "repentigny-qc": "Repentigny", "saint-jean-sur-richelieu-qc": "Saint-Jean-sur-Richelieu", "drummondville-qc": "Drummondville", "granby-qc": "Granby", "saguenay-qc": "Saguenay", } MAX_PAGES = int(os.environ.get("LOUKA_ROOMIES_MAX_PAGES", "20")) DETAIL_LIMIT = int(os.environ.get("LOUKA_ROOMIES_DETAIL_LIMIT", "250")) TTL_DAYS = float(os.environ.get("LOUKA_ROOMIES_TTL_DAYS", "5")) _ROOM_LINK_RE = re.compile(r'href="(?:https://www\.roomies\.ca)?(/rooms/(\d+))"') _PHOTO_RE = re.compile( r'https://cloudinary\.roomies\.pics/image/upload/[^"\\\s]+?/(\w+)') def _parse_fiche(html: str) -> dict: out: dict = {} for node in du.ld_nodes(html): if node.get("@type") != "Room": continue name = str(node.get("name") or "") out["type_label"] = name.split("|")[0].strip() # « Furnished room in… » addr = node.get("address") or {} out["address"] = (addr.get("streetAddress") or "").strip() out["postal_code"] = (addr.get("postalCode") or "").strip() out["lat"] = node.get("latitude") out["lng"] = node.get("longitude") if node.get("petsAllowed") is not None: out["pets"] = "oui" if node["petsAllowed"] else "non" details = {} if node.get("numberOfBedrooms"): details["Chambres (logement)"] = str(node["numberOfBedrooms"]) if node.get("numberOfBathroomsTotal"): details["Salles de bain"] = str(node["numberOfBathroomsTotal"]) if details: out["details"] = details feats = node.get("amenityFeature") or [] amenities = [] for f in feats: if isinstance(f, dict) and f.get("name") and f.get("value") is not False: amenities.append(str(f["name"])) if f["name"] == "Furnished": out["furnished"] = True if amenities: out["amenities"] = amenities offers = node.get("offers") or {} try: out["price"] = float(offers.get("price")) except (TypeError, ValueError): pass starts = str(offers.get("availabilityStarts") or "")[:10] if starts: out["availability_date"] = ("now" if starts <= datetime.date.today().isoformat() else starts) if node.get("description"): out["description"] = str(node["description"]).strip()[:6000] # description intégrale (le JSON-LD est tronqué à ~250 caractères) soup = BeautifulSoup(html, "html.parser") prose = soup.find("div", class_=lambda c: c and "prose" in c and "break-words" in c) if prose: full = prose.get_text("\n", strip=True) if len(full) > len(out.get("description") or ""): out["description"] = full[:6000] # galerie Cloudinary : dédupliquée par identifiant public, format standard photos, seen = [], set() for pid in _PHOTO_RE.findall(html): if pid in seen or len(pid) < 12: continue seen.add(pid) photos.append("https://cloudinary.roomies.pics/image/upload/" f"c_fill,f_jpg,fl_lossy,g_auto,h_512,q_auto:good,w_896/{pid}") if photos: out["images"] = photos return out class RoomiesConnector(BaseConnector): source_id = "roomies" request_delay = 0.8 def _city_room_ids(self, slug: str) -> list[str]: ids, page = [], 1 while page <= MAX_PAGES: url = f"{BASE}/{slug}" + (f"?page={page}" if page > 1 else "") try: html = self.get(url).text except Exception: break found = [rid for _, rid in _ROOM_LINK_RE.findall(html)] fresh = [r for r in found if r not in ids] if not fresh: break ids.extend(fresh) page += 1 return ids def fetch(self) -> list[Listing]: cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS) out: dict[str, Listing] = {} try: for slug, city in CITIES.items(): for rid in self._city_room_ids(slug): if rid in out: continue url = f"{BASE}/rooms/{rid}" payload = cache.get(rid, url, _parse_fiche) if not payload or payload.get("gone"): continue price = payload.get("price") avail = payload.get("availability_date") type_label = (payload.get("type_label") or "").lower() unit_type = ("Studio" if "studio" in type_label and "shared" not in type_label else "Chambre") lst = Listing( source=self.source_id, external_id=rid, url=url, title=(payload.get("type_label") or "Chambre") + (f" — {payload['address']}" if payload.get("address") else ""), address=payload.get("address") or "", city=city, unit_type=unit_type, price=price, price_label=(f"{price:,.0f} $/mois".replace(",", " ") if price else ""), availability_date=avail, availability=("Libre immédiatement" if avail == "now" else f"Libre le {avail}" if avail else ""), description=payload.get("description") or "", amenities=payload.get("amenities") or [], details=payload.get("details") or {}, images=payload.get("images") or [], lat=payload.get("lat"), lng=payload.get("lng"), ) if payload.get("postal_code"): lst.details["Code postal"] = payload["postal_code"] if payload.get("pets"): lst.pets = payload["pets"] if payload.get("furnished"): lst.furnished = True out[rid] = lst finally: cache.close() return list(out.values())