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%
10.6 KB · 259 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/_detailutil.py : utilitaires partagés d'enrichissement « page détail »5#   Mutualise ce que les connecteurs de portails répètent pour capter TOUTES les6#   infos de la fiche source : description JSON-LD, coordonnées, application7#   au Listing avec cache BD et budget de requêtes par synchronisation.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import html as _html12import json13import re1415from ..schema import Listing1617_LD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)18_COORD_RE = re.compile(r'(?:google\.[^"\']*?[?&](?:q|query|ll|center)=|maps/@)'19                       r'(-?\d{1,2}\.\d+)[ ,%+A-Za-z]+?(-?\d{2,3}\.\d+)')20_LD_PROP_TYPES = {"RealEstateListing", "Residence", "SingleFamilyResidence",21                  "House", "Apartment", "Product", "Offer", "Place", "Accommodation"}222324def ld_nodes(html: str):25    """Itère les objets JSON-LD (aplatis depuis @graph)."""26    for block in _LD_RE.findall(html):27        try:28            data = json.loads(block)29        except ValueError:30            continue31        graph = data.get("@graph", [data]) if isinstance(data, dict) else data32        for node in (graph if isinstance(graph, list) else [graph]):33            if isinstance(node, dict):34                yield node353637def ld_description(html: str) -> str:38    """Description depuis un nœud JSON-LD de type propriété (le plus long trouvé)."""39    best = ""40    for n in ld_nodes(html):41        t = n.get("@type")42        types = t if isinstance(t, list) else [t]43        if any(x in _LD_PROP_TYPES for x in types) and n.get("description"):44            d = _html.unescape(str(n["description"])).strip()45            if len(d) > len(best):46                best = d47    return best484950# plateformes de visite virtuelle reconnues dans le HTML d'une fiche51_VTOUR_RE = re.compile(52    r'https?://(?:my\.)?(?:matterport\.com/show/[^"\'\s<>]+'53    r'|(?:www\.)?youriguide\.com/[^"\'\s<>]+'54    r'|(?:www\.)?klapty\.com/tour/[^"\'\s<>]+'55    r'|kuula\.co/(?:share|post)/[^"\'\s<>]+'56    r'|tours?\.[a-z0-9-]+\.(?:com|ca)/[^"\'\s<>]*(?:tour|visite)[^"\'\s<>]*'57    r'|(?:www\.)?realvision\.com/[^"\'\s<>]+)', re.I)585960def virtual_tour(html: str) -> str | None:61    """Première URL de visite virtuelle (Matterport, iGUIDE, Klapty, Kuula…)62    trouvée dans le HTML d'une fiche. None si absente — jamais inventée."""63    m = _VTOUR_RE.search(html or "")64    if not m:65        return None66    return _html.unescape(m.group(0)).replace("\\/", "/").rstrip("\\").rstrip('&')676869def gmaps_coords(html: str) -> tuple[float, float] | None:70    m = _COORD_RE.search(html)71    if not m:72        return None73    try:74        lat, lng = float(m.group(1)), float(m.group(2))75    except ValueError:76        return None77    if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:78        return lat, lng79    return None808182def flatten(html: str) -> str:83    """HTML -> texte « valeur | libellé » pour extraire les tableaux de fiches."""84    t = _html.unescape(re.sub(r"<[^>]+>", " | ", html))85    t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t)86    return re.sub(r"[ \t]+", " ", t)878889# libellés Centris standard (fiches de courtiers) cherchés dans un texte aplati90# « valeur | libellé » OU « libellé | valeur » — version location91_LABELS = [92    "Type de propriété", "Genre de propriété", "Style de bâtiment",93    "Année de construction", "Superficie habitable", "Superficie du terrain",94    "Nombre de pièces", "Nombre d'unités", "Stationnement (total)",95    "Stationnement", "Garage", "Système de chauffage",96    "Énergie pour le chauffage", "Piscine", "Déménagement", "Date d'emménagement",97    "Disponibilité", "Bail", "Durée du bail", "Meublé", "Animaux",98    "Inclus dans le loyer", "Cuisine",99]100101102def centris_details(text: str) -> dict:103    """Extrait les caractéristiques Centris d'un texte aplati (les deux ordres)."""104    out: dict = {}105    for label in _LABELS:106        lab = re.escape(label)107        m = (re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)108             or re.search(lab + r"\b\s*\|\s*([^|]{1,55})", text))109        if m:110            val = m.group(1).strip(" |")111            if val and 1 <= len(val) <= 55 and val.lower() != label.lower():112                out[label] = val113    return out114115116def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):117    """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`.118119    - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail.120    - `key` : versionne le cache (changer pour forcer un rafraîchissement).121    - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer122      connector.get_rendered / get_scrapfly pour les sites derrière anti-bot.123    Le budget `limit` fait que chaque sync n'enrichit qu'un quota de fiches ;124    le parc se complète sur plusieurs cycles (le cache est permanent tant que125    la clé ne change pas).126    """127    if limit <= 0:128        return129    from .. import db130    fetch_html = fetch_html or (lambda u: connector.get(u).text)131    con = db.connect()132    budget = limit133    try:134        for lst in listings:135            cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key)136            if cached is None:137                if budget <= 0:138                    continue139                try:140                    cached = parse_fn(fetch_html(lst.url))141                except Exception:142                    cached = {}143                db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached)144                budget -= 1145            apply_detail(lst, cached)146    finally:147        con.close()148149150class TtlDetailCache:151    """Cache BD des pages détail AVEC durée de vie, pour les portails où la152    fiche est la seule source de données (LogisQuébec, DuProprio…).153154    Contrairement à enrich() (cache permanent tant que la clé ne change pas),155    chaque fiche est re-visitée après `ttl_days` pour capter les changements156    de prix/disponibilité — dans la limite de `budget` requêtes par157    synchronisation. Budget épuisé : le payload périmé est réutilisé tel quel158    (jamais de trou de données) ; une fiche jamais visitée retourne None et159    sera captée à une synchronisation suivante.160    """161162    def __init__(self, connector, budget: int, ttl_days: float = 7.0,163                 key: str = "v1", fetch_html=None) -> None:164        from .. import db165        self.connector = connector166        self.con = db.connect()167        self.budget = budget168        self.ttl = ttl_days * 86400169        self.key = key170        self.fetch_html = fetch_html or (lambda u: connector.get(u).text)171172    def peek(self, external_id: str) -> tuple[dict | None, bool]:173        """(payload en cache, frais ?) SANS déclencher de requête ni toucher174        au budget — permet aux connecteurs de planifier des lots parallèles."""175        import time as _time176        row = self.con.execute(177            "SELECT key, payload, fetched_at FROM detail_cache"178            " WHERE source=? AND external_id=?",179            (self.connector.source_id, str(external_id))).fetchone()180        stale = None181        if row and row["payload"]:182            try:183                stale = json.loads(row["payload"])184            except ValueError:185                stale = None186            if (stale is not None and row["key"] == self.key187                    and _time.time() - (row["fetched_at"] or 0) < self.ttl):188                return stale, True               # frais : aucun trafic requis189        return stale, False190191    def put(self, external_id: str, payload: dict) -> None:192        """Écrit un payload obtenu hors de get() (ex. téléchargement en lot)."""193        from .. import db194        db.put_cached_detail(self.con, self.connector.source_id,195                             str(external_id), self.key, payload)196197    def get(self, external_id: str, url: str, parse_fn) -> dict | None:198        from .. import db199        stale, fresh = self.peek(external_id)200        if fresh:201            return stale                         # frais : aucun trafic202        if self.budget <= 0:203            return stale                         # périmé toléré / None si jamais vu204        self.budget -= 1205        try:206            payload = parse_fn(self.fetch_html(url)) or {}207        except Exception as exc:                 # 404/410 = fiche retirée208            code = getattr(getattr(exc, "response", None), "status_code", None)209            if code in (404, 410):210                payload = {"gone": True}211            else:212                return stale                     # erreur réseau : on garde l'ancien213        db.put_cached_detail(self.con, self.connector.source_id,214                             str(external_id), self.key, payload)215        return payload216217    def close(self) -> None:218        try:219            self.con.close()220        except Exception:221            pass222223224def apply_detail(lst: Listing, d: dict) -> None:225    """Applique un payload détail au Listing sans écraser les valeurs déjà226    présentes (sauf images : on garde la plus grande galerie)."""227    if not d:228        return229    imgs = d.get("images")230    if imgs and len(imgs) > len(lst.images):231        lst.images = imgs232    if d.get("amenities"):233        seen = {a.lower() for a in lst.amenities}234        for a in d["amenities"]:235            if a.lower() not in seen:236                lst.amenities.append(a)237                seen.add(a.lower())238    if d.get("details"):239        merged = dict(d["details"])240        merged.update(lst.details)      # les valeurs du connecteur priment241        lst.details = merged242    # description : on garde la plus riche (la fiche détail bat le résumé liste)243    if d.get("description") and len(d["description"]) > len(lst.description or ""):244        lst.description = d["description"]245    for f in ("price_label", "address", "city", "sector", "unit_type",246              "availability", "title"):247        if d.get(f) and not getattr(lst, f, ""):248            setattr(lst, f, d[f])249    for f in ("price", "area_sqft", "lat", "lng", "availability_date",250              "pets", "furnished"):251        if d.get(f) is not None and getattr(lst, f, None) is None:252            setattr(lst, f, d[f])253    from ..normalize import coerce_count254    for f in ("bedrooms", "bathrooms"):255        if d.get(f) is not None and getattr(lst, f, None) is None:256            setattr(lst, f, coerce_count(d[f]))257    if d.get("virtual_tour") and not lst.details.get("virtual_tour"):258        lst.details["virtual_tour"] = d["virtual_tour"]259