# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/terater.py : connecteur Les Constructions Tèratèr (terater.ca) # Constructeur-locateur de l'Estrie : Val-Joli/Windsor, Sherbrooke (Nord, # Fleurimont, Seyval), Orford, Cookshire-Eaton. WordPress + thème enfant # Avada avec blocs maison (même famille de balisage que morin.py) : archive # /a-louer/ rendue serveur — cartes div.landing_modele_block_item complètes # (bandeau de disponibilité coloré, type « 3 ½ », adresse, secteur/ville, # chambres/salle de bain/étage/stationnement/animaux, prix « par mois »). # La fiche (via self.detail, cache BD) ajoute la superficie, la liste des # inclusions et la galerie photos. Les logements loués n'apparaissent pas # dans l'archive (bandeau « Loué » absent au moment de l'écriture) mais on # filtre par prudence. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://terater.ca" LIST_URL = f"{BASE}/a-louer/" # variantes redimensionnées WordPress (-600x397.jpg) -> pleine taille _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) # villes réelles desservies (jamais devinées) + secteurs de Sherbrooke connus _KNOWN_CITIES = [ "Sherbrooke", "Windsor", "Val-Joli", "Orford", "Cookshire-Eaton", "East Angus", "Magog", "Richmond", "Ascot Corner", "Saint-Denis-de-Brompton", ] _SHERBROOKE_SECTORS = [ "Fleurimont", "Rock Forest", "Lennoxville", "Mont-Bellevue", "Jacques-Cartier", "Brompton", "Saint-Élie", "St-Élie", ] # espaces non résidentiels dans le champ « type » de la carte _NON_RESIDENTIAL_RE = re.compile( r"commercial|bureau|local|garage|entrep[oô]t|rangement", re.I) def _city_sector(raw: str) -> tuple[str, str]: """« Val-Joli » -> (Val-Joli, '') ; « Nord de Sherbrooke » -> (Sherbrooke, Nord de Sherbrooke) ; « Fleurimont » -> (Sherbrooke, Fleurimont). Ville vide si le secteur est inconnu (rien d'inventé).""" t = re.sub(r"\s+", " ", raw or "").strip() if not t: return "", "" for city in _KNOWN_CITIES: if t.lower() == city.lower(): return city, "" if re.search(r"\bsherbrooke\b", t, re.I): return "Sherbrooke", t for sec in _SHERBROOKE_SECTORS: if t.lower() == sec.lower(): return "Sherbrooke", t return "", t def _clean_unit_type(raw: str) -> str: """normalize_unit_type seulement si le motif est net (« 3 ½ », studio…).""" ut = normalize_unit_type(raw or "") if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): return ut return "" class TeraterConnector(BaseConnector): source_id = "terater" request_delay = 0.7 max_details = 30 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for card in soup.select("div.landing_modele_block_item"): try: self._parse_card(card, listings) except Exception: continue # fiches détail (cache BD) : superficie, inclusions, galerie self._fetched = 0 for lst in listings.values(): card_key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, card_key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte de l'archive /a-louer/ ------------------------------------------ def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one("a.landing_modele_block_item_link[href]") if not link: return url = link["href"] m = re.search(r"/a-louer/([^/]+)/?", url) if not m: return ext_id = m.group(1) if ext_id in listings or "formulaire" in ext_id: return # bandeau(x) de disponibilité : « Libre immédiatement », « Juillet # 2026 »… — on écarte tout logement marqué loué tags = [re.sub(r"\s+", " ", t.get_text(" ", strip=True)).strip() for t in card.select(".landing_modele_item_tag_item")] tags = [t for t in tags if t] if any(re.search(r"\blou[ée]s?\b", t, re.I) for t in tags): return availability = tags[0] if tags else "" # type d'espace (« Appartement », « Maison »…) : écarter le non # résidentiel (locaux commerciaux, garages…) category = "" cat_el = card.select_one(".landing_modele_block_item_type") if cat_el: category = cat_el.get_text(" ", strip=True) if _NON_RESIDENTIAL_RE.search(category): return piece_el = card.select_one(".landing_modele_piece") piece = piece_el.get_text(" ", strip=True) if piece_el else "" addr_el = card.select_one(".landing_modele_block_item_adresse") address = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)).strip() \ if addr_el else "" sect_el = card.select_one(".landing_modele_block_item_secteur div") city, sector = _city_sector( sect_el.get_text(" ", strip=True) if sect_el else "") # prix : « 1 075$ » + « par mois » price_el = card.select_one(".landing_modele_price") after_el = card.select_one(".landing_modele_price_after") price_label = "" if price_el: price_label = price_el.get_text(" ", strip=True) if after_el: price_label += " " + after_el.get_text(" ", strip=True) # pictos : chambres / salle de bain / étage / stationnement / animaux amenities: list[str] = [] for cls, label in [("landing_modele_item_bed", "Chambres"), ("landing_modele_item_bath", "Salle(s) de bain"), ("landing_modele_etage", "Étage"), ("landing_modele_stationnement", "Stationnement")]: el = card.select_one(f".{cls} span") if el: val = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if val: amenities.append(f"{label} : {val}") animals_el = card.select_one(".landing_modele_animaux .animaux_note") if animals_el: note = animals_el.get_text(" ", strip=True) if note: amenities.append(note) # « Animaux accepter » (texte du site) # visuel de la carte (background-image) images: list[str] = [] img_el = card.select_one(".landing_modele_block_item_img_bg[style]") if img_el: m_img = re.search(r"url\('([^']+)'\)", img_el.get("style") or "") if m_img: images = [_SIZE_SUFFIX.sub("", m_img.group(1))] title = " ".join(p for p in (piece, category, "—", address) if p) \ if address else (piece or category) details = {"category": category} if category else {} listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=re.sub(r"\s+", " ", title).strip(), address=address, sector=sector, city=city, unit_type=_clean_unit_type(piece), price=parse_price(price_label), price_label=price_label, availability=availability, amenities=amenities, details=details, images=images, ) # -- fiche détail ----------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Superficie, inclusions et galerie de la fiche single_modele_*.""" 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 = {} sup_el = soup.select_one(".single_modele_superficie") if sup_el: out["superficie"] = re.sub( r"\s+", " ", sup_el.get_text(" ", strip=True)).strip() out["inclusions"] = [ re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() for el in soup.select(".single_modele_inclus_item_name") if el.get_text(strip=True)][:30] # galerie : photos du bloc .single_modele_galerie seulement — les # blocs slider_modele_* en pied de page sont les AUTRES logements images: list[str] = [] gal = soup.select_one(".single_modele_galerie") if gal: for u in re.findall( r"https://terater\.ca/wp-content/uploads/[^\"'()\s]+" r"\.(?:jpg|jpeg|webp)", str(gal), re.I): u = _SIZE_SUFFIX.sub("", u) if u not in images: images.append(u) out["images"] = images[:30] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("superficie"): # « Superficie : 1375 pi² » lst.amenities = list(dict.fromkeys(lst.amenities + [d["superficie"]])) if d.get("inclusions"): lst.amenities = list(dict.fromkeys(lst.amenities + d["inclusions"])) if d.get("images"): merged = list(dict.fromkeys(d["images"] + lst.images)) lst.images = merged[:30]