Spaces:
Sleeping
Sleeping
| # -*- coding: utf-8 -*- | |
| """Persistencia incremental en Neon/Postgres para el scraper de Idealista. | |
| Modelo analítico v2: | |
| - Estado actual por anuncio en idealista_listings. | |
| - Snapshot por corrida en idealista_listing_snapshots. | |
| - Control de corridas en idealista_runs. | |
| - Detección de desplazamiento como desaparición rápida después de ser observado. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import os | |
| from typing import Any | |
| import pandas as pd | |
| import psycopg | |
| from psycopg.rows import dict_row | |
| from psycopg.types.json import Jsonb | |
| DATABASE_URL_ENV_NAMES = ("DATABASE_URL_TASAS", "DATABASE_URL", "NEON_DATABASE_URL") | |
| DEFAULT_PROBABLY_RENTED_THRESHOLD = int(os.getenv("PROBABLY_RENTED_MISSING_RUNS", "3")) | |
| DEFAULT_NOT_SEEN_THRESHOLD = int(os.getenv("NOT_SEEN_RECENTLY_MISSING_RUNS", "2")) | |
| # Categorías recomendadas para una frecuencia de monitoreo cada tercer día. | |
| # Se clasifican por corridas visibles, no por días exactos, porque la | |
| # desaparición sólo se observa entre cortes de scraping. | |
| RENTAL_VELOCITY_BASIS = "visible_runs_3_day_cadence" | |
| RENTAL_VELOCITY_LABELS = { | |
| "very_fast": "Muy rápido", | |
| "fast": "Rápido", | |
| "normal": "Normal", | |
| "slow": "Lento", | |
| "very_slow": "Muy lento", | |
| "unknown": "Sin clasificar", | |
| } | |
| WATCH_FIELDS = [ | |
| "url", "title", "address_text", "location_full", "price_eur", "price_text", "price_period", | |
| "tipologia", "tipologia_text", "area_m2", "area_text", "floor_info", "listed_when", | |
| "estimated_published_at", "tag", "agency_name", "agency_url", "image_main_url", | |
| "image_main_webp", "image_count", "online_booking", "has_map_button", | |
| ] | |
| DB_COLUMNS = [ | |
| "listing_id", "district_slug", "source_input", "page_hint", "position_in_page", "global_position", | |
| "url", "title", "address_text", "location_full", "price_eur", "price_text", "price_period", | |
| "tipologia", "tipologia_text", "area_m2", "area_text", "floor_info", "listed_when", | |
| "estimated_published_at", "tag", "agency_name", "agency_url", "image_main_url", "image_main_webp", | |
| "image_count", "online_booking", "has_map_button", | |
| ] | |
| def get_database_url() -> str | None: | |
| for name in DATABASE_URL_ENV_NAMES: | |
| value = os.getenv(name) | |
| if value: | |
| return value | |
| return None | |
| def require_database_url() -> str: | |
| value = get_database_url() | |
| if not value: | |
| names = ", ".join(DATABASE_URL_ENV_NAMES) | |
| raise RuntimeError(f"No se encontró cadena de conexión Neon. Configura una de estas variables: {names}") | |
| return value | |
| def connect() -> psycopg.Connection: | |
| return psycopg.connect(require_database_url(), row_factory=dict_row) | |
| def ensure_schema(conn: psycopg.Connection) -> None: | |
| with conn.cursor() as cur: | |
| cur.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto;") | |
| cur.execute( | |
| """ | |
| CREATE TABLE IF NOT EXISTS idealista_runs ( | |
| run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| started_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| finished_at TIMESTAMPTZ, | |
| source_filename TEXT, | |
| entries_count INTEGER DEFAULT 0, | |
| scraped_count INTEGER DEFAULT 0, | |
| inserted_count INTEGER DEFAULT 0, | |
| updated_count INTEGER DEFAULT 0, | |
| unchanged_count INTEGER DEFAULT 0, | |
| reactivated_count INTEGER DEFAULT 0, | |
| missing_updated_count INTEGER DEFAULT 0, | |
| probably_rented_count INTEGER DEFAULT 0, | |
| snapshot_count INTEGER DEFAULT 0, | |
| status TEXT NOT NULL DEFAULT 'running', | |
| error_message TEXT, | |
| districts_queried JSONB DEFAULT '[]'::jsonb, | |
| metadata JSONB DEFAULT '{}'::jsonb | |
| ); | |
| """ | |
| ) | |
| cur.execute( | |
| """ | |
| CREATE TABLE IF NOT EXISTS idealista_listings ( | |
| listing_key TEXT PRIMARY KEY, | |
| listing_id TEXT, | |
| district_slug TEXT, | |
| source_input TEXT, | |
| page_hint INTEGER, | |
| position_in_page INTEGER, | |
| global_position INTEGER, | |
| url TEXT, | |
| title TEXT, | |
| address_text TEXT, | |
| location_full TEXT, | |
| price_eur NUMERIC, | |
| price_text TEXT, | |
| price_period TEXT, | |
| tipologia INTEGER, | |
| tipologia_text TEXT, | |
| area_m2 NUMERIC, | |
| area_text TEXT, | |
| floor_info TEXT, | |
| listed_when TEXT, | |
| estimated_published_at DATE, | |
| tag TEXT, | |
| agency_name TEXT, | |
| agency_url TEXT, | |
| image_main_url TEXT, | |
| image_main_webp TEXT, | |
| image_count INTEGER, | |
| online_booking BOOLEAN, | |
| has_map_button BOOLEAN, | |
| first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| last_run_id UUID REFERENCES idealista_runs(run_id) ON DELETE SET NULL, | |
| is_active BOOLEAN NOT NULL DEFAULT TRUE, | |
| status TEXT NOT NULL DEFAULT 'active', | |
| missing_runs INTEGER NOT NULL DEFAULT 0, | |
| visible_runs INTEGER NOT NULL DEFAULT 1, | |
| days_to_displacement INTEGER, | |
| displacement_detection_lag_days INTEGER, | |
| rental_velocity_category TEXT NOT NULL DEFAULT 'unknown', | |
| rental_velocity_basis TEXT NOT NULL DEFAULT 'visible_runs_3_day_cadence', | |
| rental_velocity_classified_at TIMESTAMPTZ, | |
| deactivated_at TIMESTAMPTZ, | |
| reactivated_at TIMESTAMPTZ, | |
| content_hash TEXT, | |
| payload JSONB DEFAULT '{}'::jsonb, | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| updated_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| ); | |
| """ | |
| ) | |
| # Migraciones seguras si existe una versión anterior. | |
| alterations = [ | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS source_filename TEXT", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS entries_count INTEGER DEFAULT 0", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS scraped_count INTEGER DEFAULT 0", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS inserted_count INTEGER DEFAULT 0", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS updated_count INTEGER DEFAULT 0", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS unchanged_count INTEGER DEFAULT 0", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS snapshot_count INTEGER DEFAULT 0", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS reactivated_count INTEGER DEFAULT 0", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS missing_updated_count INTEGER DEFAULT 0", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS probably_rented_count INTEGER DEFAULT 0", | |
| "ALTER TABLE idealista_runs ADD COLUMN IF NOT EXISTS districts_queried JSONB DEFAULT '[]'::jsonb", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS source_input TEXT", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS position_in_page INTEGER", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS global_position INTEGER", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS estimated_published_at DATE", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS missing_runs INTEGER NOT NULL DEFAULT 0", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS visible_runs INTEGER NOT NULL DEFAULT 1", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS days_to_displacement INTEGER", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS displacement_detection_lag_days INTEGER", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS rental_velocity_category TEXT NOT NULL DEFAULT 'unknown'", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS rental_velocity_basis TEXT NOT NULL DEFAULT 'visible_runs_3_day_cadence'", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS rental_velocity_classified_at TIMESTAMPTZ", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMPTZ", | |
| "ALTER TABLE idealista_listings ADD COLUMN IF NOT EXISTS reactivated_at TIMESTAMPTZ", | |
| ] | |
| for sql in alterations: | |
| cur.execute(sql) | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_listings_listing_id ON idealista_listings(listing_id);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_listings_district ON idealista_listings(district_slug);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_listings_price ON idealista_listings(price_eur);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_listings_last_seen ON idealista_listings(last_seen_at);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_listings_status ON idealista_listings(status);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_listings_missing ON idealista_listings(missing_runs);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_listings_velocity ON idealista_listings(rental_velocity_category);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_listings_visible_runs ON idealista_listings(visible_runs);") | |
| cur.execute( | |
| """ | |
| CREATE TABLE IF NOT EXISTS idealista_listing_snapshots ( | |
| snapshot_id BIGSERIAL PRIMARY KEY, | |
| run_id UUID REFERENCES idealista_runs(run_id) ON DELETE CASCADE, | |
| listing_key TEXT NOT NULL, | |
| listing_id TEXT, | |
| district_slug TEXT, | |
| price_eur NUMERIC, | |
| area_m2 NUMERIC, | |
| title TEXT, | |
| url TEXT, | |
| page_hint INTEGER, | |
| position_in_page INTEGER, | |
| global_position INTEGER, | |
| listed_when TEXT, | |
| estimated_published_at DATE, | |
| content_hash TEXT, | |
| observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| payload JSONB DEFAULT '{}'::jsonb, | |
| UNIQUE(run_id, listing_key) | |
| ); | |
| """ | |
| ) | |
| snapshot_alterations = [ | |
| "ALTER TABLE idealista_listing_snapshots ADD COLUMN IF NOT EXISTS page_hint INTEGER", | |
| "ALTER TABLE idealista_listing_snapshots ADD COLUMN IF NOT EXISTS position_in_page INTEGER", | |
| "ALTER TABLE idealista_listing_snapshots ADD COLUMN IF NOT EXISTS global_position INTEGER", | |
| "ALTER TABLE idealista_listing_snapshots ADD COLUMN IF NOT EXISTS listed_when TEXT", | |
| "ALTER TABLE idealista_listing_snapshots ADD COLUMN IF NOT EXISTS estimated_published_at DATE", | |
| ] | |
| for sql in snapshot_alterations: | |
| cur.execute(sql) | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_snapshots_listing_key ON idealista_listing_snapshots(listing_key);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_snapshots_run ON idealista_listing_snapshots(run_id);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_idealista_snapshots_observed ON idealista_listing_snapshots(observed_at);") | |
| conn.commit() | |
| def _none_if_nan(value: Any) -> Any: | |
| if value is None: | |
| return None | |
| try: | |
| if pd.isna(value): | |
| return None | |
| except Exception: | |
| pass | |
| if hasattr(value, "item"): | |
| try: | |
| return value.item() | |
| except Exception: | |
| pass | |
| return value | |
| def normalize_record(row: dict[str, Any]) -> dict[str, Any]: | |
| out = {k: _none_if_nan(v) for k, v in row.items()} | |
| for k in ["page_hint", "position_in_page", "global_position", "tipologia", "image_count"]: | |
| if out.get(k) is not None: | |
| out[k] = int(out[k]) | |
| for k in ["price_eur", "area_m2"]: | |
| if out.get(k) is not None: | |
| out[k] = float(out[k]) | |
| return out | |
| def listing_key_for(row: dict[str, Any]) -> str | None: | |
| listing_id = row.get("listing_id") | |
| url = row.get("url") | |
| if listing_id: | |
| return str(listing_id) | |
| if url: | |
| return str(url) | |
| return None | |
| def content_hash(row: dict[str, Any]) -> str: | |
| payload = {field: row.get(field) for field in WATCH_FIELDS} | |
| raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) | |
| return hashlib.sha256(raw.encode("utf-8")).hexdigest() | |
| def rental_velocity_category_from_visible_runs(visible_runs: int | None) -> str: | |
| """Clasifica velocidad de arrendamiento inferido para corrida cada tercer día. | |
| Criterio: | |
| - 1 corrida visible: very_fast | |
| - 2 a 3 corridas visibles: fast | |
| - 4 a 6 corridas visibles: normal | |
| - 7 a 10 corridas visibles: slow | |
| - 11+ corridas visibles: very_slow | |
| """ | |
| if visible_runs is None: | |
| return "unknown" | |
| try: | |
| n = int(visible_runs) | |
| except Exception: | |
| return "unknown" | |
| if n <= 0: | |
| return "unknown" | |
| if n == 1: | |
| return "very_fast" | |
| if 2 <= n <= 3: | |
| return "fast" | |
| if 4 <= n <= 6: | |
| return "normal" | |
| if 7 <= n <= 10: | |
| return "slow" | |
| return "very_slow" | |
| def create_run( | |
| conn: psycopg.Connection, | |
| source_filename: str, | |
| entries_count: int, | |
| districts_queried: list[str] | None = None, | |
| metadata: dict | None = None, | |
| ) -> str: | |
| ensure_schema(conn) | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| """ | |
| INSERT INTO idealista_runs (source_filename, entries_count, districts_queried, metadata) | |
| VALUES (%s, %s, %s, %s) | |
| RETURNING run_id; | |
| """, | |
| (source_filename, entries_count, Jsonb(districts_queried or []), Jsonb(metadata or {})), | |
| ) | |
| row = cur.fetchone() | |
| conn.commit() | |
| return str(row["run_id"]) | |
| def finish_run(conn: psycopg.Connection, run_id: str, stats: dict[str, int], status: str = "success", error_message: str | None = None) -> None: | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| """ | |
| UPDATE idealista_runs | |
| SET finished_at = now(), | |
| scraped_count = %s, | |
| inserted_count = %s, | |
| updated_count = %s, | |
| unchanged_count = %s, | |
| reactivated_count = %s, | |
| missing_updated_count = %s, | |
| probably_rented_count = %s, | |
| snapshot_count = %s, | |
| status = %s, | |
| error_message = %s | |
| WHERE run_id = %s; | |
| """, | |
| ( | |
| stats.get("scraped", 0), | |
| stats.get("inserted", 0), | |
| stats.get("updated", 0), | |
| stats.get("unchanged", 0), | |
| stats.get("reactivated", 0), | |
| stats.get("missing_updated", 0), | |
| stats.get("probably_rented", 0), | |
| stats.get("snapshots", 0), | |
| status, | |
| error_message, | |
| run_id, | |
| ), | |
| ) | |
| conn.commit() | |
| def _insert_snapshot(cur: psycopg.Cursor, row: dict[str, Any], key: str, run_id: str, h: str) -> bool: | |
| cur.execute( | |
| """ | |
| INSERT INTO idealista_listing_snapshots ( | |
| run_id, listing_key, listing_id, district_slug, price_eur, area_m2, title, url, | |
| page_hint, position_in_page, global_position, listed_when, estimated_published_at, | |
| content_hash, payload | |
| ) | |
| VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) | |
| ON CONFLICT (run_id, listing_key) DO NOTHING; | |
| """, | |
| ( | |
| run_id, key, row.get("listing_id"), row.get("district_slug"), row.get("price_eur"), row.get("area_m2"), | |
| row.get("title"), row.get("url"), row.get("page_hint"), row.get("position_in_page"), row.get("global_position"), | |
| row.get("listed_when"), row.get("estimated_published_at"), h, Jsonb(row), | |
| ), | |
| ) | |
| return cur.rowcount > 0 | |
| def upsert_listings( | |
| conn: psycopg.Connection, | |
| df: pd.DataFrame, | |
| run_id: str, | |
| districts_queried: list[str] | None = None, | |
| probably_rented_threshold: int = DEFAULT_PROBABLY_RENTED_THRESHOLD, | |
| not_seen_threshold: int = DEFAULT_NOT_SEEN_THRESHOLD, | |
| ) -> dict[str, int]: | |
| ensure_schema(conn) | |
| stats = { | |
| "scraped": int(len(df)), "inserted": 0, "updated": 0, "unchanged": 0, | |
| "reactivated": 0, "missing_updated": 0, "probably_rented": 0, "snapshots": 0, | |
| } | |
| observed_keys: set[str] = set() | |
| with conn.cursor() as cur: | |
| for _, raw_row in df.iterrows(): | |
| row = normalize_record(raw_row.to_dict()) | |
| key = listing_key_for(row) | |
| if not key: | |
| continue | |
| # Evita sumar visible_runs más de una vez en la misma corrida si el mismo | |
| # anuncio aparece duplicado por solapamiento de distritos o paginación. | |
| if key in observed_keys: | |
| continue | |
| observed_keys.add(key) | |
| h = content_hash(row) | |
| cur.execute("SELECT content_hash, status, is_active FROM idealista_listings WHERE listing_key = %s", (key,)) | |
| existing = cur.fetchone() | |
| was_reactivated = bool(existing and existing.get("status") in {"temporarily_missing", "not_seen_recently", "probably_rented", "possibly_removed"}) | |
| if existing is None: | |
| cur.execute( | |
| """ | |
| INSERT INTO idealista_listings ( | |
| listing_key, listing_id, district_slug, source_input, page_hint, position_in_page, global_position, | |
| url, title, address_text, location_full, price_eur, price_text, price_period, | |
| tipologia, tipologia_text, area_m2, area_text, floor_info, listed_when, estimated_published_at, | |
| tag, agency_name, agency_url, image_main_url, image_main_webp, image_count, | |
| online_booking, has_map_button, last_run_id, is_active, status, missing_runs, visible_runs, | |
| days_to_displacement, displacement_detection_lag_days, | |
| rental_velocity_category, rental_velocity_basis, rental_velocity_classified_at, | |
| content_hash, payload | |
| ) | |
| VALUES ( | |
| %(listing_key)s, %(listing_id)s, %(district_slug)s, %(source_input)s, %(page_hint)s, %(position_in_page)s, %(global_position)s, | |
| %(url)s, %(title)s, %(address_text)s, %(location_full)s, %(price_eur)s, %(price_text)s, %(price_period)s, | |
| %(tipologia)s, %(tipologia_text)s, %(area_m2)s, %(area_text)s, %(floor_info)s, %(listed_when)s, %(estimated_published_at)s, | |
| %(tag)s, %(agency_name)s, %(agency_url)s, %(image_main_url)s, %(image_main_webp)s, %(image_count)s, | |
| %(online_booking)s, %(has_map_button)s, %(last_run_id)s, TRUE, 'active', 0, 1, | |
| NULL, NULL, 'unknown', 'visible_runs_3_day_cadence', NULL, | |
| %(content_hash)s, %(payload)s | |
| ); | |
| """, | |
| {**{c: row.get(c) for c in DB_COLUMNS}, "listing_key": key, "last_run_id": run_id, "content_hash": h, "payload": Jsonb(row)}, | |
| ) | |
| stats["inserted"] += 1 | |
| else: | |
| changed = existing.get("content_hash") != h | |
| set_status = "active" | |
| cur.execute( | |
| """ | |
| UPDATE idealista_listings | |
| SET listing_id = %(listing_id)s, | |
| district_slug = %(district_slug)s, | |
| source_input = %(source_input)s, | |
| page_hint = %(page_hint)s, | |
| position_in_page = %(position_in_page)s, | |
| global_position = %(global_position)s, | |
| url = %(url)s, | |
| title = %(title)s, | |
| address_text = %(address_text)s, | |
| location_full = %(location_full)s, | |
| price_eur = %(price_eur)s, | |
| price_text = %(price_text)s, | |
| price_period = %(price_period)s, | |
| tipologia = %(tipologia)s, | |
| tipologia_text = %(tipologia_text)s, | |
| area_m2 = %(area_m2)s, | |
| area_text = %(area_text)s, | |
| floor_info = %(floor_info)s, | |
| listed_when = %(listed_when)s, | |
| estimated_published_at = %(estimated_published_at)s, | |
| tag = %(tag)s, | |
| agency_name = %(agency_name)s, | |
| agency_url = %(agency_url)s, | |
| image_main_url = %(image_main_url)s, | |
| image_main_webp = %(image_main_webp)s, | |
| image_count = %(image_count)s, | |
| online_booking = %(online_booking)s, | |
| has_map_button = %(has_map_button)s, | |
| last_seen_at = now(), | |
| last_run_id = %(last_run_id)s, | |
| is_active = TRUE, | |
| status = %(status)s, | |
| missing_runs = 0, | |
| visible_runs = COALESCE(visible_runs, 0) + 1, | |
| days_to_displacement = NULL, | |
| displacement_detection_lag_days = NULL, | |
| rental_velocity_category = 'unknown', | |
| rental_velocity_basis = 'visible_runs_3_day_cadence', | |
| rental_velocity_classified_at = NULL, | |
| deactivated_at = NULL, | |
| reactivated_at = CASE WHEN %(was_reactivated)s THEN now() ELSE reactivated_at END, | |
| content_hash = %(content_hash)s, | |
| payload = %(payload)s, | |
| updated_at = now() | |
| WHERE listing_key = %(listing_key)s; | |
| """, | |
| { | |
| **{c: row.get(c) for c in DB_COLUMNS}, | |
| "listing_key": key, | |
| "last_run_id": run_id, | |
| "status": set_status, | |
| "was_reactivated": was_reactivated, | |
| "content_hash": h, | |
| "payload": Jsonb(row), | |
| }, | |
| ) | |
| if was_reactivated: | |
| stats["reactivated"] += 1 | |
| if changed or was_reactivated: | |
| stats["updated"] += 1 | |
| else: | |
| stats["unchanged"] += 1 | |
| if _insert_snapshot(cur, row, key, run_id, h): | |
| stats["snapshots"] += 1 | |
| if districts_queried: | |
| stats.update(_mark_missing(cur, observed_keys, districts_queried, probably_rented_threshold, not_seen_threshold)) | |
| conn.commit() | |
| return stats | |
| def _mark_missing( | |
| cur: psycopg.Cursor, | |
| observed_keys: set[str], | |
| districts_queried: list[str], | |
| probably_rented_threshold: int, | |
| not_seen_threshold: int, | |
| ) -> dict[str, int]: | |
| stats = {"missing_updated": 0, "probably_rented": 0} | |
| district_list = sorted(set([d for d in districts_queried if d])) | |
| if not district_list: | |
| return stats | |
| # PostgreSQL usa <> ALL(array) para excluir observados. Si observed_keys está vacío, | |
| # usamos una lista imposible para evitar SQL dinámico peligroso. | |
| observed_list = list(observed_keys) or ["__NO_OBSERVED_KEYS__"] | |
| cur.execute( | |
| """ | |
| WITH candidates AS ( | |
| SELECT | |
| listing_key, | |
| missing_runs + 1 AS next_missing_runs, | |
| COALESCE(visible_runs, 0) AS visible_runs, | |
| status | |
| FROM idealista_listings | |
| WHERE district_slug = ANY(%s) | |
| AND listing_key <> ALL(%s) | |
| AND status <> 'probably_rented' | |
| ), classified AS ( | |
| SELECT | |
| listing_key, | |
| next_missing_runs, | |
| visible_runs, | |
| CASE | |
| WHEN next_missing_runs >= %s THEN 'probably_rented' | |
| WHEN next_missing_runs >= %s THEN 'not_seen_recently' | |
| ELSE 'temporarily_missing' | |
| END AS next_status, | |
| CASE | |
| WHEN visible_runs <= 0 THEN 'unknown' | |
| WHEN visible_runs = 1 THEN 'very_fast' | |
| WHEN visible_runs BETWEEN 2 AND 3 THEN 'fast' | |
| WHEN visible_runs BETWEEN 4 AND 6 THEN 'normal' | |
| WHEN visible_runs BETWEEN 7 AND 10 THEN 'slow' | |
| ELSE 'very_slow' | |
| END AS next_velocity_category | |
| FROM candidates | |
| ), updated AS ( | |
| UPDATE idealista_listings l | |
| SET missing_runs = c.next_missing_runs, | |
| is_active = FALSE, | |
| status = c.next_status, | |
| deactivated_at = COALESCE(l.deactivated_at, now()), | |
| days_to_displacement = CASE | |
| WHEN c.next_status = 'probably_rented' | |
| THEN GREATEST(0, EXTRACT(DAY FROM (l.last_seen_at - l.first_seen_at))::int) | |
| ELSE l.days_to_displacement | |
| END, | |
| displacement_detection_lag_days = CASE | |
| WHEN c.next_status = 'probably_rented' | |
| THEN GREATEST(0, EXTRACT(DAY FROM (now() - l.last_seen_at))::int) | |
| ELSE l.displacement_detection_lag_days | |
| END, | |
| rental_velocity_category = CASE | |
| WHEN c.next_status = 'probably_rented' THEN c.next_velocity_category | |
| ELSE l.rental_velocity_category | |
| END, | |
| rental_velocity_basis = CASE | |
| WHEN c.next_status = 'probably_rented' THEN 'visible_runs_3_day_cadence' | |
| ELSE l.rental_velocity_basis | |
| END, | |
| rental_velocity_classified_at = CASE | |
| WHEN c.next_status = 'probably_rented' THEN COALESCE(l.rental_velocity_classified_at, now()) | |
| ELSE l.rental_velocity_classified_at | |
| END, | |
| updated_at = now() | |
| FROM classified c | |
| WHERE l.listing_key = c.listing_key | |
| RETURNING l.status | |
| ) | |
| SELECT | |
| COUNT(*)::int AS missing_updated, | |
| COUNT(*) FILTER (WHERE status = 'probably_rented')::int AS probably_rented | |
| FROM updated; | |
| """, | |
| (district_list, observed_list, probably_rented_threshold, not_seen_threshold), | |
| ) | |
| row = cur.fetchone() or {} | |
| stats["missing_updated"] = int(row.get("missing_updated") or 0) | |
| stats["probably_rented"] = int(row.get("probably_rented") or 0) | |
| return stats | |
| def _remove_timezone_if_datetime(value): | |
| """Convierte datetime con timezone a datetime sin timezone para exportar a Excel.""" | |
| if hasattr(value, "tzinfo") and value.tzinfo is not None: | |
| try: | |
| return value.replace(tzinfo=None) | |
| except Exception: | |
| return value | |
| return value | |
| def read_dataframe(conn: psycopg.Connection, sql: str, params: tuple | None = None) -> pd.DataFrame: | |
| """Lee una consulta SQL en DataFrame usando cursor psycopg3 y limpia timezones para Excel.""" | |
| with conn.cursor() as cur: | |
| cur.execute(sql, params or ()) | |
| rows = cur.fetchall() | |
| columns = [desc.name for desc in cur.description] if cur.description else [] | |
| df = pd.DataFrame(rows, columns=columns) | |
| # Excel no soporta datetimes con timezone. | |
| # Convertimos cualquier columna datetime tz-aware a datetime sin timezone. | |
| for col in df.columns: | |
| if pd.api.types.is_datetime64tz_dtype(df[col]): | |
| df[col] = df[col].dt.tz_localize(None) | |
| elif df[col].dtype == "object": | |
| df[col] = df[col].apply(_remove_timezone_if_datetime) | |
| return df | |
| def write_sheet(writer, conn, sql: str, sheet_name: str): | |
| df = read_dataframe(conn, sql) | |
| if df.empty: | |
| df = pd.DataFrame({"mensaje": ["Sin datos disponibles"]}) | |
| df.to_excel(writer, sheet_name=sheet_name, index=False) | |
| def export_incremental_excel(conn: psycopg.Connection, output_xlsx: str) -> str: | |
| """Exporta workbook analítico completo desde Neon.""" | |
| base_sql = """ | |
| SELECT | |
| listing_key, listing_id, district_slug, status, is_active, missing_runs, visible_runs, | |
| rental_velocity_category, | |
| CASE rental_velocity_category | |
| WHEN 'very_fast' THEN 'Muy rápido' | |
| WHEN 'fast' THEN 'Rápido' | |
| WHEN 'normal' THEN 'Normal' | |
| WHEN 'slow' THEN 'Lento' | |
| WHEN 'very_slow' THEN 'Muy lento' | |
| ELSE 'Sin clasificar' | |
| END AS rental_velocity_label, | |
| rental_velocity_basis, days_to_displacement, displacement_detection_lag_days, | |
| rental_velocity_classified_at, | |
| first_seen_at, last_seen_at, deactivated_at, reactivated_at, | |
| EXTRACT(DAY FROM (COALESCE(deactivated_at, last_seen_at, now()) - first_seen_at))::int AS days_visible, | |
| estimated_published_at, | |
| CASE WHEN estimated_published_at IS NOT NULL THEN (CURRENT_DATE - estimated_published_at)::int END AS estimated_days_on_portal, | |
| price_eur, area_m2, | |
| CASE WHEN area_m2 IS NOT NULL AND area_m2 <> 0 THEN ROUND((price_eur / area_m2)::numeric, 2) END AS eur_m2, | |
| page_hint, position_in_page, global_position, | |
| title, url, address_text, location_full, price_text, price_period, tipologia, tipologia_text, | |
| agency_name, agency_url, listed_when, tag, source_input, last_run_id, updated_at | |
| FROM idealista_listings | |
| ORDER BY district_slug, price_eur NULLS LAST; | |
| """ | |
| mayor_desplazamiento_sql = """ | |
| SELECT | |
| listing_key, listing_id, district_slug, status, rental_velocity_category, | |
| CASE rental_velocity_category | |
| WHEN 'very_fast' THEN 'Muy rápido' | |
| WHEN 'fast' THEN 'Rápido' | |
| WHEN 'normal' THEN 'Normal' | |
| WHEN 'slow' THEN 'Lento' | |
| WHEN 'very_slow' THEN 'Muy lento' | |
| ELSE 'Sin clasificar' | |
| END AS rental_velocity_label, | |
| visible_runs, missing_runs, | |
| first_seen_at, last_seen_at, deactivated_at, | |
| days_to_displacement, | |
| displacement_detection_lag_days, | |
| price_eur, area_m2, | |
| title, url, address_text, location_full, listed_when, estimated_published_at | |
| FROM idealista_listings | |
| WHERE status = 'probably_rented' | |
| ORDER BY visible_runs ASC NULLS LAST, | |
| days_to_displacement ASC NULLS LAST, | |
| displacement_detection_lag_days ASC NULLS LAST, | |
| deactivated_at DESC NULLS LAST | |
| LIMIT 500; | |
| """ | |
| mayor_antiguedad_sql = """ | |
| SELECT listing_key, listing_id, district_slug, status, first_seen_at, last_seen_at, | |
| EXTRACT(DAY FROM (now() - first_seen_at))::int AS days_in_base, | |
| price_eur, area_m2, title, url, address_text, location_full | |
| FROM idealista_listings | |
| ORDER BY first_seen_at ASC | |
| LIMIT 500; | |
| """ | |
| menor_antiguedad_sql = """ | |
| SELECT listing_key, listing_id, district_slug, status, first_seen_at, last_seen_at, | |
| EXTRACT(DAY FROM (now() - first_seen_at))::int AS days_in_base, | |
| price_eur, area_m2, title, url, address_text, location_full | |
| FROM idealista_listings | |
| ORDER BY first_seen_at DESC | |
| LIMIT 500; | |
| """ | |
| cambios_precio_sql = """ | |
| WITH ordered AS ( | |
| SELECT | |
| listing_key, | |
| district_slug, | |
| (ARRAY_AGG(price_eur ORDER BY observed_at ASC))[1] AS first_price_eur, | |
| (ARRAY_AGG(price_eur ORDER BY observed_at DESC))[1] AS last_price_eur, | |
| MIN(price_eur) AS min_price_eur, | |
| MAX(price_eur) AS max_price_eur, | |
| MIN(observed_at) AS first_observed_at, | |
| MAX(observed_at) AS last_observed_at, | |
| COUNT(*) AS observations | |
| FROM idealista_listing_snapshots | |
| WHERE price_eur IS NOT NULL | |
| GROUP BY listing_key, district_slug | |
| ) | |
| SELECT | |
| o.*, | |
| (last_price_eur - first_price_eur) AS change_abs_eur, | |
| ROUND(((last_price_eur - first_price_eur) / NULLIF(first_price_eur, 0)) * 100, 2) AS change_pct | |
| FROM ordered o | |
| WHERE observations > 1 AND first_price_eur IS DISTINCT FROM last_price_eur | |
| ORDER BY change_abs_eur ASC NULLS LAST; | |
| """ | |
| categorias_sql = """ | |
| SELECT | |
| rental_velocity_category, | |
| CASE rental_velocity_category | |
| WHEN 'very_fast' THEN 'Muy rápido' | |
| WHEN 'fast' THEN 'Rápido' | |
| WHEN 'normal' THEN 'Normal' | |
| WHEN 'slow' THEN 'Lento' | |
| WHEN 'very_slow' THEN 'Muy lento' | |
| ELSE 'Sin clasificar' | |
| END AS rental_velocity_label, | |
| COUNT(*)::int AS listings_count, | |
| ROUND(AVG(visible_runs)::numeric, 2) AS avg_visible_runs, | |
| ROUND(AVG(days_to_displacement)::numeric, 2) AS avg_days_to_displacement, | |
| ROUND(AVG(price_eur)::numeric, 2) AS avg_price_eur | |
| FROM idealista_listings | |
| WHERE status = 'probably_rented' | |
| GROUP BY rental_velocity_category | |
| ORDER BY MIN(CASE rental_velocity_category | |
| WHEN 'very_fast' THEN 1 | |
| WHEN 'fast' THEN 2 | |
| WHEN 'normal' THEN 3 | |
| WHEN 'slow' THEN 4 | |
| WHEN 'very_slow' THEN 5 | |
| ELSE 6 | |
| END); | |
| """ | |
| runs_sql = """ | |
| SELECT run_id, started_at, finished_at, source_filename, entries_count, scraped_count, | |
| inserted_count, updated_count, unchanged_count, reactivated_count, | |
| missing_updated_count, probably_rented_count, snapshot_count, status, | |
| districts_queried, error_message | |
| FROM idealista_runs | |
| ORDER BY run_id DESC | |
| LIMIT 200; | |
| """ | |
| with pd.ExcelWriter(output_xlsx, engine="openpyxl") as writer: | |
| write_sheet(writer, conn, base_sql, "Base incremental") | |
| write_sheet(writer, conn, mayor_desplazamiento_sql, "Mayor desplazamiento") | |
| write_sheet(writer, conn, categorias_sql, "Categorias velocidad") | |
| write_sheet(writer, conn, mayor_antiguedad_sql, "Mayor antiguedad") | |
| write_sheet(writer, conn, menor_antiguedad_sql, "Menor antiguedad") | |
| write_sheet(writer, conn, cambios_precio_sql, "Cambios precio") | |
| write_sheet(writer, conn, runs_sql, "Corridas") | |