# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # history.py : « Historique Lou-Ka » d'une annonce — timeline construite à # partir des observations réelles des crawls : # - price_log (depuis la v1) : chaque changement de prix observé ; # - listing_events (db.py/_log_events) : description, superficie, dispo, # inclusions, photos, disparition/réapparition. # Chaque élément est étiqueté `observed` : rien n'est estimé ni inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import json import time from . import db def timeline(uid: str, con=None) -> dict | None: """Historique complet d'une annonce (fiche + API). Retourne : première/dernière observation, jours en ligne, prix initial et courant, variation, nombre de modifications et la timeline fusionnée (prix + événements), du plus récent au plus ancien. """ own = con is None if own: con = db.connect() row = con.execute( "SELECT uid, price, first_seen, last_seen, active FROM listings" " WHERE uid=?", (uid,)).fetchone() if row is None: if own: con.close() return None prices = con.execute( "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts", (uid,)).fetchall() events = con.execute( "SELECT ts, event, old, new FROM listing_events WHERE uid=? ORDER BY ts", (uid,)).fetchall() items: list[dict] = [] prev_price = None for p in prices: it = {"ts": p["ts"], "type": "prix", "prix": p["price"], "statut": "observed"} if prev_price is not None and p["price"] is not None: it["prix_avant"] = prev_price items.append(it) if p["price"] is not None: prev_price = p["price"] for e in events: def _load(v): try: return json.loads(v) if v is not None else None except (ValueError, TypeError): return None items.append({"ts": e["ts"], "type": e["event"], "avant": _load(e["old"]), "apres": _load(e["new"]), "statut": "observed"}) items.sort(key=lambda x: x["ts"], reverse=True) first_price = next((p["price"] for p in prices if p["price"] is not None), None) cur_price = row["price"] variation = None if first_price and cur_price and first_price > 0: variation = round((cur_price - first_price) / first_price, 4) now = time.time() fin = row["last_seen"] if not row["active"] else now jours = max(0, round((fin - (row["first_seen"] or fin)) / 86400)) # modifications = éléments de timeline hors point de départ du prix n_modif = max(0, len([p for p in prices]) - 1) + len( [e for e in events if e["event"] not in ("disparition", "reapparition")]) out = { "uid": uid, "premiere_observation": row["first_seen"], "derniere_observation": row["last_seen"], "active": bool(row["active"]), "jours_en_ligne": jours, "prix_initial": first_price, "prix_actuel": cur_price, "variation": variation, "modifications": n_modif, "timeline": items[:60], "methode": ("Observations directes des synchronisations Lou-Ka " "(prix, description, superficie, disponibilité, inclusions, " "photos, retraits/retours). Aucune estimation."), } if own: con.close() return out # --------------------------------------------------------------------------- # padding ka2 (2026-08-31) : garantit une taille superieure a la version # precedente du fichier : sous APFS sature une reecriture peut ne pas # tronquer, laissant une queue residuelle apres l'EOF (SyntaxError vue # ici le 2026-08-31). Fin sur commentaire sans saut de ligne final. #