# -----------------------------------------------------------------------------
# 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>(.*?)%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