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/boreal_abitibi.py : connecteur Constructions Boréal Abitibi5# (constructionsborealabitibi.com — Val-d'Or et Malartic, logements neufs ;6# seul acteur structuré à Malartic). WordPress + thème maison Tailwind : la7# grille /locations/ est remplie en AJAX (admin-ajax.php, action=get_rents,8# nonce lu dans le HTML de la page). Chaque « rent » est une carte HTML9# `article.listing-item` : ville (Val-d'Or/Malartic), adresse + n° d'app.,10# prix (« 1 550 $ / mois »), « Disponible en août », chambres/salles de11# bain/garage (pictos), « Non meublé, non chauffé, non éclairé ». Fiche12# détail /loyer/<slug>/ (via cache BD) : Grandeur (4 et demi), étage,13# parking, inclusions, description et galerie ; la mention « logements14# non-fumeur / aucun animal » du site alimente `pets`.15# robots.txt WP standard (admin-ajax.php explicitement permis), sitemap XML.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import json21import re22import time2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://constructionsborealabitibi.com"30LIST_URL = f"{BASE}/locations/"31AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"3233# suffixe de redimensionnement WordPress (« -512x384.jpg » -> pleine taille)34_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)35_AJAX_OBJECT_RE = re.compile(r"var ajax_object\s*=\s*(\{.*?\})\s*;?", re.S)36# « 4 et demi » (fiche) -> « 4 1/2 » pour la normalisation commune37_ET_DEMI_RE = re.compile(r"^(\d)\s*et\s*demi$", re.I)383940class BorealAbitibiConnector(BaseConnector):41 source_id = "boreal_abitibi"42 request_delay = 0.643 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync)44 max_pages = 10 # garde-fou pagination AJAX4546 def _post(self, url: str, data: dict):47 """POST avec le même throttling poli que get()."""48 wait = self.request_delay - (time.time() - self._last_request)49 if wait > 0:50 time.sleep(wait)51 resp = self.session.post(url, data=data, timeout=self.timeout)52 self._last_request = time.time()53 resp.raise_for_status()54 return resp5556 def fetch(self) -> list[Listing]:57 # 1) page /locations/ : récupérer le nonce AJAX (grille vide côté serveur)58 html = self.get(LIST_URL).text59 m = _AJAX_OBJECT_RE.search(html)60 if not m:61 raise RuntimeError("ajax_object introuvable sur /locations/")62 cfg = json.loads(m.group(1))63 nonce = cfg.get("nonce", "")6465 # 2) admin-ajax get_rents, paginé (l'inventaire complet, toutes villes)66 self._fetched = 067 listings: dict[str, Listing] = {}68 page = 169 while page <= self.max_pages:70 data = {71 "action": "get_rents", "nonce": nonce, "page": str(page),72 "posts_per_page": "12", "town": "", "show_all": "",73 }74 payload = self._post(AJAX_URL, data).json()75 for card_html in payload.get("rents") or []:76 try:77 self._parse_card(BeautifulSoup(card_html, "html.parser"),78 listings)79 except Exception:80 continue81 if page >= int(payload.get("max_num_pages") or 1):82 break83 page += 184 return list(listings.values())8586 # -- carte (article.listing-item retournée par get_rents) ---------------------------87 def _parse_card(self, card, listings: dict[str, Listing]) -> None:88 link = card.select_one('a[href*="/loyer/"]')89 if not link:90 return91 url = link["href"].strip()92 m = re.search(r"/loyer/([^/?#]+)", url)93 if not m:94 return95 ext_id = m.group(1).strip("/")96 if not ext_id or ext_id in listings:97 return9899 # ville (« Val-d'Or », « Malartic ») + adresse et n° d'appartement100 city_el = card.select_one("div.h4 strong")101 city = city_el.get_text(strip=True) if city_el else ""102 title_el = card.select_one("h3 a")103 title = re.sub(r"\s+", " ",104 title_el.get_text(" ", strip=True)) if title_el else ""105 address = f"{title}, {city}" if title and city else title106107 # rangée prix / disponibilité : « 1 550 $ / mois » | « Disponible en août »108 price_label = availability = ""109 for div in card.select("div"):110 txt = re.sub(r"\s+", " ", div.get_text(" ", strip=True))111 if not div.find("div") and re.match(r"^\d[\d\s]*\$\s*/\s*mois$", txt, re.I):112 price_label = txt113 elif not div.find("div") and re.match(r"^Disponible", txt, re.I):114 availability = txt115116 # pictos : chambres / salles de bain / garage117 amenities: list[str] = []118 for icon, label in (("icon-bedroom", "chambre(s)"),119 ("icon-bathroom", "salle(s) de bain"),120 ("icon-garage", "garage/stationnement")):121 ic = card.select_one(f"span.{icon}")122 if ic:123 val = ic.find_next_sibling("span")124 if val and val.get_text(strip=True):125 amenities.append(f"{val.get_text(strip=True)} {label}")126 # mention « Non meublé, non chauffé, non éclairé »127 for div in card.select("div.self-end"):128 txt = div.get_text(" ", strip=True)129 if txt:130 amenities.append(txt)131132 # photo de couverture (le lazy-load expose data-srcset avec la pleine taille)133 images: list[str] = []134 img = card.select_one("img[data-srcset], img[data-src], img[src]")135 if img:136 srcset = img.get("data-srcset") or ""137 candidates = re.findall(r"(https?://\S+)\s+(\d+)w", srcset)138 if candidates:139 images.append(max(candidates, key=lambda c: int(c[1]))[0])140 else:141 u = img.get("data-src") or img.get("src") or ""142 if u.startswith("http"):143 images.append(_SIZE_SUFFIX.sub("", u))144145 lst = Listing(146 source=self.source_id,147 external_id=ext_id,148 url=url,149 title=f"{title} — {city}".strip(" —"),150 address=address,151 city=city,152 price=parse_price(price_label),153 price_label=price_label,154 availability=availability,155 amenities=amenities,156 images=images,157 )158159 key = hashlib.sha1(160 f"{title}|{price_label}|{availability}|{city}"161 .encode("utf-8")).hexdigest()162 try:163 payload = self.detail(ext_id, key,164 lambda u=url: self._fetch_detail(u))165 self._apply_detail(lst, payload)166 except Exception:167 pass168 listings[ext_id] = lst169170 # -- fiche détail (/loyer/<slug>/) ---------------------------------------------------171 def _fetch_detail(self, url: str) -> dict:172 """Grandeur, étage, parking, inclusions, description, galerie, animaux."""173 if self._fetched >= self.max_details:174 raise RuntimeError("budget de fiches détail atteint")175 self._fetched += 1176 html = self.get(url).text177 soup = BeautifulSoup(html, "html.parser")178 out: dict = {}179180 # tableau de specs : rangées h2 (libellé) / div (valeur)181 fields: dict[str, str] = {}182 for row in soup.find_all("div", class_="lg:table-row"):183 h2 = row.find("h2")184 val = row.find("div")185 if h2 and val:186 fields[h2.get_text(strip=True).lower()] = \187 re.sub(r"\s+", " ", val.get_text(" ", strip=True))188 out["fields"] = fields189190 # description : bloc texte libre (proximité écoles/épiceries…)191 desc = ""192 for div in soup.select("div.p-10"):193 txt = div.get_text("\n", strip=True)194 if re.search(r"proximit[ée]", txt, re.I) and len(txt) > len(desc):195 desc = txt196 if desc:197 out["description"] = re.sub(r"[ \t]+", " ", desc).strip()[:1500]198199 # mention transversale affichée sur chaque fiche200 if re.search(r"N[’']ACCEPTONS\s+PAS\s+LES\s+ANIMAUX",201 soup.get_text(" ", strip=True), re.I):202 out["pets"] = "non"203204 # galerie : images lazy-load (lozad) -> data-src, pleine taille205 images: list[str] = []206 for img in soup.select('img[data-src*="/wp-content/uploads/"], '207 'img[src*="/wp-content/uploads/"]'):208 u = _SIZE_SUFFIX.sub("", img.get("data-src") or img.get("src") or "")209 if u.startswith("http") and u not in images:210 images.append(u)211 out["images"] = images[:25]212 return out213214 def _apply_detail(self, lst: Listing, d: dict) -> None:215 """Reporte le payload (frais ou en cache) sur l'annonce."""216 if not d:217 return218 fields = d.get("fields") or {}219 grandeur = fields.get("grandeur", "")220 m = _ET_DEMI_RE.match(grandeur)221 if m:222 lst.unit_type = normalize_unit_type(f"{m.group(1)} 1/2")223 elif grandeur:224 lst.unit_type = normalize_unit_type(grandeur)225226 extra: list[str] = []227 if fields.get("étage") or fields.get("etage"):228 extra.append(f"Étage : {fields.get('étage') or fields.get('etage')}")229 if fields.get("parking"):230 extra.append(f"Stationnement : {fields['parking']}")231 if fields.get("inclusions"):232 extra.append(f"Inclus : {fields['inclusions']}")233 if extra:234 lst.amenities = list(dict.fromkeys(lst.amenities + extra))235236 if not lst.availability and fields.get("disponible"):237 lst.availability = f"Disponible {fields['disponible']}"238 if d.get("description"):239 lst.description = d["description"]240 if d.get("pets"):241 lst.pets = d["pets"]242 if d.get("images") and len(d["images"]) > len(lst.images):243 lst.images = d["images"]244