# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/bribourg.py : connecteur Bri Bourg (bribourg.com) # Site PHP simple (Charlesbourg, Vanier, Beauport). Les annonces de # logements à louer sont publiées via un blogue DropInBlog embarqué dans # Logements-a-louer.php ; on lit le flux RSS DropInBlog, puis on complète # avec la page d'immeuble correspondante (Immeubles.php) : photos, code # postal, descriptif de l'immeuble et services à proximité (via le cache # self.detail()). Contact (tel:/mailto:) pris sur la page principale. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as htmllib import re import unicodedata from urllib.parse import unquote, urlparse, parse_qs from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://bribourg.com" RENT_PAGE = f"{BASE}/Logements-a-louer.php" # identifiant du blogue DropInBlog (repli si non détecté dans la page) DEFAULT_BLOG_ID = "dcc6791b-355d-4d9b-bb91-a30d855f6cdf" FEED_URL = "https://io.dropinblog.com/feed/{blog_id}/?limit=100" def _strip_tags(s: str) -> str: return re.sub(r"\s+", " ", htmllib.unescape(re.sub(r"<[^>]+>", " ", s))).strip() def _norm(s: str) -> str: s = unicodedata.normalize("NFD", s.lower()) return "".join(c for c in s if unicodedata.category(c) != "Mn") class BribourgConnector(BaseConnector): source_id = "bribourg" request_delay = 0.6 def fetch(self) -> list[Listing]: # 1) Détecter l'identifiant du blogue DropInBlog dans la page # (+ contact du gestionnaire : liens tel:/mailto: structurés) blog_id = DEFAULT_BLOG_ID self._contact: dict = {} try: page = self.get(RENT_PAGE).text m = re.search(r"embedjs/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-" r"[0-9a-f]{4}-[0-9a-f]{12})", page) if m: blog_id = m.group(1) m = re.search(r'href="tel:(\d{10})"', page) if m: d = m.group(1) self._contact["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}" m = re.search(r'href="mailto:([^"?]+)"', page) if m: self._contact["email"] = m.group(1) except Exception: pass # 2) Flux RSS des annonces try: feed = self.get(FEED_URL.format(blog_id=blog_id)).text except Exception: return [] # 3) Pages d'immeubles (pour compléter les photos) building_pages = self._building_pages() listings: list[Listing] = [] for item in re.findall(r"(.*?)", feed, re.S): try: lst = self._parse_item(item, building_pages) if lst: listings.append(lst) except Exception: continue return listings # -- inventaire des pages d'immeubles --------------------------------------- def _building_pages(self) -> list[str]: try: html = self.get(f"{BASE}/Immeubles.php").text except Exception: return [] pages = re.findall(r'href="((?:\./)?\d[\w\-]+\.php)"', html) return sorted({p.lstrip("./") for p in pages}) # -- une annonce du flux ----------------------------------------------------- def _parse_item(self, item: str, building_pages: list[str]) -> Listing | None: def tag(name: str) -> str: m = re.search(r"<%s>(.*?)" % (name, name), item, re.S) return htmllib.unescape(m.group(1).strip()) if m else "" title = _strip_tags(tag("title")) link = tag("link") or RENT_PAGE m = re.search(r"\s*", item, re.S) content = m.group(1) if m else tag("description") # exclusions : stationnement / commercial / rangement if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t", title, re.I): return None text = _strip_tags(content) # "Adresse : 510, avenue Claude-Martin, Québec (Vanier)" addr_m = re.search(r"Adresse\s*:\s*([^A-Z]*?[\w\s,.'’\-()]+?)" r"(?=\s*(?:Loyer|Disponibilit|Caract|$))", text) address = addr_m.group(1).strip(" ,") if addr_m else "" price_m = re.search(r"Loyer\s*:\s*([\d\s,]+\$[^A-Z]*?)(?=\s*(?:Disponibilit|Caract|$))", text) price_label = price_m.group(1).strip() if price_m else "" avail_m = re.search(r"Disponibilit[ée]\s*:\s*(.+?)(?=\s*Caract|$)", text) availability = avail_m.group(1).strip() if avail_m else "" # secteur : "(Vanier)" dans l'adresse ou le titre sector = "" m = re.search(r"\(([^)]+)\)", address) or re.search(r"\(([^)]+)\)", title) if m: sector = m.group(1).strip() # caractéristiques (liste
  • ) amenities = [] for li in re.findall(r"]*>(.*?)
  • ", content, re.S): t = _strip_tags(li) if t and t not in amenities: amenities.append(t) unit_type = normalize_unit_type(title) or normalize_unit_type(" ".join(amenities)) # images de l'annonce (hébergées chez DropInBlog) images = [u for u in dict.fromkeys( re.findall(r'src="(https?://[^"]+\.(?:jpg|jpeg|png|webp))"', content, re.I))] # identifiant stable : slug du paramètre ?p= du lien qs = parse_qs(urlparse(link).query) ext_id = unquote(qs.get("p", [""])[0]) or _norm(re.sub(r"\W+", "-", address or title)) # compléter avec la page de l'immeuble correspondant (numéro civique # + rue) : photos, code postal, descriptif, services à proximité — # via le cache self.detail() (clé = hash du contenu RSS de l'annonce) description = text[:600] civic_m = re.match(r"(\d+)", address) if civic_m and building_pages: civic = civic_m.group(1) street_words = [w for w in re.findall(r"[a-z]{4,}", _norm(address)) if w not in ("avenue", "boulevard", "quebec")] for pg in building_pages: pg_n = _norm(pg) if re.match(r"%s\D" % re.escape(civic), pg) and \ any(w in pg_n for w in street_words): key = hashlib.sha1(item.encode("utf-8")).hexdigest() payload = self.detail( ext_id, key, lambda p=pg: self._building_payload(p)) for full in payload.get("images") or []: if full not in images: images.append(full) postal = payload.get("postal") or "" if postal and postal not in address: address = f"{address}, {postal}" bits = [text] if payload.get("building_desc"): bits.append("Immeuble : " + payload["building_desc"]) if payload.get("services"): bits.append("Services à proximité : " + ", ".join(payload["services"])) description = " — ".join(bits)[:600] break city = infer_city(sector, default="Québec") return Listing( source=self.source_id, external_id=ext_id, url=link, title=title, address=address, sector=sector, city=city, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, description=description, amenities=amenities, details={"contact": dict(self._contact)} if self._contact else {}, images=images[:25], ) # -- page d'immeuble (photos, code postal, descriptif, services) ----------- def _building_payload(self, page: str) -> dict: try: bhtml = self.get(f"{BASE}/{page}").text except Exception: return {} out: dict = {} images: list[str] = [] extra = re.findall( r'(?:src|href)="((?:\./)?images/[\w\-]+\.(?:jpg|jpeg|png|webp))"', bhtml, re.I) for u in dict.fromkeys(extra): if re.search(r"logo|favicon|apropos|ico-", u, re.I): continue images.append(f"{BASE}/{u.lstrip('./')}") if images: out["images"] = images[:25] btext = _strip_tags(bhtml) m = re.search(r"Code postal\s*:\s*([A-Z]\d[A-Z]\s?\d[A-Z]\d)", btext) if m: out["postal"] = m.group(1) # descriptif : premier paragraphe substantiel (« Immeuble situé … ») for p in re.findall(r"]*>(.*?)

    ", bhtml, re.S): t = _strip_tags(p) if len(t) > 60 and not re.search(r"RSS|FeedBurner", t): out["building_desc"] = t[:300] break # « SERVICES À PROXIMITÉ » (liste à puces) m = re.search(r"PROXIMIT.*?]*>(.*?)", bhtml, re.S) if m: services = [_strip_tags(li) for li in re.findall(r"]*>(.*?)", m.group(1), re.S)] services = [s for s in services if s] if services: out["services"] = services[:12] return out