SPB Git

spb/immo-ka Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

Python 66.4% TypeScript 19.9% CSS 13.2% HTML 0.5%
9.8 KB · 244 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/remax_agences.py : BACKUP RE/MAX — un connecteur par sous-agence5#6#   Les ~54 bannières RE/MAX du Québec (RE/MAX 1er Choix, du Cartier, 3000,7#   Platine…) ont chacune leur SITE PROPRE, indépendant du portail central8#   remax-quebec.com. La majorité (42) partagent la même plateforme Centris9#   (« /fr/nos-proprietes/{ville}/{adresse}/{no-centris} », rendu serveur,10#   sitemap.xml exhaustif). C'est un plan B si la clé Meilisearch centrale11#   (voir remax_quebec.py) est rotée ou bloquée.12#13#   Une CLASSE PAR AGENCE est générée dynamiquement depuis14#   data/remax_agencies.json (source_id « remax_ag_<slug> »). L'énumération15#   se fait via le sitemap (1 requête → toutes les propriétés) ; le numéro16#   Centris (dans l'URL) sert de clé de déduplication contre le flux central17#   (voir la fusion dédupliquée dans immoka/web.py) — aucun double-comptage.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import json22import os23import re24from pathlib import Path2526from .base import BaseConnector27from ..schema import PropertyListing2829REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "remax_agencies.json"3031# Enrichissement page détail (prix/chambres/photos) : coûteux (1 requête par32# propriété). Désactivé par défaut : le flux central est déjà enrichi et ces33# fiches sont dédoublonnées contre lui. Activable par agence si le central tombe.34DETAIL_LIMIT = int(os.environ.get("IMMOKA_AGENCE_DETAIL_LIMIT", "0"))3536_PROP_RE = re.compile(r'/fr/nos-proprietes/([a-z0-9-]+)/([^<"/]+)/(\d+)', re.I)37# 2e plateforme Centris (« centris-other ») : sitemap_inscriptions.xml, URL38#   /fr/proprietes/{type-ville-secteur}/{adresse}-{no-centris}39_PROP_RE2 = re.compile(r'/fr/proprietes/([a-z0-9-]+)/([a-z0-9-]+?)-(\d{6,})(?:/)?(?=["<]|$)', re.I)40# préfixes de type de propriété (les plus longs d'abord) pour découper le 1er segment41_TYPE_PREFIXES = [42    "batisse-commerciale-bureau", "chalet-et-maison-de-campagne",43    "maison-a-paliers-multiples", "maison-de-plain-pied", "maison-a-etages",44    "propriete-a-revenus", "local-commercial", "fond-de-commerce",45    "terre-agricole", "maison-mobile", "quadruplex", "quintuplex",46    "appartement", "condominium", "fermette", "triplex", "duplex", "chalet",47    "terrain", "maison", "condo", "commerce", "bureau", "industriel", "ferme",48    "garage", "loft", "terre",49]50_IMG_RE = re.compile(51    r'https://media\.remax-quebec\.com/img/(?:fiche|www_full|www_large|v4thumbs)/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',52    re.I)53_IMG_SIZE_RE = re.compile(r'/img/(?:fiche|www_[a-z]+|v4thumbs)/', re.I)545556class RemaxAgenceConnector(BaseConnector):57    """Base des connecteurs de sous-agences RE/MAX (plateforme « nos-proprietes »).5859    Les sous-classes (générées plus bas) définissent `source_id`, `domain`60    et `agency_name`.61    """6263    domain: str = ""64    agency_name: str = ""65    platform: str = "nos-proprietes"   # ou "centris-other"66    request_delay = 0.467    use_detail_cache = True6869    def fetch(self) -> list[PropertyListing]:70        urls = self._sitemap_property_urls()71        by_id: dict[str, PropertyListing] = {}72        for url in urls:73            lst = self._to_listing(url)74            if lst is not None:75                by_id[lst.external_id] = lst76        if DETAIL_LIMIT:77            self._enrich(list(by_id.values()))78        return list(by_id.values())7980    def _sitemap_url(self) -> str:81        return ("sitemap_inscriptions.xml" if self.platform == "centris-other"82                else "sitemap.xml")8384    def _sitemap_property_urls(self) -> list[str]:85        sm = self._sitemap_url()86        xml = ""87        for host in (self.domain, f"www.{self.domain}"):88            try:89                xml = self.get(f"https://{host}/{sm}").text90                if xml:91                    break92            except Exception:93                continue94        if not xml:95            return []96        rx = _PROP_RE2 if self.platform == "centris-other" else _PROP_RE97        seen, out = set(), []98        for m in rx.finditer(xml):99            centris = m.groups()[-1]100            if centris not in seen:101                seen.add(centris)102                out.append(m.group(0))103        return out104105    def _to_listing(self, path: str) -> PropertyListing | None:106        if self.platform == "centris-other":107            return self._to_listing_centris(path)108        m = _PROP_RE.search(path)109        if not m:110            return None111        city_slug, addr_slug, centris = m.group(1), m.group(2), m.group(3)112        url = f"https://{self.domain}{path if path.startswith('/') else '/' + path}"113        city, sector = _split_city(city_slug)114        return PropertyListing(115            source=self.source_id, external_id=centris, url=url,116            title=_deslug(addr_slug), address=_deslug(addr_slug),117            city=city, sector=sector, mls=centris, broker_name=self.agency_name,118        )119120    def _to_listing_centris(self, url: str) -> PropertyListing | None:121        m = _PROP_RE2.search(url)122        if not m:123            return None124        seg1, addr_slug, centris = m.group(1), m.group(2), m.group(3)125        ptype, rest = _split_type(seg1)      # type + « ville-secteur »126        city, sector = _split_city(rest)127        return PropertyListing(128            source=self.source_id, external_id=centris,129            url=url if url.startswith("http") else f"https://{self.domain}{url}",130            title=_deslug(addr_slug), address=_deslug(addr_slug),131            property_type=ptype, city=city, sector=sector,132            mls=centris, broker_name=self.agency_name,133        )134135    # -- enrichissement optionnel (prix/chambres/photos) ----------------------136    def _enrich(self, listings: list[PropertyListing]) -> None:137        from .. import db138        con = db.connect()139        budget = DETAIL_LIMIT140        for lst in listings:141            cached = db.get_cached_detail(con, self.source_id, lst.external_id, "d")142            if cached is None:143                if budget <= 0:144                    continue145                cached = self._scrape_detail(lst.url)146                db.put_cached_detail(con, self.source_id, lst.external_id, "d", cached)147                budget -= 1148            _apply(lst, cached)149        con.close()150151    def _scrape_detail(self, url: str) -> dict:152        try:153            html = self.get(url).text154        except Exception:155            return {}156        out: dict = {}157        seen, imgs = set(), []158        for u in _IMG_RE.findall(html):159            full = _IMG_SIZE_RE.sub("/img/www_full/", u)160            fn = full.rsplit("/", 1)[-1]161            if fn not in seen and "nophoto" not in full:162                seen.add(fn)163                imgs.append(full)164        if imgs:165            out["images"] = imgs166        import html as _h167        t = _h.unescape(re.sub(r"<[^>]+>", " ", html))168        t = re.sub(r"\s+", " ", t)169        mp = re.search(r"([\d  ]{4,})\s*\$", t)170        if mp:171            out["price_label"] = mp.group(0).strip()172        mb = re.search(r"(\d+)\s*chambre", t, re.I)173        if mb:174            out["bedrooms"] = int(mb.group(1))175        ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", t, re.I)176        if ms:177            out["bathrooms"] = int(ms.group(1))178        return out179180181def _apply(lst: PropertyListing, d: dict) -> None:182    if d.get("images"):183        lst.images = d["images"]184    if d.get("price_label") and not lst.price_label:185        lst.price_label = d["price_label"]186    for f in ("bedrooms", "bathrooms"):187        if d.get(f) is not None and getattr(lst, f, None) is None:188            setattr(lst, f, d[f])189190191def _deslug(s: str) -> str:192    return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()193194195def _split_type(seg1: str) -> tuple[str, str]:196    """« propriete-a-revenus-laval-laval-des-rapides » -> (type, « laval-... »)."""197    for pref in _TYPE_PREFIXES:198        if seg1.startswith(pref):199            return _deslug(pref), seg1[len(pref):].lstrip("-")200    # pas de type reconnu : tout est considéré comme localisation201    return "", seg1202203204# quelques villes composées fréquentes dans les slugs de la plateforme205def _split_city(slug: str) -> tuple[str, str]:206    parts = slug.split("-")207    city = parts[0].title() if parts else ""208    fixes = {"Quebec": "Québec", "Levis": "Lévis", "Montreal": "Montréal",209             "Trois": "Trois-Rivières", "St": "Saint"}210    city = fixes.get(city, city)211    sector = _deslug("-".join(parts[1:])) if len(parts) > 1 else ""212    return city, sector213214215# ---------------------------------------------------------------------------216# Génération d'une classe par sous-agence (plateforme « nos-proprietes »)217# ---------------------------------------------------------------------------218219def _slugify_id(banner: str) -> str:220    return "remax_ag_" + re.sub(r"[^a-z0-9]+", "_", banner.replace("remax-", "")).strip("_")221222223def _load_agencies() -> list[dict]:224    try:225        return json.loads(REGISTRY.read_text(encoding="utf-8"))226    except Exception:227        return []228229230# Crée dynamiquement RemaxAg<...> pour chaque agence dotée d'un site propre,231# sur l'une des deux plateformes Centris reconnues (nos-proprietes / centris-other).232for _ag in _load_agencies():233    _plat = _ag.get("platform")234    if _plat not in ("nos-proprietes", "centris-other") or not _ag.get("domain"):235        continue236    _dom = re.sub(r"^https?://", "", _ag["domain"]).rstrip("/")237    _sid = _slugify_id(_ag["banner"])238    _name = "RE/MAX " + _ag["banner"].replace("remax-", "").replace("-", " ").title()239    globals()[_sid.upper()] = type(240        "".join(p.title() for p in _sid.split("_")),241        (RemaxAgenceConnector,),242        {"source_id": _sid, "domain": _dom, "agency_name": _name, "platform": _plat},243    )244