spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/terater.py : connecteur Les Constructions Tèratèr (terater.ca)5# Constructeur-locateur de l'Estrie : Val-Joli/Windsor, Sherbrooke (Nord,6# Fleurimont, Seyval), Orford, Cookshire-Eaton. WordPress + thème enfant7# Avada avec blocs maison (même famille de balisage que morin.py) : archive8# /a-louer/ rendue serveur — cartes div.landing_modele_block_item complètes9# (bandeau de disponibilité coloré, type « 3 ½ », adresse, secteur/ville,10# chambres/salle de bain/étage/stationnement/animaux, prix « par mois »).11# La fiche (via self.detail, cache BD) ajoute la superficie, la liste des12# inclusions et la galerie photos. Les logements loués n'apparaissent pas13# dans l'archive (bandeau « Loué » absent au moment de l'écriture) mais on14# filtre par prudence.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://terater.ca"27LIST_URL = f"{BASE}/a-louer/"2829# variantes redimensionnées WordPress (-600x397.jpg) -> pleine taille30_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)3132# villes réelles desservies (jamais devinées) + secteurs de Sherbrooke connus33_KNOWN_CITIES = [34 "Sherbrooke", "Windsor", "Val-Joli", "Orford", "Cookshire-Eaton",35 "East Angus", "Magog", "Richmond", "Ascot Corner", "Saint-Denis-de-Brompton",36]37_SHERBROOKE_SECTORS = [38 "Fleurimont", "Rock Forest", "Lennoxville", "Mont-Bellevue",39 "Jacques-Cartier", "Brompton", "Saint-Élie", "St-Élie",40]4142# espaces non résidentiels dans le champ « type » de la carte43_NON_RESIDENTIAL_RE = re.compile(44 r"commercial|bureau|local|garage|entrep[oô]t|rangement", re.I)454647def _city_sector(raw: str) -> tuple[str, str]:48 """« Val-Joli » -> (Val-Joli, '') ; « Nord de Sherbrooke » ->49 (Sherbrooke, Nord de Sherbrooke) ; « Fleurimont » -> (Sherbrooke,50 Fleurimont). Ville vide si le secteur est inconnu (rien d'inventé)."""51 t = re.sub(r"\s+", " ", raw or "").strip()52 if not t:53 return "", ""54 for city in _KNOWN_CITIES:55 if t.lower() == city.lower():56 return city, ""57 if re.search(r"\bsherbrooke\b", t, re.I):58 return "Sherbrooke", t59 for sec in _SHERBROOKE_SECTORS:60 if t.lower() == sec.lower():61 return "Sherbrooke", t62 return "", t636465def _clean_unit_type(raw: str) -> str:66 """normalize_unit_type seulement si le motif est net (« 3 ½ », studio…)."""67 ut = normalize_unit_type(raw or "")68 if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""):69 return ut70 return ""717273class TeraterConnector(BaseConnector):74 source_id = "terater"75 request_delay = 0.776 max_details = 30 # garde-fou fiches détail (vraies requêtes)7778 def fetch(self) -> list[Listing]:79 html = self.get(LIST_URL).text80 soup = BeautifulSoup(html, "html.parser")8182 listings: dict[str, Listing] = {}83 for card in soup.select("div.landing_modele_block_item"):84 try:85 self._parse_card(card, listings)86 except Exception:87 continue8889 # fiches détail (cache BD) : superficie, inclusions, galerie90 self._fetched = 091 for lst in listings.values():92 card_key = hashlib.sha1(93 f"{lst.title}|{lst.price_label}|{lst.availability}"94 .encode("utf-8")).hexdigest()95 try:96 payload = self.detail(lst.external_id, card_key,97 lambda u=lst.url: self._fetch_detail(u))98 except Exception:99 continue100 self._apply_detail(lst, payload)101 return list(listings.values())102103 # -- carte de l'archive /a-louer/ ------------------------------------------104 def _parse_card(self, card, listings: dict[str, Listing]) -> None:105 link = card.select_one("a.landing_modele_block_item_link[href]")106 if not link:107 return108 url = link["href"]109 m = re.search(r"/a-louer/([^/]+)/?", url)110 if not m:111 return112 ext_id = m.group(1)113 if ext_id in listings or "formulaire" in ext_id:114 return115116 # bandeau(x) de disponibilité : « Libre immédiatement », « Juillet117 # 2026 »… — on écarte tout logement marqué loué118 tags = [re.sub(r"\s+", " ", t.get_text(" ", strip=True)).strip()119 for t in card.select(".landing_modele_item_tag_item")]120 tags = [t for t in tags if t]121 if any(re.search(r"\blou[ée]s?\b", t, re.I) for t in tags):122 return123 availability = tags[0] if tags else ""124125 # type d'espace (« Appartement », « Maison »…) : écarter le non126 # résidentiel (locaux commerciaux, garages…)127 category = ""128 cat_el = card.select_one(".landing_modele_block_item_type")129 if cat_el:130 category = cat_el.get_text(" ", strip=True)131 if _NON_RESIDENTIAL_RE.search(category):132 return133134 piece_el = card.select_one(".landing_modele_piece")135 piece = piece_el.get_text(" ", strip=True) if piece_el else ""136137 addr_el = card.select_one(".landing_modele_block_item_adresse")138 address = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)).strip() \139 if addr_el else ""140141 sect_el = card.select_one(".landing_modele_block_item_secteur div")142 city, sector = _city_sector(143 sect_el.get_text(" ", strip=True) if sect_el else "")144145 # prix : « 1 075$ » + « par mois »146 price_el = card.select_one(".landing_modele_price")147 after_el = card.select_one(".landing_modele_price_after")148 price_label = ""149 if price_el:150 price_label = price_el.get_text(" ", strip=True)151 if after_el:152 price_label += " " + after_el.get_text(" ", strip=True)153154 # pictos : chambres / salle de bain / étage / stationnement / animaux155 amenities: list[str] = []156 for cls, label in [("landing_modele_item_bed", "Chambres"),157 ("landing_modele_item_bath", "Salle(s) de bain"),158 ("landing_modele_etage", "Étage"),159 ("landing_modele_stationnement", "Stationnement")]:160 el = card.select_one(f".{cls} span")161 if el:162 val = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()163 if val:164 amenities.append(f"{label} : {val}")165 animals_el = card.select_one(".landing_modele_animaux .animaux_note")166 if animals_el:167 note = animals_el.get_text(" ", strip=True)168 if note:169 amenities.append(note) # « Animaux accepter » (texte du site)170171 # visuel de la carte (background-image)172 images: list[str] = []173 img_el = card.select_one(".landing_modele_block_item_img_bg[style]")174 if img_el:175 m_img = re.search(r"url\('([^']+)'\)", img_el.get("style") or "")176 if m_img:177 images = [_SIZE_SUFFIX.sub("", m_img.group(1))]178179 title = " ".join(p for p in (piece, category, "—", address) if p) \180 if address else (piece or category)181 details = {"category": category} if category else {}182183 listings[ext_id] = Listing(184 source=self.source_id,185 external_id=ext_id,186 url=url,187 title=re.sub(r"\s+", " ", title).strip(),188 address=address,189 sector=sector,190 city=city,191 unit_type=_clean_unit_type(piece),192 price=parse_price(price_label),193 price_label=price_label,194 availability=availability,195 amenities=amenities,196 details=details,197 images=images,198 )199200 # -- fiche détail -----------------------------------------------------------201 def _fetch_detail(self, url: str) -> dict:202 """Superficie, inclusions et galerie de la fiche single_modele_*."""203 if self._fetched >= self.max_details:204 raise RuntimeError("budget de fiches détail atteint")205 self._fetched += 1206 html = self.get(url).text207 soup = BeautifulSoup(html, "html.parser")208 out: dict = {}209210 sup_el = soup.select_one(".single_modele_superficie")211 if sup_el:212 out["superficie"] = re.sub(213 r"\s+", " ", sup_el.get_text(" ", strip=True)).strip()214215 out["inclusions"] = [216 re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()217 for el in soup.select(".single_modele_inclus_item_name")218 if el.get_text(strip=True)][:30]219220 # galerie : photos du bloc .single_modele_galerie seulement — les221 # blocs slider_modele_* en pied de page sont les AUTRES logements222 images: list[str] = []223 gal = soup.select_one(".single_modele_galerie")224 if gal:225 for u in re.findall(226 r"https://terater\.ca/wp-content/uploads/[^\"'()\s]+"227 r"\.(?:jpg|jpeg|webp)", str(gal), re.I):228 u = _SIZE_SUFFIX.sub("", u)229 if u not in images:230 images.append(u)231 out["images"] = images[:30]232 return out233234 def _apply_detail(self, lst: Listing, d: dict) -> None:235 if not d:236 return237 if d.get("superficie"): # « Superficie : 1375 pi² »238 lst.amenities = list(dict.fromkeys(lst.amenities + [d["superficie"]]))239 if d.get("inclusions"):240 lst.amenities = list(dict.fromkeys(lst.amenities + d["inclusions"]))241 if d.get("images"):242 merged = list(dict.fromkeys(d["images"] + lst.images))243 lst.images = merged[:30]244