# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/quartier_oree.py : connecteur Quartier Orée (quartieroree.com) # Route Chassé, Sainte-Marie (Chaudière-Appalaches) — 4 immeubles locatifs # neufs (Orée-A à Orée-D, 279 unités au total). # Site WordPress + FacetWP : la page /plans-et-prix/ est rendue CÔTÉ SERVEUR # et ne contient QUE les unités disponibles (les facettes FacetWP # « disponibilite / loyer / batiment » ne servent qu'au filtrage client) : # - carte `.unite` : n° d'unité, loyer mensuel, superficie, nombre de # chambres, immeuble (Orée-A…D) et disponibilité (« Immédiate », # « Juillet 2026 ») ; # - lien « Plan de l'unité » (PDF) ; # - lien « Demande d'info » portant les attributs structurés # unite="Unité 101" et batiment="Orée-B". # L'adresse civique de chaque immeuble est publiée sur la même page # (`div.infos.OREE-B` -> « 485 Route Chassé, Ste-Marie-de-Beauce… »), et les # commodités du projet sur l'accueil (`.box-icone`). # ⚠ Cloudflare renvoie 403 aux robots sans en-tête de navigateur : le # User-Agent de base.py (Chrome + LouKaBot) passe. robots.txt autorise # /plans-et-prix/ (seuls /unite/* et /batiment/* sont interdits). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_area_sqft, parse_price from .base import BaseConnector BASE = "https://quartieroree.com" PLANS_URL = f"{BASE}/plans-et-prix/" CITY = "Sainte-Marie" _PRICE_RE = re.compile(r"[\d\s]+\$\s*/\s*mois") _AREA_RE = re.compile(r"[\d\s]+pi[²2]") _ROOMS_RE = re.compile(r"(?i)\bstudio\b|\d+\s*chambres?") _BUILDING_RE = re.compile(r"(?i)or[ée]e[\s-]*[A-D]\b") _IMG_RE = re.compile( r'https?://[^"\'\s\)]+/wp-content/uploads/[^"\'\s\)]+' r'\.(?:jpg|jpeg|png|webp)', re.I) _SKIP_IMG = re.compile( r"logo|icon|favicon|gdpr|plan-prix-bg|-\d{2,4}x\d{2,4}\.", re.I) def _slug(txt: str) -> str: """« Orée-B » -> « oree-b ».""" s = txt.lower().replace("é", "e").replace("è", "e") return re.sub(r"[^a-z0-9]+", "-", s).strip("-") class QuartierOreeConnector(BaseConnector): source_id = "quartier_oree" request_delay = 0.6 max_images = 10 # -- helpers --------------------------------------------------------------- @staticmethod def _addresses(soup) -> dict[str, str]: """Adresse civique par immeuble : {'oree-b': '485 Route Chassé, …'}.""" out: dict[str, str] = {} for div in soup.select("div.infos"): classes = [c for c in (div.get("class") or []) if re.match(r"(?i)^oree-[a-d]$", c)] if not classes: continue txt = re.sub(r"\s+", " ", div.get_text(", ", strip=True)).strip(" ,") txt = re.sub(r"(?i)\s*,?\s*Canada\s*$", "", txt) txt = re.sub(r"\s*,\s*", ", ", txt) if txt: out[_slug(classes[0])] = txt return out def _project_pages(self) -> tuple[list[str], list[str]]: """Commodités (accueil, `.box-icone`) et photos du projet.""" amenities: list[str] = [] images: list[str] = [] try: html = self.get(f"{BASE}/").text except Exception: return amenities, images soup = BeautifulSoup(html, "html.parser") for el in soup.select(".box-icone h4, .box-icone h3"): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if 3 <= len(t) <= 90 and t not in amenities: amenities.append(t) for u in dict.fromkeys(_IMG_RE.findall(html)): if not _SKIP_IMG.search(u) and u not in images: images.append(u) return amenities[:30], images[: self.max_images] # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(PLANS_URL).text, "html.parser") addresses = self._addresses(soup) amenities, images = self._project_pages() listings: list[Listing] = [] for card in soup.select(".unite"): try: h2 = card.select_one("h2") unit_label = h2.get_text(" ", strip=True) if h2 else "" info = card.select_one(".infos") if not (unit_label and info): continue info_txt = re.sub(r"\s+", " ", info.get_text(" · ", strip=True)) # lien « Demande d'info » : n° d'unité et immeuble structurés nav = card.select_one("a.nav-info") building = (nav.get("batiment") if nav else "") or "" if not building: m = _BUILDING_RE.search(info_txt) building = m.group(0) if m else "" num = re.sub(r"(?i)^unit[ée]\s*", "", (nav.get("unite") if nav else unit_label)).strip() if not num: continue m = _PRICE_RE.search(info_txt) price_label = (re.sub(r"\s*/\s*", "/", m.group(0)).strip() if m else "") m = _AREA_RE.search(info_txt) area_txt = m.group(0).strip() if m else "" m = _ROOMS_RE.search(info_txt) rooms_txt = m.group(0).strip() if m else "" # disponibilité : dernier segment de la carte (après l'immeuble) segs = [s.strip() for s in info_txt.split("·") if s.strip()] availability = "" for seg in reversed(segs): if seg in (price_label, area_txt, rooms_txt): continue if building and _slug(seg) == _slug(building): continue availability = seg break plan_pdf = "" for a in card.select("a[href$='.pdf']"): plan_pdf = a.get("href", "") break b_slug = _slug(building) if building else "" ext = f"{b_slug}-{num}" if b_slug else num desc = " — ".join(x for x in [ rooms_txt, area_txt, building] if x) details: dict = {} if plan_pdf.startswith("http"): details["plan_pdf"] = plan_pdf listings.append(Listing( source=self.source_id, external_id=ext, url=f"{PLANS_URL}#{ext}", title=(f"Quartier Orée {re.sub(r'(?i)^or[ée]e[\\s-]*', '', building)}" f" — Unité {num}" if building else f"Quartier Orée — Unité {num}"), address=addresses.get(b_slug, ""), sector="", city=CITY, unit_type=normalize_unit_type(rooms_txt), price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=parse_area_sqft(area_txt), description=desc[:600], amenities=list(amenities), details=details, images=list(images), )) except Exception: continue uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values())