# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/boreal_abitibi.py : connecteur Constructions Boréal Abitibi # (constructionsborealabitibi.com — Val-d'Or et Malartic, logements neufs ; # seul acteur structuré à Malartic). WordPress + thème maison Tailwind : la # grille /locations/ est remplie en AJAX (admin-ajax.php, action=get_rents, # nonce lu dans le HTML de la page). Chaque « rent » est une carte HTML # `article.listing-item` : ville (Val-d'Or/Malartic), adresse + n° d'app., # prix (« 1 550 $ / mois »), « Disponible en août », chambres/salles de # bain/garage (pictos), « Non meublé, non chauffé, non éclairé ». Fiche # détail /loyer// (via cache BD) : Grandeur (4 et demi), étage, # parking, inclusions, description et galerie ; la mention « logements # non-fumeur / aucun animal » du site alimente `pets`. # robots.txt WP standard (admin-ajax.php explicitement permis), sitemap XML. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re import time from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://constructionsborealabitibi.com" LIST_URL = f"{BASE}/locations/" AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php" # suffixe de redimensionnement WordPress (« -512x384.jpg » -> pleine taille) _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) _AJAX_OBJECT_RE = re.compile(r"var ajax_object\s*=\s*(\{.*?\})\s*;?", re.S) # « 4 et demi » (fiche) -> « 4 1/2 » pour la normalisation commune _ET_DEMI_RE = re.compile(r"^(\d)\s*et\s*demi$", re.I) class BorealAbitibiConnector(BaseConnector): source_id = "boreal_abitibi" request_delay = 0.6 max_details = 30 # garde-fou fiches détail (vraies requêtes par sync) max_pages = 10 # garde-fou pagination AJAX def _post(self, url: str, data: dict): """POST avec le même throttling poli que get().""" wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.post(url, data=data, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return resp def fetch(self) -> list[Listing]: # 1) page /locations/ : récupérer le nonce AJAX (grille vide côté serveur) html = self.get(LIST_URL).text m = _AJAX_OBJECT_RE.search(html) if not m: raise RuntimeError("ajax_object introuvable sur /locations/") cfg = json.loads(m.group(1)) nonce = cfg.get("nonce", "") # 2) admin-ajax get_rents, paginé (l'inventaire complet, toutes villes) self._fetched = 0 listings: dict[str, Listing] = {} page = 1 while page <= self.max_pages: data = { "action": "get_rents", "nonce": nonce, "page": str(page), "posts_per_page": "12", "town": "", "show_all": "", } payload = self._post(AJAX_URL, data).json() for card_html in payload.get("rents") or []: try: self._parse_card(BeautifulSoup(card_html, "html.parser"), listings) except Exception: continue if page >= int(payload.get("max_num_pages") or 1): break page += 1 return list(listings.values()) # -- carte (article.listing-item retournée par get_rents) --------------------------- def _parse_card(self, card, listings: dict[str, Listing]) -> None: link = card.select_one('a[href*="/loyer/"]') if not link: return url = link["href"].strip() m = re.search(r"/loyer/([^/?#]+)", url) if not m: return ext_id = m.group(1).strip("/") if not ext_id or ext_id in listings: return # ville (« Val-d'Or », « Malartic ») + adresse et n° d'appartement city_el = card.select_one("div.h4 strong") city = city_el.get_text(strip=True) if city_el else "" title_el = card.select_one("h3 a") title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True)) if title_el else "" address = f"{title}, {city}" if title and city else title # rangée prix / disponibilité : « 1 550 $ / mois » | « Disponible en août » price_label = availability = "" for div in card.select("div"): txt = re.sub(r"\s+", " ", div.get_text(" ", strip=True)) if not div.find("div") and re.match(r"^\d[\d\s]*\$\s*/\s*mois$", txt, re.I): price_label = txt elif not div.find("div") and re.match(r"^Disponible", txt, re.I): availability = txt # pictos : chambres / salles de bain / garage amenities: list[str] = [] for icon, label in (("icon-bedroom", "chambre(s)"), ("icon-bathroom", "salle(s) de bain"), ("icon-garage", "garage/stationnement")): ic = card.select_one(f"span.{icon}") if ic: val = ic.find_next_sibling("span") if val and val.get_text(strip=True): amenities.append(f"{val.get_text(strip=True)} {label}") # mention « Non meublé, non chauffé, non éclairé » for div in card.select("div.self-end"): txt = div.get_text(" ", strip=True) if txt: amenities.append(txt) # photo de couverture (le lazy-load expose data-srcset avec la pleine taille) images: list[str] = [] img = card.select_one("img[data-srcset], img[data-src], img[src]") if img: srcset = img.get("data-srcset") or "" candidates = re.findall(r"(https?://\S+)\s+(\d+)w", srcset) if candidates: images.append(max(candidates, key=lambda c: int(c[1]))[0]) else: u = img.get("data-src") or img.get("src") or "" if u.startswith("http"): images.append(_SIZE_SUFFIX.sub("", u)) lst = Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{title} — {city}".strip(" —"), address=address, city=city, price=parse_price(price_label), price_label=price_label, availability=availability, amenities=amenities, images=images, ) key = hashlib.sha1( f"{title}|{price_label}|{availability}|{city}" .encode("utf-8")).hexdigest() try: payload = self.detail(ext_id, key, lambda u=url: self._fetch_detail(u)) self._apply_detail(lst, payload) except Exception: pass listings[ext_id] = lst # -- fiche détail (/loyer//) --------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Grandeur, étage, parking, inclusions, description, galerie, animaux.""" 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 = {} # tableau de specs : rangées h2 (libellé) / div (valeur) fields: dict[str, str] = {} for row in soup.find_all("div", class_="lg:table-row"): h2 = row.find("h2") val = row.find("div") if h2 and val: fields[h2.get_text(strip=True).lower()] = \ re.sub(r"\s+", " ", val.get_text(" ", strip=True)) out["fields"] = fields # description : bloc texte libre (proximité écoles/épiceries…) desc = "" for div in soup.select("div.p-10"): txt = div.get_text("\n", strip=True) if re.search(r"proximit[ée]", txt, re.I) and len(txt) > len(desc): desc = txt if desc: out["description"] = re.sub(r"[ \t]+", " ", desc).strip()[:1500] # mention transversale affichée sur chaque fiche if re.search(r"N[’']ACCEPTONS\s+PAS\s+LES\s+ANIMAUX", soup.get_text(" ", strip=True), re.I): out["pets"] = "non" # galerie : images lazy-load (lozad) -> data-src, pleine taille images: list[str] = [] for img in soup.select('img[data-src*="/wp-content/uploads/"], ' 'img[src*="/wp-content/uploads/"]'): u = _SIZE_SUFFIX.sub("", img.get("data-src") or img.get("src") or "") if u.startswith("http") and u not in images: images.append(u) out["images"] = images[:25] return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais ou en cache) sur l'annonce.""" if not d: return fields = d.get("fields") or {} grandeur = fields.get("grandeur", "") m = _ET_DEMI_RE.match(grandeur) if m: lst.unit_type = normalize_unit_type(f"{m.group(1)} 1/2") elif grandeur: lst.unit_type = normalize_unit_type(grandeur) extra: list[str] = [] if fields.get("étage") or fields.get("etage"): extra.append(f"Étage : {fields.get('étage') or fields.get('etage')}") if fields.get("parking"): extra.append(f"Stationnement : {fields['parking']}") if fields.get("inclusions"): extra.append(f"Inclus : {fields['inclusions']}") if extra: lst.amenities = list(dict.fromkeys(lst.amenities + extra)) if not lst.availability and fields.get("disponible"): lst.availability = f"Disponible {fields['disponible']}" if d.get("description"): lst.description = d["description"] if d.get("pets"): lst.pets = d["pets"] if d.get("images") and len(d["images"]) > len(lst.images): lst.images = d["images"]