SPB Git forge

spb/immo-ka

Public

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

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
12.0 KB · 275 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/logisquebec.py : LogisQuébec (section « à vendre » seulement)5#6#   Portail québécois surtout connu pour la location, mais avec ~4 2007#   propriétés À VENDRE (particuliers + agences syndiquées, ex. Proprio Direct).8#   Pagination serveur classique /a-vendre/{page} (10 cartes/page, ~420 pages),9#   cartes riches (type, prix, rue, ville, extrait, photo). La fiche détail10#   (JSON-LD SingleFamilyResidence + Product) fournit adresse exacte, code11#   postal, géoloc, description et le n° MLS/Centris quand l'annonce vient12#   d'une agence ; galerie i.logisquebec.com séquentielle.13#14#   source_id « logis_ag_quebec » : l'infixe _ag_ active la dédup Centris —15#   les annonces d'agences déjà couvertes (même n° MLS) sont masquées, les16#   annonces de particuliers (id interne vNNNNNN) restent toujours visibles.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import html as _html21import os22import re2324from .base import BaseConnector25from . import _detailutil as du26from ..normalize import parse_price27from ..schema import PropertyListing2829SITE = "https://www.logisquebec.com"30DETAIL_LIMIT = int(os.environ.get("IMMOKA_LOGISQC_DETAIL_LIMIT",31                                  os.environ.get("IMMOKA_DETAIL_LIMIT", "300")))32AGENCY = "LogisQuébec"3334_PAGE_MAX_RE = re.compile(r'href="/a-vendre/(\d+)"')35_CARD_RE = re.compile(36    r'<a href="/([a-z0-9_-]+?)-a-vendre-([a-z0-9_-]+)-v(\d+)"[^>]*'37    r'class="property-result[^"]*"(.*?)</a>', re.S | re.I)38_MLS_RE = re.compile(r"(?:MLS|Centris)[^0-9]{0,15}(\d{7,9})", re.I)39# galerie : la VRAIE galerie est le tableau JS lightGallery « dynamicimgEl »40# dont les src sont RELATIFS (i-a-vendre/<uid>/<vid>/N.jpg, sans https://) —41# un motif ancré sur le domaine ne voit que la photo 1 (og:image), les42# vignettes /thumbnail/ et les couvertures des « propriétés similaires »43# (autres vid). D'où le motif src: + repli absolu filtré sur le vid canonique.44_GALLERY_RE = re.compile(45    r"src:\s*'(?:https://i\.logisquebec\.com/)?(i-a-vendre/[^']+?\.jpe?g)'", re.I)46_PHOTO_RE = re.compile(47    r"https://i\.logisquebec\.com/(i-a-vendre/(\d+)/(\d+)/([^\"'\\ ]+\.jpe?g))", re.I)48_CANON_RE = re.compile(r'canonical"\s+href="[^"]*-v(\d+)"')49_IMG_HOST = "https://i.logisquebec.com/"505152class LogisQuebecConnector(BaseConnector):53    source_id = "logis_ag_quebec"54    request_delay = 0.355    max_pages = 600          # garde-fou (10 cartes/page)5657    def fetch(self) -> list[PropertyListing]:58        by_id: dict[str, PropertyListing] = {}59        last_page, dry = None, 060        for page in range(1, self.max_pages + 1):61            url = f"{SITE}/a-vendre" + (f"/{page}" if page > 1 else "")62            try:63                html = self.get(url).text64            except Exception:65                break66            if last_page is None:67                nums = [int(n) for n in _PAGE_MAX_RE.findall(html)]68                last_page = max(nums) if nums else 169            before = len(by_id)70            for lst in self._cards(html):71                by_id.setdefault(lst.external_id, lst)72            dry = dry + 1 if len(by_id) == before else 073            if dry >= 3 or page >= (last_page or 1):74                break75        listings = list(by_id.values())76        # v2 : galerie filtrée sur le vid de la fiche (v1 mélangeait vignettes77        # et « propriétés similaires »), specs du tableau Libellé|Valeur,78        # description HTML complète, garde-fou GPS Québec. NB : certaines79        # fiches encore listées répondent 410 (fantômes côté site) — l'échec80        # est mis en cache {} et ne sera retenté qu'au prochain bump de clé.81        du.enrich(self, listings, DETAIL_LIMIT, parse_logisqc_detail, key="v2")82        # n° MLS connu -> external_id (dédup Centris contre les bannières)83        out: dict[str, PropertyListing] = {}84        for lst in listings:85            mls = str(lst.details.pop("_mls", "") or "")86            if mls:87                lst.mls = mls88                lst.external_id = mls89            addr = lst.details.pop("_addr", "")90            if addr:91                lst.address = addr92                lst.title = addr93            city = lst.details.pop("_city", "")94            if city:95                lst.city = city96            out.setdefault(lst.external_id, lst)97        return list(out.values())9899    # -- cartes de résultats ----------------------------------------------------100    def _cards(self, html: str):101        for m in _CARD_RE.finditer(html):102            type_s, city_s, vid, blk = m.groups()103104            def grab(pat: str) -> str:105                g = re.search(pat, blk, re.S)106                return _html.unescape(re.sub(r"<[^>]+>", " ", g.group(1))).strip() if g else ""107108            prix = grab(r'class="prix-valeur">([^<]+)<')109            rue = grab(r'class="adresse-rue">([^<]+)<')110            ville = grab(r'class="adresse-ville">([^<]+)<')111            typ = grab(r'class="box-result-unit-12A">([^<]+)<')112            desc = grab(r'class="box-result-unit-3A">(.*?)</div>')113            img = re.search(r'src="(https://i\.logisquebec\.com/[^"]+)"', blk)114            addr = " ".join(filter(None, [rue])) if rue and rue != "enclavé" else ""115            yield PropertyListing(116                source=self.source_id, external_id=vid,117                url=f"{SITE}/{type_s}-a-vendre-{city_s}-v{vid}",118                title=addr or f"{typ} — {ville}",119                address=addr,120                city=ville,121                property_type=typ or type_s.replace("-", " "),122                price=parse_price(prix),123                price_label=prix,124                description=desc[:1000],125                images=[img.group(1)] if img else [],126                agency=AGENCY,127            )128129130# ---------------------------------------------------------------------------131def parse_logisqc_detail(html: str) -> dict:132    """JSON-LD SingleFamilyResidence (adresse/géo/postal) + Product (prix,133    description) ; n° MLS dans le HTML ; galerie i.logisquebec.com complète."""134    out: dict = {}135    details: dict = {}136    for node in du.ld_nodes(html):137        t = node.get("@type")138        if t in ("SingleFamilyResidence", "Residence", "Apartment"):139            addr = node.get("address") or {}140            if addr.get("streetAddress"):141                details["_addr"] = str(addr["streetAddress"])142            if addr.get("addressLocality"):143                details["_city"] = str(addr["addressLocality"])144            if addr.get("postalCode"):145                details["postal_code"] = str(addr["postalCode"])146            geo = node.get("geo") or {}147            try:148                lat, lng = float(geo["latitude"]), float(geo["longitude"])149                # le géocodage du site déraille parfois (Guadeloupe, Floride…) :150                # on ne garde que des coordonnées plausibles pour le Québec151                if 44.0 <= lat <= 63.0 and -80.5 <= lng <= -56.0:152                    out["lat"], out["lng"] = lat, lng153            except (KeyError, TypeError, ValueError):154                pass155            if node.get("numberOfRooms"):156                details["Nombre de pièces"] = node["numberOfRooms"]157        elif t == "Product":158            if node.get("description"):159                out["description"] = _html.unescape(str(node["description"])).strip()[:4000]160            offers = node.get("offers") or {}161            p = offers.get("price") if isinstance(offers, dict) else None162            try:163                out["price"] = float(p)164                out["price_label"] = f"{float(p):,.0f} $".replace(",", " ")165            except (TypeError, ValueError):166                pass167168    mm = _MLS_RE.search(html)169    if mm:170        details["_mls"] = mm.group(1)171172    # galerie : tableau lightGallery (src relatifs, ordre de la fiche, pleine173    # taille) ; repli sur les URL absolues du vid canonique (sans /thumbnail/174    # ni couvertures des « propriétés similaires »)175    mc = _CANON_RE.search(html)176    want = mc.group(1) if mc else None177    seen: set = set()178    imgs = []179    for rel in _GALLERY_RE.findall(html):180        if "/thumbnail/" in rel:181            continue182        u = _IMG_HOST + rel183        if u not in seen:184            seen.add(u)185            imgs.append(u)186    if want:187        imgs = [u for u in imgs if f"/{want}/" in u] or imgs188    if not imgs:189        buckets: dict = {}190        for m in _PHOTO_RE.finditer(html):191            _rel, _uid, vid, fname = m.groups()192            u = m.group(0)193            if fname.lower().startswith("thumbnail") or u in seen:194                continue195            seen.add(u)196            buckets.setdefault(vid, []).append(u)197        if want and want in buckets:198            imgs = buckets[want]199        elif buckets:200            imgs = max(buckets.values(), key=len)201    if imgs:202        out["images"] = imgs203204    # specs du tableau « Libellé | Valeur » (texte aplati) : Chambre à coucher,205    # Salle de bain/d'eau, Pièces, Aire habitable (pc/mc), Dimension terrain…206    # (0 et « -- » = non renseigné). Prose « N chambres » gardée en repli.207    text = du.flatten(html)208    mb = (re.search(r"Chambre à coucher\s*\|\s*(\d{1,2})\b", text)209          or re.search(r"(\d{1,2})\s*chambre", text, re.I))210    if mb and int(mb.group(1)) > 0:211        out["bedrooms"] = int(mb.group(1))212    ms = (re.search(r"Salle de bain/d[’']eau\s*\|\s*(\d{1,2})\b", text)213          or re.search(r"(\d{1,2})\s*salle[s]?\s*de\s*bain", text, re.I))214    if ms and int(ms.group(1)) > 0:215        out["bathrooms"] = int(ms.group(1))216    mp2 = re.search(r"Pièces\s*\|\s*(\d{1,2})\b", text)217    if mp2 and int(mp2.group(1)) > 0:218        details.setdefault("Nombre de pièces", int(mp2.group(1)))219    my = re.search(r"Année de construction\s*\|?\s*:?\s*\|?\s*([12]\d{3})\b", text)220    if my:221        out["year_built"] = int(my.group(1))222223    ma = re.search(r"Aire habitable\s*\|\s*([\d\s.,]+?)\s*(pc|pi2|pi²|pi|mc|m2|m²)\b",224                   text, re.I)225    if ma:226        v = _num(ma.group(1))227        if v:228            out["area_sqft"] = round(v * (10.7639 if ma.group(2).lower()229                                          in ("mc", "m2", "m²") else 1), 0)230231    # « Dimension terrain | 26.02x25.29 mc » (LxW) ou superficie directe232    mt = re.search(r"Dimension terrain\s*\|\s*([\d.,]+)\s*[xX]\s*([\d.,]+)"233                   r"\s*(pc|pi|mc|m)?\b", text)234    if mt:235        a, b = _num(mt.group(1)), _num(mt.group(2))236        if a and b:237            f = 10.7639 if (mt.group(3) or "").lower() in ("mc", "m") else 1238            out["lot_sqft"] = round(a * b * f, 0)239    else:240        mt2 = re.search(r"Dimension terrain\s*\|\s*([\d\s.,]+?)\s*(pc|mc)\b", text)241        if mt2:242            v = _num(mt2.group(1))243            if v:244                out["lot_sqft"] = round(v * (10.7639 if mt2.group(2).lower() == "mc"245                                             else 1), 0)246247    mbat = re.search(r"Dimension bâtiment\s*\|\s*([\d][\d\sxX.,]{1,20}?(?:pc|pi|mc|m)?)\s*\|",248                     text)249    if mbat:250        details["Dimension bâtiment"] = mbat.group(1).strip()251    for lab in ("Étages", "Garage", "Stationnement privé"):252        mv = re.search(re.escape(lab) + r"\s*\|\s*(\d{1,3})\b", text)253        if mv and int(mv.group(1)) > 0:254            details[lab] = int(mv.group(1))255256    # description HTML de la fiche (le JSON-LD Product la tronque souvent)257    mdsc = re.search(r'class="content-annonce-description-text"[^>]*>(.*?)</div>',258                     html, re.S)259    if mdsc:260        d = re.sub(r"\s+", " ",261                   _html.unescape(re.sub(r"<[^>]+>", " ", mdsc.group(1)))).strip()262        if len(d) > len(out.get("description") or ""):263            out["description"] = d[:4000]264265    if details:266        out["details"] = details267    return out268269270def _num(s: str) -> float | None:271    try:272        return float(s.replace(" ", "").replace(" ", "").replace(",", "."))273    except (AttributeError, ValueError):274        return None275