# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/trouve_ton_appart.py : connecteur Trouve-Ton-Appart
# (trouve-ton-appart.com — regroupe 3 propriétaires familiaux : Construction
# Léandre Demers, Immeubles Des Rochers, Immeubles Morency). ~229 fiches
# dans Lotbinière (Laurier-Station, St-Flavien, St-Agapit, St-Apollinaire),
# Lévis (St-Nicolas, St-Rédempteur, St-Romuald), Québec, Ste-Marie et
# Trois-Rivières. Page /logements/ rendue serveur (PHP maison « Dix-Onze ») :
# sections
Ville
puis cartes div.loge — pastille img.dispo
# green.png = Disponible / red.png = Non disponible. On n'ingère QUE les
# unités disponibles (pastille verte). Fiche /logements/logement///
# (via cache BD) : description, grandeur + prix, propriétaire gestionnaire
# (nom + téléphone + courriel) et photo principale.
# -----------------------------------------------------------------------------
from __future__ import annotations
import hashlib
import re
from bs4 import BeautifulSoup
from ..schema import Listing, infer_city, normalize_unit_type
from .base import BaseConnector
BASE = "https://www.trouve-ton-appart.com"
LIST_URL = f"{BASE}/logements/"
# « 940 $ » (span.plus) puis « / mois 6½ » dans la même rangée
PRICE_RE = re.compile(r"([\d\s]+)\s*\$")
TYPE_RE = re.compile(r"/\s*mois\s*([\d]\s*½|Studio|Loft)", re.I)
LOGEMENT_URL_RE = re.compile(r"/logements/logement/(\d+)/([^/?#]+)")
PHONE_RE = re.compile(r"(\d{3})[\s.\-](\d{3})[\s.\-](\d{4})")
# photos : /app_photos//pp_. (pleine taille, hors /thumbnails/)
PHOTO_RE = re.compile(r"/app_photos/\d+/(?!thumbnails/)[^\"'\s)]+"
r"\.(?:jpe?g|png|webp)", re.I)
# villes du site -> ville canonique Lou-Ka (le reste passe par infer_city)
CITY_MAP = {
"Ste-Marie-de-Beauce": "Sainte-Marie",
"St-Agapit": "Saint-Agapit",
"St-Apollinaire": "Saint-Apollinaire",
"St-Flavien": "Saint-Flavien",
}
class TrouveTonAppartConnector(BaseConnector):
source_id = "trouve_ton_appart"
request_delay = 0.6
max_details = 40 # garde-fou fiches détail (vraies requêtes par sync)
def fetch(self) -> list[Listing]:
listings: list[Listing] = []
html = self.get(LIST_URL).text
soup = BeautifulSoup(html, "html.parser")
self._fetched = 0
container = soup.select_one("#logements") or soup
city_label = ""
for el in container.find_all(["h2", "div"], recursive=True):
if el.name == "h2":
city_label = el.get_text(strip=True)
continue
if "loge" not in (el.get("class") or []):
continue
try:
lst = self._parse_card(el, city_label)
if lst is not None:
listings.append(lst)
except Exception:
continue
return listings
# -- carte (div.loge d'une section Ville
) -------------------------------
def _parse_card(self, card, city_label: str) -> Listing | None:
# pastille de disponibilité : on n'ingère que le vert (Disponible)
badge = card.select_one("img.dispo")
badge_src = (badge.get("src") or "") if badge else ""
if "green" not in badge_src:
return None
link = card.select_one('a[href*="/logements/logement/"]')
if not link:
return None
m = LOGEMENT_URL_RE.search(link.get("href") or "")
if not m:
return None
ext_id, slug = m.group(1), m.group(2)
url = f"{BASE}/logements/logement/{ext_id}/{slug}/"
detail_div = card.select_one("div.detail")
text = re.sub(r"\s+", " ",
detail_div.get_text(" ", strip=True)) if detail_div else ""
addr_el = card.select_one("div.detail div.plus")
street = addr_el.get_text(strip=True) if addr_el else ""
# prix : le de la rangée prix (« 940 $ »), pour ne
# pas absorber un numéro civique du genre « route 273 » dans le montant
price = None
price_label = ""
unit_type = ""
price_el = detail_div.select_one("span.plus") if detail_div else None
pm = PRICE_RE.search(price_el.get_text(" ", strip=True)) if price_el \
else None
if pm:
try:
val = float(re.sub(r"\s", "", pm.group(1)))
if 100 <= val <= 20000:
price = val
price_label = f"{int(val)} $ / mois"
except ValueError:
pass
tm = TYPE_RE.search(text)
if tm:
unit_type = normalize_unit_type(tm.group(1))
city = CITY_MAP.get(city_label) or infer_city(city_label,
default=city_label)
# vignette de la liste (repli si la fiche n'a pas de photo pleine taille)
images: list[str] = []
thumb = card.select_one("img.image")
if thumb and thumb.get("src"):
src = thumb["src"]
images.append(src if src.startswith("http") else BASE + src)
lst = Listing(
source=self.source_id,
external_id=ext_id,
url=url,
title=f"{street}, {city_label}" if street else city_label,
address=f"{street}, {city_label}" if street else "",
city=city,
unit_type=unit_type,
price=price,
price_label=price_label,
availability="Disponible",
images=images,
)
key = hashlib.sha1(f"{street}|{text}|{city_label}|green"
.encode("utf-8")).hexdigest()
try:
d = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u))
self._apply_detail(lst, d)
except Exception:
pass
return lst
# -- fiche détail (/logements/logement///) -----------------------------
def _fetch_detail(self, url: str) -> dict:
"""Description, grandeur/prix, propriétaire (nom, tél., courriel), photos."""
if self._fetched >= self.max_details:
raise RuntimeError("budget de fiches détail atteint")
self._fetched += 1
html = self.get(url).text
soup = BeautifulSoup(html, "html.parser")
out: dict = {}
# description : premier sous
Description
for h2 in soup.find_all("h2"):
if "description" in h2.get_text(strip=True).lower():
p = h2.find_next("p")
if p:
out["description"] = re.sub(
r"\s+", " ", p.get_text(" ", strip=True)).strip()[:1200]
break
# propriétaire gestionnaire : sous « Pour location » + tel:/mailto:
anchor = soup.find("a", attrs={"id": "location"})
h3 = anchor.find_next("h3") if anchor else soup.find("h3")
if h3 and h3.get_text(strip=True):
out["manager"] = h3.get_text(strip=True)
m = re.search(r'href="mailto:([^"?]+)"', html)
if m:
out["email"] = m.group(1).strip().lower()
m = re.search(r'href="tel:\+?1?(\d{10})"', html)
if m:
d10 = m.group(1)
out["phone"] = f"{d10[:3]}-{d10[3:6]}-{d10[6:]}"
# photos pleine taille (/app_photos//pp_*.ext hors miniatures)
photos = [u if u.startswith("http") else BASE + u
for u in dict.fromkeys(PHOTO_RE.findall(html))]
if photos:
out["images"] = photos[:20]
return out
def _apply_detail(self, lst: Listing, d: dict) -> None:
if not d:
return
if d.get("description"):
lst.description = d["description"]
contact = {k: d[k] for k in ("phone", "email") if d.get(k)}
if d.get("manager"):
contact["name"] = d["manager"]
if contact:
lst.details["contact"] = contact
if d.get("images"):
lst.images = d["images"]