# ----------------------------------------------------------------------------- # 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"]