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/quartier_oree.py : connecteur Quartier Orée (quartieroree.com)5# Route Chassé, Sainte-Marie (Chaudière-Appalaches) — 4 immeubles locatifs6# neufs (Orée-A à Orée-D, 279 unités au total).7# Site WordPress + FacetWP : la page /plans-et-prix/ est rendue CÔTÉ SERVEUR8# et ne contient QUE les unités disponibles (les facettes FacetWP9# « disponibilite / loyer / batiment » ne servent qu'au filtrage client) :10# - carte `.unite` : n° d'unité, loyer mensuel, superficie, nombre de11# chambres, immeuble (Orée-A…D) et disponibilité (« Immédiate »,12# « Juillet 2026 ») ;13# - lien « Plan de l'unité » (PDF) ;14# - lien « Demande d'info » portant les attributs structurés15# unite="Unité 101" et batiment="Orée-B".16# L'adresse civique de chaque immeuble est publiée sur la même page17# (`div.infos.OREE-B` -> « 485 Route Chassé, Ste-Marie-de-Beauce… »), et les18# commodités du projet sur l'accueil (`.box-icone`).19# ⚠ Cloudflare renvoie 403 aux robots sans en-tête de navigateur : le20# User-Agent de base.py (Chrome + LouKaBot) passe. robots.txt autorise21# /plans-et-prix/ (seuls /unite/* et /batiment/* sont interdits).22# -----------------------------------------------------------------------------23from __future__ import annotations2425import re2627from bs4 import BeautifulSoup2829from ..schema import Listing, normalize_unit_type, parse_area_sqft, parse_price30from .base import BaseConnector3132BASE = "https://quartieroree.com"33PLANS_URL = f"{BASE}/plans-et-prix/"34CITY = "Sainte-Marie"3536_PRICE_RE = re.compile(r"[\d\s]+\$\s*/\s*mois")37_AREA_RE = re.compile(r"[\d\s]+pi[²2]")38_ROOMS_RE = re.compile(r"(?i)\bstudio\b|\d+\s*chambres?")39_BUILDING_RE = re.compile(r"(?i)or[ée]e[\s-]*[A-D]\b")40_IMG_RE = re.compile(41 r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+'42 r'\.(?:jpg|jpeg|png|webp)', re.I)43_SKIP_IMG = re.compile(44 r"logo|icon|favicon|gdpr|plan-prix-bg|-\d{2,4}x\d{2,4}\.", re.I)454647def _slug(txt: str) -> str:48 """« Orée-B » -> « oree-b »."""49 s = txt.lower().replace("é", "e").replace("è", "e")50 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")515253class QuartierOreeConnector(BaseConnector):54 source_id = "quartier_oree"55 request_delay = 0.656 max_images = 105758 # -- helpers ---------------------------------------------------------------59 @staticmethod60 def _addresses(soup) -> dict[str, str]:61 """Adresse civique par immeuble : {'oree-b': '485 Route Chassé, …'}."""62 out: dict[str, str] = {}63 for div in soup.select("div.infos"):64 classes = [c for c in (div.get("class") or [])65 if re.match(r"(?i)^oree-[a-d]$", c)]66 if not classes:67 continue68 txt = re.sub(r"\s+", " ",69 div.get_text(", ", strip=True)).strip(" ,")70 txt = re.sub(r"(?i)\s*,?\s*Canada\s*$", "", txt)71 txt = re.sub(r"\s*,\s*", ", ", txt)72 if txt:73 out[_slug(classes[0])] = txt74 return out7576 def _project_pages(self) -> tuple[list[str], list[str]]:77 """Commodités (accueil, `.box-icone`) et photos du projet."""78 amenities: list[str] = []79 images: list[str] = []80 try:81 html = self.get(f"{BASE}/").text82 except Exception:83 return amenities, images84 soup = BeautifulSoup(html, "html.parser")85 for el in soup.select(".box-icone h4, .box-icone h3"):86 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()87 if 3 <= len(t) <= 90 and t not in amenities:88 amenities.append(t)89 for u in dict.fromkeys(_IMG_RE.findall(html)):90 if not _SKIP_IMG.search(u) and u not in images:91 images.append(u)92 return amenities[:30], images[: self.max_images]9394 # -- fetch -----------------------------------------------------------------95 def fetch(self) -> list[Listing]:96 soup = BeautifulSoup(self.get(PLANS_URL).text, "html.parser")97 addresses = self._addresses(soup)98 amenities, images = self._project_pages()99100 listings: list[Listing] = []101 for card in soup.select(".unite"):102 try:103 h2 = card.select_one("h2")104 unit_label = h2.get_text(" ", strip=True) if h2 else ""105 info = card.select_one(".infos")106 if not (unit_label and info):107 continue108 info_txt = re.sub(r"\s+", " ", info.get_text(" · ", strip=True))109110 # lien « Demande d'info » : n° d'unité et immeuble structurés111 nav = card.select_one("a.nav-info")112 building = (nav.get("batiment") if nav else "") or ""113 if not building:114 m = _BUILDING_RE.search(info_txt)115 building = m.group(0) if m else ""116 num = re.sub(r"(?i)^unit[ée]\s*", "",117 (nav.get("unite") if nav else unit_label)).strip()118 if not num:119 continue120121 m = _PRICE_RE.search(info_txt)122 price_label = (re.sub(r"\s*/\s*", "/", m.group(0)).strip()123 if m else "")124 m = _AREA_RE.search(info_txt)125 area_txt = m.group(0).strip() if m else ""126 m = _ROOMS_RE.search(info_txt)127 rooms_txt = m.group(0).strip() if m else ""128129 # disponibilité : dernier segment de la carte (après l'immeuble)130 segs = [s.strip() for s in info_txt.split("·") if s.strip()]131 availability = ""132 for seg in reversed(segs):133 if seg in (price_label, area_txt, rooms_txt):134 continue135 if building and _slug(seg) == _slug(building):136 continue137 availability = seg138 break139140 plan_pdf = ""141 for a in card.select("a[href$='.pdf']"):142 plan_pdf = a.get("href", "")143 break144145 b_slug = _slug(building) if building else ""146 ext = f"{b_slug}-{num}" if b_slug else num147 desc = " — ".join(x for x in [148 rooms_txt, area_txt, building] if x)149150 details: dict = {}151 if plan_pdf.startswith("http"):152 details["plan_pdf"] = plan_pdf153154 listings.append(Listing(155 source=self.source_id,156 external_id=ext,157 url=f"{PLANS_URL}#{ext}",158 title=(f"Quartier Orée {re.sub(r'(?i)^or[ée]e[\\s-]*', '', building)}"159 f" — Unité {num}" if building160 else f"Quartier Orée — Unité {num}"),161 address=addresses.get(b_slug, ""),162 sector="",163 city=CITY,164 unit_type=normalize_unit_type(rooms_txt),165 price=parse_price(price_label),166 price_label=price_label,167 availability=availability,168 area_sqft=parse_area_sqft(area_txt),169 description=desc[:600],170 amenities=list(amenities),171 details=details,172 images=list(images),173 ))174 except Exception:175 continue176177 uniq: dict[str, Listing] = {}178 for lst in listings:179 uniq.setdefault(lst.external_id, lst)180 return list(uniq.values())181