Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/trouve_ton_appart.py : connecteur Trouve-Ton-Appart5# (trouve-ton-appart.com — regroupe 3 propriétaires familiaux : Construction6# Léandre Demers, Immeubles Des Rochers, Immeubles Morency). ~229 fiches7# dans Lotbinière (Laurier-Station, St-Flavien, St-Agapit, St-Apollinaire),8# Lévis (St-Nicolas, St-Rédempteur, St-Romuald), Québec, Ste-Marie et9# Trois-Rivières. Page /logements/ rendue serveur (PHP maison « Dix-Onze ») :10# sections <h2>Ville</h2> puis cartes div.loge — pastille img.dispo11# green.png = Disponible / red.png = Non disponible. On n'ingère QUE les12# unités disponibles (pastille verte). Fiche /logements/logement/<id>/<slug>/13# (via cache BD) : description, grandeur + prix, propriétaire gestionnaire14# (nom + téléphone + courriel) et photo principale.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, infer_city, normalize_unit_type24from .base import BaseConnector2526BASE = "https://www.trouve-ton-appart.com"27LIST_URL = f"{BASE}/logements/"2829# « 940 $ » (span.plus) puis « / mois 6½ » dans la même rangée30PRICE_RE = re.compile(r"([\d\s]+)\s*\$")31TYPE_RE = re.compile(r"/\s*mois\s*([\d]\s*½|Studio|Loft)", re.I)32LOGEMENT_URL_RE = re.compile(r"/logements/logement/(\d+)/([^/?#]+)")33PHONE_RE = re.compile(r"(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})")34# photos : /app_photos/<id>/pp_<hash>.<ext> (pleine taille, hors /thumbnails/)35PHOTO_RE = re.compile(r"/app_photos/\d+/(?!thumbnails/)[^\"'\s)]+"36 r"\.(?:jpe?g|png|webp)", re.I)3738# villes du site -> ville canonique Lou-Ka (le reste passe par infer_city)39CITY_MAP = {40 "Ste-Marie-de-Beauce": "Sainte-Marie",41 "St-Agapit": "Saint-Agapit",42 "St-Apollinaire": "Saint-Apollinaire",43 "St-Flavien": "Saint-Flavien",44}454647class TrouveTonAppartConnector(BaseConnector):48 source_id = "trouve_ton_appart"49 request_delay = 0.650 max_details = 40 # garde-fou fiches détail (vraies requêtes par sync)5152 def fetch(self) -> list[Listing]:53 listings: list[Listing] = []54 html = self.get(LIST_URL).text55 soup = BeautifulSoup(html, "html.parser")56 self._fetched = 05758 container = soup.select_one("#logements") or soup59 city_label = ""60 for el in container.find_all(["h2", "div"], recursive=True):61 if el.name == "h2":62 city_label = el.get_text(strip=True)63 continue64 if "loge" not in (el.get("class") or []):65 continue66 try:67 lst = self._parse_card(el, city_label)68 if lst is not None:69 listings.append(lst)70 except Exception:71 continue72 return listings7374 # -- carte (div.loge d'une section <h2>Ville</h2>) -------------------------------75 def _parse_card(self, card, city_label: str) -> Listing | None:76 # pastille de disponibilité : on n'ingère que le vert (Disponible)77 badge = card.select_one("img.dispo")78 badge_src = (badge.get("src") or "") if badge else ""79 if "green" not in badge_src:80 return None8182 link = card.select_one('a[href*="/logements/logement/"]')83 if not link:84 return None85 m = LOGEMENT_URL_RE.search(link.get("href") or "")86 if not m:87 return None88 ext_id, slug = m.group(1), m.group(2)89 url = f"{BASE}/logements/logement/{ext_id}/{slug}/"9091 detail_div = card.select_one("div.detail")92 text = re.sub(r"\s+", " ",93 detail_div.get_text(" ", strip=True)) if detail_div else ""94 addr_el = card.select_one("div.detail div.plus")95 street = addr_el.get_text(strip=True) if addr_el else ""9697 # prix : le <span class="plus"> de la rangée prix (« 940 $ »), pour ne98 # pas absorber un numéro civique du genre « route 273 » dans le montant99 price = None100 price_label = ""101 unit_type = ""102 price_el = detail_div.select_one("span.plus") if detail_div else None103 pm = PRICE_RE.search(price_el.get_text(" ", strip=True)) if price_el \104 else None105 if pm:106 try:107 val = float(re.sub(r"\s", "", pm.group(1)))108 if 100 <= val <= 20000:109 price = val110 price_label = f"{int(val)} $ / mois"111 except ValueError:112 pass113 tm = TYPE_RE.search(text)114 if tm:115 unit_type = normalize_unit_type(tm.group(1))116117 city = CITY_MAP.get(city_label) or infer_city(city_label,118 default=city_label)119120 # vignette de la liste (repli si la fiche n'a pas de photo pleine taille)121 images: list[str] = []122 thumb = card.select_one("img.image")123 if thumb and thumb.get("src"):124 src = thumb["src"]125 images.append(src if src.startswith("http") else BASE + src)126127 lst = Listing(128 source=self.source_id,129 external_id=ext_id,130 url=url,131 title=f"{street}, {city_label}" if street else city_label,132 address=f"{street}, {city_label}" if street else "",133 city=city,134 unit_type=unit_type,135 price=price,136 price_label=price_label,137 availability="Disponible",138 images=images,139 )140141 key = hashlib.sha1(f"{street}|{text}|{city_label}|green"142 .encode("utf-8")).hexdigest()143 try:144 d = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u))145 self._apply_detail(lst, d)146 except Exception:147 pass148 return lst149150 # -- fiche détail (/logements/logement/<id>/<slug>/) -----------------------------151 def _fetch_detail(self, url: str) -> dict:152 """Description, grandeur/prix, propriétaire (nom, tél., courriel), photos."""153 if self._fetched >= self.max_details:154 raise RuntimeError("budget de fiches détail atteint")155 self._fetched += 1156 html = self.get(url).text157 soup = BeautifulSoup(html, "html.parser")158 out: dict = {}159160 # description : premier <p> sous <h2>Description</h2>161 for h2 in soup.find_all("h2"):162 if "description" in h2.get_text(strip=True).lower():163 p = h2.find_next("p")164 if p:165 out["description"] = re.sub(166 r"\s+", " ", p.get_text(" ", strip=True)).strip()[:1200]167 break168169 # propriétaire gestionnaire : <h3> sous « Pour location » + tel:/mailto:170 anchor = soup.find("a", attrs={"id": "location"})171 h3 = anchor.find_next("h3") if anchor else soup.find("h3")172 if h3 and h3.get_text(strip=True):173 out["manager"] = h3.get_text(strip=True)174 m = re.search(r'href="mailto:([^"?]+)"', html)175 if m:176 out["email"] = m.group(1).strip().lower()177 m = re.search(r'href="tel:\+?1?(\d{10})"', html)178 if m:179 d10 = m.group(1)180 out["phone"] = f"{d10[:3]}-{d10[3:6]}-{d10[6:]}"181182 # photos pleine taille (/app_photos/<id>/pp_*.ext hors miniatures)183 photos = [u if u.startswith("http") else BASE + u184 for u in dict.fromkeys(PHOTO_RE.findall(html))]185 if photos:186 out["images"] = photos[:20]187 return out188189 def _apply_detail(self, lst: Listing, d: dict) -> None:190 if not d:191 return192 if d.get("description"):193 lst.description = d["description"]194 contact = {k: d[k] for k in ("phone", "email") if d.get(k)}195 if d.get("manager"):196 contact["name"] = d["manager"]197 if contact:198 lst.details["contact"] = contact199 if d.get("images"):200 lst.images = d["images"]201