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%
19.6 KB · 468 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/remax_quebec.py : RE/MAX Québec (remax-quebec.com) — LOCATIONS5#   Le site interroge un index Meilisearch public (search-only key exposée dans6#   la config de la page). L'index « inscriptions » couvre toute la province et7#   mélange ventes et locations : on ne garde QUE les slugs « -a-louer » /8#   « -for-rent » (et on écarte les Loué/Vendu que l'index conserve).9#   Meilisearch plafonne à 1000 hits par requête (maxTotalHits) : on shard donc10#   par région de tri d'acheminement postale (FSA, 3 premiers caractères) et on11#   dédoublonne par numéro d'inscription. Adapté du connecteur « à vendre »12#   d'Immo-Ka (agent-courtage/immoka).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import html as _html17import os18import re1920from bs4 import BeautifulSoup2122from ..normalize import parse_area_sqft, parse_price23from ..schema import Listing24from .base import BaseConnector2526from . import _detailutil as du2728SEARCH_URL = "https://search.remax-quebec.com/indexes/inscriptions/search"29# Clé de recherche (search-only) exposée publiquement dans la config du site.30SEARCH_KEY = "b0b93998ab78573e8b937b528ad37d2ce3fbc97e07a9f2c909c4220db910c152"31SITE = "https://www.remax-quebec.com"3233# Territoires postaux du Québec : préfixes G, H et J.34FSA_LETTERS = ("G", "H", "J")35# annonces conclues que l'index conserve encore quelque temps36GONE_TOKENS = ("loué", "loue", "rented", "vendu", "sold")3738# Enrichissement page détail (photos + description + specs) : plafonné par39# exécution pour garder chaque sync borné. Les fiches en cache sont réutilisées40# gratis ; le parc se complète sur plusieurs cycles.41DETAIL_LIMIT = int(os.environ.get("LOUKA_REMAX_DETAIL_LIMIT", "400"))42# Réparation ciblée : re-visite des fiches déjà en cache SANS description43# (payload d'avant le repli « Addenda », ou échec réseau passé) — le44# marqueur desc_checked évite de re-visiter les fiches dont l'absence de45# description est confirmée à la source.46REPAIR_LIMIT = int(os.environ.get("LOUKA_REMAX_REPAIR_LIMIT", "400"))4748# slug « condo-a-louer-montreal/... » -> type affichable. Les appartements /49# condos / plex restent sans unit_type figé : la fiche détail fournit le nombre50# de chambres (converti en n+2½ par la normalisation lou-ka).51_TYPE_SLUG = {52    "maison": "Maison", "house": "Maison",53    "chalet": "Chalet", "cottage": "Chalet",54    "loft": "Loft",55    "condo": "Condo", "appartement": "Appartement", "apartment": "Appartement",56    "plex": "Multiplex", "duplex": "Duplex", "triplex": "Triplex",57}58# ce qui n'est pas un logement (lou-ka = résidentiel locatif uniquement)59_EXCLUDE_SLUG = ("commercial", "commerciale", "terrain", "land", "industriel",60                 "bureau", "ferme", "fermette")61_RENT_SLUG_RE = re.compile(r"-(?:a-louer|for-rent)\b")626364class RemaxQuebecConnector(BaseConnector):65    source_id = "remax_quebec"66    request_delay = 0.4           # partagé API de recherche / pages détail6768    def fetch(self) -> list[Listing]:69        by_id: dict[int, dict] = {}70        for fsa in self._fsa_candidates():71            for h in self._search(fsa):72                nid = h.get("no_inscription")73                if nid is not None:74                    by_id[nid] = h        # dédoublonnage inter-shards75        listings = []76        for h in by_id.values():77            lst = self._to_listing(h)78            if lst is not None:79                listings.append(lst)80        du.enrich(self, listings, DETAIL_LIMIT, parse_remax_detail, key="v1")81        self._repair_missing_desc(listings)82        for lst in listings:83            _unit_from_bedrooms(lst)84        return listings8586    def _repair_missing_desc(self, listings: list[Listing]) -> None:87        """Re-visite les fiches en cache restées sans description : payloads88        d'avant le repli « Addenda » du parseur, ou payloads vides (échec89        réseau passé). Le nouveau parseur pose desc_checked=True, donc une90        fiche réellement sans addenda n'est re-visitée qu'une seule fois.91        Le reste du cache (fiches déjà riches) n'est pas invalidé."""92        from .. import db93        budget = REPAIR_LIMIT94        if budget <= 0:95            return96        con = db.connect()97        try:98            for lst in listings:99                if budget <= 0:100                    break101                if lst.description:102                    continue103                cached = db.get_cached_detail(con, self.source_id,104                                              lst.external_id, "v1")105                if cached is None:          # jamais visitée : du.enrich s'en charge106                    continue107                if cached.get("description") or cached.get("desc_checked"):108                    continue109                budget -= 1110                try:111                    payload = parse_remax_detail(self.get(lst.url).text)112                except Exception:113                    continue                 # erreur réseau : on réessaiera114                db.put_cached_detail(con, self.source_id, lst.external_id,115                                     "v1", payload)116                du.apply_detail(lst, payload)117        finally:118            con.close()119120    # -- sharding --------------------------------------------------------------121    @staticmethod122    def _fsa_candidates():123        for letter in FSA_LETTERS:124            for digit in "0123456789":125                for last in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":126                    yield f"{letter}{digit}{last}"127128    def _search(self, q: str) -> list[dict]:129        try:130            resp = self.post(131                SEARCH_URL,132                headers={"Authorization": f"Bearer {SEARCH_KEY}",133                         "Content-Type": "application/json"},134                json={"q": q, "limit": 1000},135            )136        except Exception:137            return []138        data = resp.json()139        return data.get("hits", []) if isinstance(data, dict) else []140141    # -- mapping ---------------------------------------------------------------142    def _to_listing(self, h: dict) -> Listing | None:143        nid = h.get("no_inscription")144        if nid is None:145            return None146        slug = (h.get("slug") or {}).get("fr", "") or ""147        slug_en = (h.get("slug") or {}).get("en", "") or ""148        # UNIQUEMENT les locations (l'index mélange ventes et locations) ;149        # l'URL de la fiche exige le slug français150        if not slug or not (_RENT_SLUG_RE.search(slug) or _RENT_SLUG_RE.search(slug_en)):151            return None152        price_label = (h.get("display_price") or {}).get("fr", "") or ""153        # exclure les logements déjà loués / retirés (l'index les conserve)154        if any(tok in price_label.lower() for tok in GONE_TOKENS):155            return None156        prop_type, region = _from_slug(slug)157        if prop_type is None:              # commercial/terrain : pas un logement158            return None159160        url = f"{SITE}/fr/proprietes/{slug}"161        full_addr = (h.get("full_address") or {}).get("fr", "") or ""162        address, sector, city = _split_address(full_addr)163164        details: dict = {"MLS": str(nid)}165        if prop_type:166            details["Type"] = prop_type167        if region:168            details["Région"] = region169        return Listing(170            source=self.source_id,171            external_id=str(nid),172            url=url,173            title=address or full_addr,174            address=address,175            sector=sector,176            city=city,177            # appartement/condo/plex : la fiche détail donne les chambres178            unit_type=prop_type if prop_type in ("Maison", "Chalet", "Loft") else "",179            price=parse_price(price_label),     # « 2 690$ par mois » -> 2690.0180            price_label=price_label,181            details=details,182        )183184185def _unit_from_bedrooms(lst: Listing) -> None:186    """Condo/appartement : « Chambres » de la fiche détail -> unit_type187    « n chambres » (la normalisation lou-ka convertit en n+2½)."""188    if lst.unit_type not in ("", "Condo", "Appartement"):189        return190    m = re.match(r"\d+", str(lst.details.get("Chambres", "")))191    if not m:192        if not lst.unit_type:193            lst.unit_type = lst.details.get("Type", "")194        return195    n = int(m.group(0))196    lst.unit_type = "Studio" if n == 0 else f"{n} chambres"197198199# ---------------------------------------------------------------------------200# Analyse de l'adresse et du slug201# ---------------------------------------------------------------------------202203_PAREN_RE = re.compile(r"\(([^)]*)\)")204_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I)205206207def _split_address(full: str) -> tuple[str, str, str]:208    """« 9561 Boul. Perras, Montréal (Rivière-des-Prairies/…) (RDP), H1E4C4 »209    -> (adresse, secteur, ville)."""210    if not full:211        return "", "", ""212    parts = [p.strip() for p in full.split(",")]213    # retirer le code postal final214    if parts and _POSTAL_RE.search(parts[-1]):215        parts = parts[:-1]216    address = parts[0] if parts else ""217    city = sector = ""218    if len(parts) >= 2:219        muni = parts[1]220        parens = _PAREN_RE.findall(muni)221        city = _PAREN_RE.sub("", muni).strip()222        if parens:223            sector = parens[-1].strip()224        # secteur supplémentaire dans les champs suivants (avant le postal)225        for extra in parts[2:]:226            e = _PAREN_RE.sub("", extra).strip() or extra.strip()227            if e and not sector:228                sector = e229    return address, sector, city230231232def _from_slug(slug: str) -> tuple[str | None, str]:233    """slug « condo-a-louer-montreal/9561-boul-perras-…-20357732 »234    -> (type affichable, région). Type None = pas un logement (commercial…)."""235    if not slug:236        return "", ""237    head = slug.split("/", 1)[0]           # condo-a-louer-montreal238    if any(re.search(rf"\b{x}", head) for x in _EXCLUDE_SLUG):239        return None, ""240    prop_type = ""241    for key, canon in _TYPE_SLUG.items():242        if re.search(rf"\b{key}", head):243            prop_type = canon244            break245    # région = ce qui suit « -a-louer-/-for-rent- »246    m = re.search(r"(?:a-louer|for-rent)-(.+)$", head)247    region = m.group(1).replace("-", " ").title() if m else ""248    return prop_type, region249250251# ---------------------------------------------------------------------------252# Page détail : photos + description + caractéristiques + courtier253# ---------------------------------------------------------------------------254255# Les photos apparaissent dans plusieurs buckets de taille (www_full, _medium,256# _small…) selon le lazy-load ; on les capte toutes et on les normalise en257# pleine résolution, dédoublonnées par nom de fichier.258_IMG_RE = re.compile(259    r'https://media\.remax-quebec\.com/img/www_[a-z]+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',260    re.I)261_IMG_SIZE_RE = re.compile(r"/www_[a-z]+/", re.I)262_COORD_RE = re.compile(r"query=(-?\d+\.\d+)%2C\+?(-?\d+\.\d+)")263# en tête de fiche, la valeur précède le libellé : « 2 | Chambres », « 1 | Salle de bain »264_BED_RE = re.compile(r"(\d+)(?:\s*\([^)]*\))?\s*\|\s*Chambres?\b", re.I)265_BATH_RE = re.compile(r"(\d+)\s*\|\s*Salles? de bain", re.I)266_WATER_RE = re.compile(r"(\d+)\s*\|\s*Salles? d'eau", re.I)267_QSTAT_RE = re.compile(268    r"quick-stat-text[^>]*>\s*<span[^>]*>\s*(.*?)\s*</span>\s*<span[^>]*>\s*(.*?)\s*</span>", re.S)269_INCL_RE = re.compile(270    r"inclusions-exclusions-section(.*?)(?:</section>|realtor-section|financial-section)", re.S)271_ROOM_RE = re.compile(r"rooms-details-section__vertical-table[^>]*>(.*?)</div>\s*</div>", re.S)272273# Sur les fiches LOCATION, le tableau « Particularités » est en ordre274# libellé | valeur (l'inverse des fiches vente) : on privilégie donc cet ordre275# et du.centris_details ne sert que de repli.276_RENT_LABELS = [277    "Type de propriété", "Genre de propriété", "Style de bâtiment",278    "Année de construction", "Superficie habitable", "Superficie du terrain",279    "Nombre de pièces", "Nombre d'unités", "Stationnement (total)", "Garage",280    "Mode de chauffage", "Système de chauffage", "Énergie pour le chauffage",281    "Approvisionnement en eau", "Système d'égouts", "Piscine", "Déménagement",282    "Date d'emménagement", "Disponibilité", "Bail", "Durée du bail", "Meublé",283    "Animaux", "Inclus dans le loyer", "Cuisine",284]285286287# une « valeur » qui est en fait un titre de section = extraction décalée288_SECTION_VAL_RE = re.compile(289    r"^(?:Particularit[ée]s|Caract[ée]ristiques|Inclusions|Exclusions|Addenda)",290    re.I)291292293def _labels_first(text: str, labels: list[str]) -> dict:294    """Extraction « libellé | valeur » (ordre des fiches location RE/MAX)."""295    out: dict = {}296    for label in labels:297        boundary = r"\b" if label[-1].isalnum() else ""298        m = re.search(re.escape(label) + boundary + r"\s*\|\s*([^|]{1,55})", text)299        if m:300            val = m.group(1).strip(" |")301            if (val and 1 <= len(val) <= 55 and val.lower() != label.lower()302                    and val not in labels and not _SECTION_VAL_RE.match(val)):303                out[label] = val304    return out305306307def _drop_section_values(details: dict) -> None:308    """Purge les valeurs qui sont des titres de section (repli valeur|libellé309    de du.centris_details décalé sur les fiches en ordre libellé|valeur)."""310    for k in [k for k, v in details.items()311              if isinstance(v, str) and _SECTION_VAL_RE.match(v)]:312        del details[k]313314315def _ld_agents(html: str):316    """Nœuds RealEstateAgent (dans l'ordre du document), même imbriqués."""317    for n in du.ld_nodes(html):318        queue = [n]319        while queue:320            cur = queue.pop(0)321            if isinstance(cur, dict):322                if cur.get("@type") == "RealEstateAgent" and cur.get("name"):323                    yield cur324                queue.extend(cur.values())325            elif isinstance(cur, list):326                queue.extend(cur)327328329def _addenda_description(html: str) -> str:330    """Repli quand le JSON-LD n'a pas de description : le texte du courtier331    vit alors dans les accordéons « Description » / « Addenda » de la fiche."""332    soup = BeautifulSoup(html, "html.parser")333    parts: list[str] = []334    for cls in ("description-section", "addenda-section"):335        for node in soup.select(f"div.{cls} .accordion__content"):336            txt = _html.unescape(node.get_text("\n", strip=True))337            txt = re.sub(r"[ \t]+", " ", txt)338            txt = re.sub(r"\n{3,}", "\n\n", txt).strip()339            if len(txt) >= 40 and txt not in parts:340                parts.append(txt)341    return "\n\n".join(parts)342343344def parse_remax_detail(html: str) -> dict:345    """Fiche RE/MAX location : description JSON-LD (repli : accordéons346    Description/Addenda), galerie pleine résolution, GPS, caractéristiques347    Centris, pièces, inclusions, courtier/agence."""348    # desc_checked : marqueur de cache — la présence de description a été349    # vérifiée AVEC le repli Addenda (évite les re-visites infinies quand la350    # fiche n'a réellement aucun texte)351    out: dict = {"desc_checked": True}352    details: dict = {}353354    # description via JSON-LD RealEstateListing, sinon accordéon Addenda355    desc = du.ld_description(html) or _addenda_description(html)356    if desc:357        out["description"] = desc[:6000]358359    # photos : toutes tailles -> pleine résolution, dédoublonnées par fichier360    seen, images = set(), []361    for u in _IMG_RE.findall(html):362        full = _IMG_SIZE_RE.sub("/www_full/", u)363        fn = full.rsplit("/", 1)[-1]364        if fn not in seen and "nophoto" not in full:365            seen.add(fn)366            images.append(full)367    if images:368        out["images"] = images369370    # coordonnées GPS (lien Google Maps)371    m = _COORD_RE.search(html)372    if m:373        lat, lng = float(m.group(1)), float(m.group(2))374        if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:375            out["lat"], out["lng"] = lat, lng376377    # courtier + agence via JSON-LD RealEstateAgent (souvent imbriqué)378    for n in _ld_agents(html):379        details.setdefault("Courtier", str(n["name"]).strip().title())380        if n.get("telephone"):381            details.setdefault("Téléphone", str(n["telephone"]).strip())382        org = n.get("parentOrganization") or {}383        org_name = org.get("name", "") if isinstance(org, dict) else ""384        if org_name:385            agence = str(org_name).strip().title() \386                .replace("Re/Max", "RE/MAX").replace("Inc.", "inc.")387            details.setdefault("Agence", agence)388389    text = du.flatten(html)390391    # chambres / salles de bain (en tête : « 2 | Chambres », « 1 | Salle de bain »)392    m = _BED_RE.search(text)393    if m:394        details["Chambres"] = m.group(1)395    m = _BATH_RE.search(text)396    if m:397        details["Salles de bain"] = m.group(1)398    m = _WATER_RE.search(text)399    if m:400        details["Salles d'eau"] = m.group(1)401402    # caractéristiques : repli valeur|libellé (du), écrasé par libellé|valeur403    details.update(du.centris_details(text))404    details.update(_labels_first(text, _RENT_LABELS))405    _drop_section_values(details)406    # « Cuisine » happé depuis le tableau des pièces (dimensions/niveau) : retirer407    if re.search(r"\d\s*[xX]\s*\d|Niveau|^\d+$", str(details.get("Cuisine", ""))):408        details.pop("Cuisine", None)409410    # quick stats (Nb de pièces, Superficie habitable…)411    for label, val in _QSTAT_RE.findall(html):412        label = _html.unescape(re.sub(r"\s+", " ", label)).strip()413        val = _html.unescape(re.sub(r"\s+", " ", val)).strip()414        if label and val and label not in details:415            details[label] = val416417    # pièces avec dimensions/niveau/revêtement418    rooms = []419    for blk in _ROOM_RE.findall(html):420        rt = _html.unescape(re.sub(r"<[^>]+>", " ", blk))421        rt = re.sub(r"\s+", " ", rt).strip()422        name = re.split(r"Niveau\s*:", rt)[0].strip()423        niveau = re.search(r"Niveau\s*:\s*([^:]+?)(?:Dimensions|Revêtement|$)", rt)424        dim = re.search(r"Dimensions\s*:\s*([0-9\'\".,X x×]+)", rt)425        rev = re.search(r"Revêtement\s*:\s*([A-Za-zÀ-ÿ ,-]+?)(?:\s*Détails|\s*$)", rt)426        if name:427            rooms.append({428                "nom": name[:40],429                "niveau": (niveau.group(1).strip() if niveau else ""),430                "dimensions": (dim.group(1).strip() if dim else ""),431                "revetement": (rev.group(1).strip() if rev else ""),432            })433    if rooms:434        details["pieces"] = rooms[:20]435436    # inclusions / exclusions -> commodités affichables437    mi = _INCL_RE.search(html)438    feats = []439    if mi:440        blk = _html.unescape(re.sub(r"<[^>]+>", "\n", mi.group(1)))441        for line in blk.split("\n"):442            line = line.strip(" \t•-")443            if 3 <= len(line) <= 120 and not line.lower().startswith(444                    ("inclusion", "exclusion")):445                feats.append(line)446    if feats:447        out["amenities"] = feats[:25]448449    # superficie habitable (quick stat ou tableau) -> pi²450    if details.get("Superficie habitable"):451        out["area_sqft"] = parse_area_sqft(details["Superficie habitable"])452    # date d'emménagement / déménagement -> disponibilité453    for k in ("Date d'emménagement", "Déménagement", "Disponibilité"):454        if details.get(k):455            out["availability"] = details[k]456            break457    # chambres -> type d'unité (les fiches liste condo/appartement sont vides)458    if details.get("Chambres"):459        try:460            n = int(details["Chambres"])461            out["unit_type"] = "Studio" if n == 0 else f"{n} chambres"462        except ValueError:463            pass464465    if details:466        out["details"] = details467    return out468