# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/royal_lepage.py : Royal LePage (royallepage.ca) — LOCATIONS Québec # # Le portail rend ses résultats côté serveur. La recherche par ville est # filtrable : /fr/search/homes/{page}/?search_str=...&prov_code=QC&city_name= # ...&lat=..&lng=..&search_type=city + « transactionType=LEASE » (trouvé dans # le formulaire caché du site) renvoie les propriétés À LOUER (46/page, # prix « 1 850.00 $ /mois » sur la carte). Chaque recherche plafonne à # ~1 250 résultats ; on SHARD par points d'ancrage répartis dans la province # et on dédoublonne par numéro MLS (dans l'URL /.../mls{no}/). Aucune # dépendance Firecrawl : requêtes directes. Adapté du connecteur « à vendre » # d'Immo-Ka (agent-courtage/immoka). # La fiche détail (accessible en direct, ~0,7 s) fournit ce que la carte # liste n'a pas : galerie COMPLÈTE (originaux pleine résolution du CDN # jumplisting, ordre du carrousel), description (« Remarques »), superficie # habitable, caractéristiques (label/valeur) et bureau — enrichissement # plafonné (DETAIL_LIMIT/cycle) + cache BD, le parc se complète au fil des # synchronisations. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from . import _detailutil as du from ..schema import Listing, parse_area_sqft BASE = "https://www.royallepage.ca/fr/search/homes" MAX_PAGES = 30 # une recherche plafonne à ~27 pages (~1250) # Description, superficie habitable et bureau (« Royal LePage Humania »…) ne # sont pas sur la carte : ils viennent de la fiche détail. Enrichissement # plafonné + cache (se remplit au fil des cycles). DETAIL_LIMIT = int(os.environ.get("LOUKA_RLP_DETAIL_LIMIT", "100")) _OFFICE_RE = re.compile(r'agent-info__brokerage".*?]*>\s*([^<,\n]+?)\s*(?:,|\n|)', re.S | re.I) # bloc « Remarques » de la fiche : paragraphes de description (+ inclusions/ # exclusions) jusqu'à la section suivante _DESC_BLOCK_RE = re.compile(r"

\s*Remarques\s*:?\s*

(.*?)(?=(.*?)

', re.S | re.I) # « Superficie habitable (approx): » -> « 527 Pieds carrés » (label/value) _AREA_VAL_RE = re.compile(r'Superficie habitable[^<]*:\s*\s*' r'\s*([^<]+)', re.I) # galerie de la fiche : toutes les photos du carrousel/vignettes, indexées # « …/photos/28/66/94/65/28669465_3_lg.jpg » ; la variante SANS suffixe # (28669465_3.jpg) est l'original pleine résolution servi par le CDN. _GALLERY_RE = re.compile( r'//rlp\.jumplisting\.com/photos/(\d+/\d+/\d+/\d+)/(\d+)_(\d+)(?:_[a-z]+)?\.jpe?g', re.I) # tableaux « details-row » de la fiche : paires _LABELVAL_RE = re.compile(r'class="label">\s*([^<]{1,60}?)\s*\s*' r'\s*([^<]{1,90}?)\s*<', re.S) # Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches # + dédoublonnage MLS couvre la province. ANCHORS = [ ("Montréal", 45.5089, -73.5542), ("Québec", 46.8139, -71.2080), ("Gatineau", 45.4765, -75.7013), ("Sherbrooke", 45.4040, -71.8929), ("Trois-Rivières", 46.3432, -72.5432), ("Saguenay", 48.4280, -71.0680), ("Laval", 45.6066, -73.7124), ("Longueuil", 45.5312, -73.5185), ("Drummondville", 45.8833, -72.4833), ("Saint-Jérôme", 45.7803, -74.0038), ("Granby", 45.4001, -72.7300), ("Rimouski", 48.4489, -68.5236), ("Rouyn-Noranda", 48.2360, -79.0230), ("Val-d'Or", 48.0975, -77.7972), ("Gaspé", 48.8330, -64.4870), ("Sept-Îles", 50.2001, -66.3821), ("Baie-Comeau", 49.2166, -68.1487), ("Joliette", 46.0167, -73.4500), ("Saint-Hyacinthe", 45.6300, -72.9569), ("Sorel-Tracy", 46.0500, -73.1200), ("Victoriaville", 46.0533, -71.9667), ("Mont-Laurier", 46.5500, -75.5000), ("Alma", 48.5500, -71.6500), ("Matane", 48.8500, -67.5300), ("Saint-Sauveur", 45.8939, -74.2051), ("Baie-Saint-Paul", 47.4400, -70.5000), ("Salaberry-de-Valleyfield", 45.2500, -74.1300), ("Thetford Mines", 46.1000, -71.3000), ] _CARD_RE = re.compile(r'card--listing-card js-listing js-property-details(.*?)' r'(?=card--listing-card js-listing js-property-details||$)', re.S) _URL_RE = re.compile(r'/fr/property/quebec/[^"\']+?/mls[a-z0-9]+/', re.I) _MLS_RE = re.compile(r'/mls([a-z0-9]+)/', re.I) _KEY_RE = re.compile(r'data-rlp-key="(-?\d+\.\d+)\.(-?\d+\.\d+)"') _PHOTO_RE = re.compile(r'(//rlp\.jumplisting\.com/photos/[^"\']+)') _CAC_RE = re.compile(r'(\d+)\s*CAC', re.I) _SDB_RE = re.compile(r'(\d+)(?:\+\d+)?\s*SDB', re.I) # loyer sur la carte, aplatie en « 1 850.00 | $ | /mois » (une carte peut aussi # afficher un prix de VENTE : on ne retient que le montant suivi de /mois) _RENT_RE = re.compile(r'(\d[\d\s ,.]*?)\s*\|?\s*\$\s*\|?\s*/\s*mois', re.I) _TYPE_RE = re.compile(r'\|\s*(Maison|Condo|Copropriété|Appartement|Duplex|Triplex|' r'Plex|Chalet|Loft|Studio|Maison de ville|Jumelé)\s*\|', re.I) def _parse_rent(raw: str) -> float | None: """« 1 850.00 » / « 1,850 » -> 1850.0 ($/mois).""" s = raw.replace(" ", "").replace("\xa0", "").replace(" ", "") if re.search(r",\d{3}\b", s): s = s.replace(",", "") else: s = s.replace(",", ".") try: v = float(s) except ValueError: return None return v if 100 <= v <= 50000 else None class RoyalLepageConnector(BaseConnector): source_id = "royal_lepage" request_delay = 0.4 def fetch(self) -> list[Listing]: by_id: dict[str, Listing] = {} for name, lat, lng in ANCHORS: self._search_anchor(name, lat, lng, by_id) listings = list(by_id.values()) # description + galerie complète + superficie + bureau via la fiche # détail — plafonné, cache accumulé (clé « detail3 » : remplace # detail2, qui ne captait pas la galerie ni les caractéristiques) du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_detail, key="detail3") return listings def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None: empty_streak = 0 for page in range(1, MAX_PAGES + 1): params = { "search_str": f"{name}, QC", "prov_code": "QC", "city_name": name, "lat": lat, "lng": lng, "search_type": "city", "transactionType": "LEASE", } try: html = self.get(f"{BASE}/{page}/", params=params, headers={"X-Requested-With": "XMLHttpRequest"}).text except Exception: break new = 0 for lst in self._parse_cards(html): if lst.external_id not in out: out[lst.external_id] = lst new += 1 if new == 0: empty_streak += 1 if empty_streak >= 2: # cap serveur atteint (page répétée) break else: empty_streak = 0 def _parse_cards(self, html: str) -> list[Listing]: out = [] for seg in _CARD_RE.findall(html): murl = _URL_RE.search(seg) if not murl: continue url = "https://www.royallepage.ca" + murl.group(0) mls = _MLS_RE.search(url) ext = mls.group(1) if mls else url # /fr/property/quebec/{ville}/{adresse}/{id}/mls{no}/ parts = murl.group(0).strip("/").split("/") city = parts[3].replace("-", " ").title() if len(parts) > 3 else "" address = parts[4].replace("-", " ").title() if len(parts) > 4 else "" sector = "" # « Montreal Ville Marie » → ville + arrondissement for big, accent in (("Montreal", "Montréal"), ("Quebec", "Québec"), ("Levis", "Lévis"), ("Gatineau", "Gatineau"), ("Longueuil", "Longueuil"), ("Laval", "Laval")): if city == big: city = accent break if city.startswith(big + " "): sector = city[len(big) + 1:] city = accent break flat = _html.unescape(re.sub(r"<[^>]+>", " | ", seg)) flat = re.sub(r"(\s*\|\s*)+", " | ", re.sub(r"\s+", " ", flat)) rent = _RENT_RE.search(flat) price = _parse_rent(rent.group(1)) if rent else None if price is None: continue # pas de « $ /mois » = pas une location beds = _CAC_RE.search(flat) baths = _SDB_RE.search(flat) typ = _TYPE_RE.search(flat) key = _KEY_RE.search(seg) photo = _PHOTO_RE.search(seg) images = [] if photo: images = ["https:" + photo.group(1).replace("_0_med", "_0_lg")] unit_type = "" if typ and typ.group(1).lower() in ("loft", "studio"): unit_type = typ.group(1).title() elif beds: unit_type = f"{beds.group(1)} chambres" # normalisé en n+2 ½ details = {"Courtier": "Royal LePage"} if mls: details["MLS"] = mls.group(1) if typ: details["Type"] = typ.group(1) if baths: details["Salles de bain"] = baths.group(1) out.append(Listing( source=self.source_id, external_id=str(ext), url=url, title=address, address=address, city=city, sector=sector, unit_type=unit_type, price=price, price_label=f"{price:,.0f} $/mois".replace(",", " "), details=details, images=images, lat=float(key.group(1)) if key else None, lng=float(key.group(2)) if key else None, )) return out def _parse_rlp_office(html: str) -> dict: """Extrait le bureau/brokerage (sous-agence) de la fiche détail.""" m = _OFFICE_RE.search(html) if not m: return {} office = _html.unescape(m.group(1)).strip() return {"details": {"Agence": office}} if office else {} def _parse_rlp_detail(html: str) -> dict: """Fiche détail complète : bureau + description (« Remarques », avec les paragraphes Inclusions/Exclusions — extract_details les minera) + galerie complète (originaux pleine résolution, ordre du carrousel) + superficie habitable + caractéristiques (tableaux label/valeur).""" out = _parse_rlp_office(html) # galerie : toutes les photos de l'annonce (les portraits d'agents sont # sous /photos/agents/ et ne matchent pas le motif dossier numérique) ; # ordre = index du carrousel, URL sans suffixe = original pleine résolution photos: dict[int, str] = {} for path, pid, idx in _GALLERY_RE.findall(html): photos.setdefault( int(idx), f"https://rlp.jumplisting.com/photos/{path}/{pid}_{idx}.jpg") if photos: out["images"] = [photos[i] for i in sorted(photos)] # caractéristiques structurées (Bâti en, Stationnement, Chauffage…) details = out.get("details") or {} for label, value in _LABELVAL_RE.findall(html): label = _html.unescape(label).strip().rstrip(" :*") value = _html.unescape(re.sub(r"\s+", " ", value)).strip().rstrip(",") if (not label or not value or "." in label # clés techniques or label.lower() in ("description",)): continue details.setdefault(label, value) if details: out["details"] = details tour = du.virtual_tour(html) if tour: out["virtual_tour"] = tour # apply_detail -> details.virtual_tour m = _DESC_BLOCK_RE.search(html) if m: paras = [] for p in _DESC_P_RE.findall(m.group(1)): t = _html.unescape(re.sub(r"", "\n", p)) t = re.sub(r"<[^>]+>", " ", t) t = re.sub(r"[ \t]+", " ", t).strip() if t: paras.append(t) if paras: out["description"] = "\n\n".join(paras)[:6000] m = _AREA_VAL_RE.search(html) if m: area = parse_area_sqft(m.group(1)) if area: out["area_sqft"] = area return out