SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
12.5 KB · 270 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/royal_lepage.py : Royal LePage (royallepage.ca) — LOCATIONS Québec5#6#   Le portail rend ses résultats côté serveur. La recherche par ville est7#   filtrable : /fr/search/homes/{page}/?search_str=...&prov_code=QC&city_name=8#   ...&lat=..&lng=..&search_type=city + « transactionType=LEASE » (trouvé dans9#   le formulaire caché du site) renvoie les propriétés À LOUER (46/page,10#   prix « 1 850.00 $ /mois » sur la carte). Chaque recherche plafonne à11#   ~1 250 résultats ; on SHARD par points d'ancrage répartis dans la province12#   et on dédoublonne par numéro MLS (dans l'URL /.../mls{no}/). Aucune13#   dépendance Firecrawl : requêtes directes. Adapté du connecteur « à vendre »14#   d'Immo-Ka (agent-courtage/immoka).15#   La fiche détail (accessible en direct, ~0,7 s) fournit ce que la carte16#   liste n'a pas : galerie COMPLÈTE (originaux pleine résolution du CDN17#   jumplisting, ordre du carrousel), description (« Remarques »), superficie18#   habitable, caractéristiques (label/valeur) et bureau — enrichissement19#   plafonné (DETAIL_LIMIT/cycle) + cache BD, le parc se complète au fil des20#   synchronisations.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import html as _html25import os26import re2728from .base import BaseConnector29from . import _detailutil as du30from ..schema import Listing, parse_area_sqft3132BASE = "https://www.royallepage.ca/fr/search/homes"33MAX_PAGES = 30                        # une recherche plafonne à ~27 pages (~1250)34# Description, superficie habitable et bureau (« Royal LePage Humania »…) ne35# sont pas sur la carte : ils viennent de la fiche détail. Enrichissement36# plafonné + cache (se remplit au fil des cycles).37DETAIL_LIMIT = int(os.environ.get("LOUKA_RLP_DETAIL_LIMIT", "100"))38_OFFICE_RE = re.compile(r'agent-info__brokerage".*?<a[^>]*>\s*([^<,\n]+?)\s*(?:,|\n|</a>)',39                        re.S | re.I)40# bloc « Remarques » de la fiche : paragraphes de description (+ inclusions/41# exclusions) jusqu'à la section suivante42_DESC_BLOCK_RE = re.compile(r"<h2>\s*Remarques\s*:?\s*</h2>(.*?)(?=<h[24]|"43                            r'<div class="details-row"|$)', re.S | re.I)44_DESC_P_RE = re.compile(r'<p class="body-15[^"]*">(.*?)</p>', re.S | re.I)45# « Superficie habitable (approx): » -> « 527 Pieds carrés » (label/value)46_AREA_VAL_RE = re.compile(r'Superficie habitable[^<]*:\s*</span>\s*'47                          r'<span class="value">\s*([^<]+)', re.I)48# galerie de la fiche : toutes les photos du carrousel/vignettes, indexées49# « …/photos/28/66/94/65/28669465_3_lg.jpg » ; la variante SANS suffixe50# (28669465_3.jpg) est l'original pleine résolution servi par le CDN.51_GALLERY_RE = re.compile(52    r'//rlp\.jumplisting\.com/photos/(\d+/\d+/\d+/\d+)/(\d+)_(\d+)(?:_[a-z]+)?\.jpe?g',53    re.I)54# tableaux « details-row » de la fiche : paires <span class=label/value>55_LABELVAL_RE = re.compile(r'class="label">\s*([^<]{1,60}?)\s*</span>\s*'56                          r'<span class="value">\s*([^<]{1,90}?)\s*<', re.S)5758# Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches59# + dédoublonnage MLS couvre la province.60ANCHORS = [61    ("Montréal", 45.5089, -73.5542), ("Québec", 46.8139, -71.2080),62    ("Gatineau", 45.4765, -75.7013), ("Sherbrooke", 45.4040, -71.8929),63    ("Trois-Rivières", 46.3432, -72.5432), ("Saguenay", 48.4280, -71.0680),64    ("Laval", 45.6066, -73.7124), ("Longueuil", 45.5312, -73.5185),65    ("Drummondville", 45.8833, -72.4833), ("Saint-Jérôme", 45.7803, -74.0038),66    ("Granby", 45.4001, -72.7300), ("Rimouski", 48.4489, -68.5236),67    ("Rouyn-Noranda", 48.2360, -79.0230), ("Val-d'Or", 48.0975, -77.7972),68    ("Gaspé", 48.8330, -64.4870), ("Sept-Îles", 50.2001, -66.3821),69    ("Baie-Comeau", 49.2166, -68.1487), ("Joliette", 46.0167, -73.4500),70    ("Saint-Hyacinthe", 45.6300, -72.9569), ("Sorel-Tracy", 46.0500, -73.1200),71    ("Victoriaville", 46.0533, -71.9667), ("Mont-Laurier", 46.5500, -75.5000),72    ("Alma", 48.5500, -71.6500), ("Matane", 48.8500, -67.5300),73    ("Saint-Sauveur", 45.8939, -74.2051), ("Baie-Saint-Paul", 47.4400, -70.5000),74    ("Salaberry-de-Valleyfield", 45.2500, -74.1300), ("Thetford Mines", 46.1000, -71.3000),75]7677_CARD_RE = re.compile(r'card--listing-card js-listing js-property-details(.*?)'78                      r'(?=card--listing-card js-listing js-property-details|</main>|$)', re.S)79_URL_RE = re.compile(r'/fr/property/quebec/[^"\']+?/mls[a-z0-9]+/', re.I)80_MLS_RE = re.compile(r'/mls([a-z0-9]+)/', re.I)81_KEY_RE = re.compile(r'data-rlp-key="(-?\d+\.\d+)\.(-?\d+\.\d+)"')82_PHOTO_RE = re.compile(r'(//rlp\.jumplisting\.com/photos/[^"\']+)')83_CAC_RE = re.compile(r'(\d+)\s*CAC', re.I)84_SDB_RE = re.compile(r'(\d+)(?:\+\d+)?\s*SDB', re.I)85# loyer sur la carte, aplatie en « 1 850.00 | $ | /mois » (une carte peut aussi86# afficher un prix de VENTE : on ne retient que le montant suivi de /mois)87_RENT_RE = re.compile(r'(\d[\d\s  ,.]*?)\s*\|?\s*\$\s*\|?\s*/\s*mois', re.I)88_TYPE_RE = re.compile(r'\|\s*(Maison|Condo|Copropriété|Appartement|Duplex|Triplex|'89                      r'Plex|Chalet|Loft|Studio|Maison de ville|Jumelé)\s*\|', re.I)909192def _parse_rent(raw: str) -> float | None:93    """« 1 850.00 » / « 1,850 » -> 1850.0 ($/mois)."""94    s = raw.replace(" ", "").replace("\xa0", "").replace(" ", "")95    if re.search(r",\d{3}\b", s):96        s = s.replace(",", "")97    else:98        s = s.replace(",", ".")99    try:100        v = float(s)101    except ValueError:102        return None103    return v if 100 <= v <= 50000 else None104105106class RoyalLepageConnector(BaseConnector):107    source_id = "royal_lepage"108    request_delay = 0.4109110    def fetch(self) -> list[Listing]:111        by_id: dict[str, Listing] = {}112        for name, lat, lng in ANCHORS:113            self._search_anchor(name, lat, lng, by_id)114        listings = list(by_id.values())115        # description + galerie complète + superficie + bureau via la fiche116        # détail — plafonné, cache accumulé (clé « detail3 » : remplace117        # detail2, qui ne captait pas la galerie ni les caractéristiques)118        du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_detail, key="detail3")119        return listings120121    def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None:122        empty_streak = 0123        for page in range(1, MAX_PAGES + 1):124            params = {125                "search_str": f"{name}, QC", "prov_code": "QC",126                "city_name": name, "lat": lat, "lng": lng, "search_type": "city",127                "transactionType": "LEASE",128            }129            try:130                html = self.get(f"{BASE}/{page}/", params=params,131                                headers={"X-Requested-With": "XMLHttpRequest"}).text132            except Exception:133                break134            new = 0135            for lst in self._parse_cards(html):136                if lst.external_id not in out:137                    out[lst.external_id] = lst138                    new += 1139            if new == 0:140                empty_streak += 1141                if empty_streak >= 2:   # cap serveur atteint (page répétée)142                    break143            else:144                empty_streak = 0145146    def _parse_cards(self, html: str) -> list[Listing]:147        out = []148        for seg in _CARD_RE.findall(html):149            murl = _URL_RE.search(seg)150            if not murl:151                continue152            url = "https://www.royallepage.ca" + murl.group(0)153            mls = _MLS_RE.search(url)154            ext = mls.group(1) if mls else url155            # /fr/property/quebec/{ville}/{adresse}/{id}/mls{no}/156            parts = murl.group(0).strip("/").split("/")157            city = parts[3].replace("-", " ").title() if len(parts) > 3 else ""158            address = parts[4].replace("-", " ").title() if len(parts) > 4 else ""159            sector = ""160            # « Montreal Ville Marie » → ville + arrondissement161            for big, accent in (("Montreal", "Montréal"), ("Quebec", "Québec"),162                                ("Levis", "Lévis"), ("Gatineau", "Gatineau"),163                                ("Longueuil", "Longueuil"), ("Laval", "Laval")):164                if city == big:165                    city = accent166                    break167                if city.startswith(big + " "):168                    sector = city[len(big) + 1:]169                    city = accent170                    break171            flat = _html.unescape(re.sub(r"<[^>]+>", " | ", seg))172            flat = re.sub(r"(\s*\|\s*)+", " | ", re.sub(r"\s+", " ", flat))173            rent = _RENT_RE.search(flat)174            price = _parse_rent(rent.group(1)) if rent else None175            if price is None:176                continue            # pas de « $ /mois » = pas une location177            beds = _CAC_RE.search(flat)178            baths = _SDB_RE.search(flat)179            typ = _TYPE_RE.search(flat)180            key = _KEY_RE.search(seg)181            photo = _PHOTO_RE.search(seg)182            images = []183            if photo:184                images = ["https:" + photo.group(1).replace("_0_med", "_0_lg")]185            unit_type = ""186            if typ and typ.group(1).lower() in ("loft", "studio"):187                unit_type = typ.group(1).title()188            elif beds:189                unit_type = f"{beds.group(1)} chambres"    # normalisé en n+2 ½190            details = {"Courtier": "Royal LePage"}191            if mls:192                details["MLS"] = mls.group(1)193            if typ:194                details["Type"] = typ.group(1)195            if baths:196                details["Salles de bain"] = baths.group(1)197            out.append(Listing(198                source=self.source_id, external_id=str(ext), url=url,199                title=address, address=address, city=city, sector=sector,200                unit_type=unit_type,201                price=price,202                price_label=f"{price:,.0f} $/mois".replace(",", " "),203                details=details,204                images=images,205                lat=float(key.group(1)) if key else None,206                lng=float(key.group(2)) if key else None,207            ))208        return out209210211def _parse_rlp_office(html: str) -> dict:212    """Extrait le bureau/brokerage (sous-agence) de la fiche détail."""213    m = _OFFICE_RE.search(html)214    if not m:215        return {}216    office = _html.unescape(m.group(1)).strip()217    return {"details": {"Agence": office}} if office else {}218219220def _parse_rlp_detail(html: str) -> dict:221    """Fiche détail complète : bureau + description (« Remarques », avec les222    paragraphes Inclusions/Exclusions — extract_details les minera) +223    galerie complète (originaux pleine résolution, ordre du carrousel) +224    superficie habitable + caractéristiques (tableaux label/valeur)."""225    out = _parse_rlp_office(html)226227    # galerie : toutes les photos de l'annonce (les portraits d'agents sont228    # sous /photos/agents/ et ne matchent pas le motif dossier numérique) ;229    # ordre = index du carrousel, URL sans suffixe = original pleine résolution230    photos: dict[int, str] = {}231    for path, pid, idx in _GALLERY_RE.findall(html):232        photos.setdefault(233            int(idx), f"https://rlp.jumplisting.com/photos/{path}/{pid}_{idx}.jpg")234    if photos:235        out["images"] = [photos[i] for i in sorted(photos)]236237    # caractéristiques structurées (Bâti en, Stationnement, Chauffage…)238    details = out.get("details") or {}239    for label, value in _LABELVAL_RE.findall(html):240        label = _html.unescape(label).strip().rstrip(" :*")241        value = _html.unescape(re.sub(r"\s+", " ", value)).strip().rstrip(",")242        if (not label or not value or "." in label      # clés techniques243                or label.lower() in ("description",)):244            continue245        details.setdefault(label, value)246    if details:247        out["details"] = details248249    tour = du.virtual_tour(html)250    if tour:251        out["virtual_tour"] = tour       # apply_detail -> details.virtual_tour252253    m = _DESC_BLOCK_RE.search(html)254    if m:255        paras = []256        for p in _DESC_P_RE.findall(m.group(1)):257            t = _html.unescape(re.sub(r"<br\s*/?>", "\n", p))258            t = re.sub(r"<[^>]+>", " ", t)259            t = re.sub(r"[ \t]+", " ", t).strip()260            if t:261                paras.append(t)262        if paras:263            out["description"] = "\n\n".join(paras)[:6000]264    m = _AREA_VAL_RE.search(html)265    if m:266        area = parse_area_sqft(m.group(1))267        if area:268            out["area_sqft"] = area269    return out270