IDEALISTA_1 / scraper.py
PHDMAFIN's picture
Upload 2 files
9e93472 verified
Raw
History Blame Contribute Delete
36.4 kB
# -*- coding: utf-8 -*-
"""Scraper de Idealista Portugal adaptado para Hugging Face Spaces.
Conserva la lógica del script original, pero:
- elimina input() para que pueda ser llamado desde Gradio;
- usa Playwright headless para servidor;
- agrega posición por página/global para análisis;
- normaliza de forma estimada la antigüedad textual del anuncio.
"""
from __future__ import annotations
import csv
import json
import re
import time
import random
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Callable
from urllib.parse import urljoin, urlsplit
import pandas as pd
from bs4 import BeautifulSoup
from playwright._impl._errors import Error as PWError
from playwright.sync_api import TimeoutError as PWTimeout
from playwright.sync_api import sync_playwright
BASE = "https://www.idealista.pt"
DEFAULT_LANG = "es" # "es" o "pt"
LISTING_SELECTORS = [
"article.item",
"article[data-element-id]",
".item-info-container",
"[data-element-id]",
]
LISTING_SELECTOR_COMBINED = ", ".join(LISTING_SELECTORS)
LISTING_COLUMNS = [
"district_slug",
"source_input",
"page_hint",
"position_in_page",
"global_position",
"listing_id",
"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",
"scraped_at",
]
COOKIE_BUTTON_TEXTS = [
"Aceptar y cerrar",
"Aceitar e fechar",
"Accept and close",
"Aceptar todo",
"Aceitar tudo",
"Accept all",
"Aceptar",
"Aceitar",
"Accept",
"Estoy de acuerdo",
"Estou de acordo",
"OK",
]
LogFn = Callable[[str], None]
def _log(log_fn: LogFn | None, msg: str) -> None:
if log_fn:
log_fn(msg)
else:
print(msg)
def norm_price(text: str | None) -> int | None:
m = re.search(r"(\d[\d\.]*)", text or "")
return int(m.group(1).replace(".", "")) if m else None
def norm_area(text: str | None) -> int | None:
m = re.search(r"(\d[\d\.]*)\s*m", (text or "").lower())
return int(m.group(1).replace(".", "")) if m else None
def parse_tipologia(text: str | None) -> int | None:
m = re.search(r"\bT(\d+)\b", (text or "").upper())
return int(m.group(1)) if m else None
def parse_address_from_title(title: str | None) -> str | None:
parts = (title or "").split(" en ", 1)
return parts[1].strip() if len(parts) == 2 else None
def build_location_full(address_text: str | None) -> str | None:
addr = (address_text or "").strip()
if not addr:
return None
addr = re.sub(r"\s*,\s*", ", ", addr)
addr = re.sub(r"\s+", " ", addr).strip(" ,")
if not re.search(r"\bPortugal\b", addr, flags=re.I):
addr = f"{addr}, Portugal"
return addr
def estimate_published_at(listed_when: str | None, observed_at: datetime | None = None) -> str | None:
"""Convierte etiquetas tipo 'hace 3 días' en fecha estimada ISO.
Es una estimación, no una fecha oficial de Idealista. Si el texto no se puede
interpretar, devuelve None.
"""
if not listed_when:
return None
observed_at = observed_at or datetime.now(timezone.utc)
text = listed_when.strip().lower()
text = text.replace("á", "a").replace("é", "e").replace("í", "i").replace("ó", "o").replace("ú", "u")
if any(x in text for x in ["ayer", "ontem"]):
return (observed_at - timedelta(days=1)).date().isoformat()
if any(x in text for x in ["hoy", "hoje", "ahora", "agora"]):
return observed_at.date().isoformat()
m = re.search(r"(\d+)\s*(minuto|minutos|min|min\.|hora|horas|h|dia|dias|semana|semanas|mes|meses)", text)
if not m:
return None
n = int(m.group(1))
unit = m.group(2)
if unit.startswith("min"):
delta = timedelta(minutes=n)
elif unit in {"hora", "horas", "h"}:
delta = timedelta(hours=n)
elif unit in {"dia", "dias"}:
delta = timedelta(days=n)
elif unit in {"semana", "semanas"}:
delta = timedelta(weeks=n)
elif unit in {"mes", "meses"}:
delta = timedelta(days=30 * n)
else:
return None
return (observed_at - delta).date().isoformat()
def parse_listing_html(html: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
out: list[dict] = []
observed_at = datetime.now(timezone.utc)
articles = soup.select("article.item, article[data-element-id]")
for pos, art in enumerate(articles, start=1):
item: dict = {}
item["listing_id"] = art.get("data-element-id")
a = art.select_one("a.item-link")
if not a:
continue
item["title"] = a.get_text(strip=True)
item["url"] = urljoin(BASE, a.get("href"))
item["address_text"] = parse_address_from_title(item["title"])
item["location_full"] = build_location_full(item["address_text"])
price_el = art.select_one(".price-row .item-price")
price_text = price_el.get_text(strip=True) if price_el else None
item["price_text"] = price_text
item["price_eur"] = norm_price(price_text)
mper = re.search(r"/(mes|semana|d[ií]a|dia)", (price_text or "").lower())
item["price_period"] = f"/{mper.group(1)}" if mper else None
details = [d.get_text(" ", strip=True) for d in art.select(".item-detail-char .item-detail")]
tipologia_text = next((d for d in details if re.search(r"\bT\d+\b", d, re.I)), None)
area_text = next((d for d in details if "m²" in d.lower() or "m2" in d.lower()), None)
time_el = art.select_one(".item-detail-char .item-detail.txt-highlight-red")
time_badge = time_el.get_text(strip=True) if time_el else None
if not time_badge:
for d in reversed(details):
if re.search(r"\b(horas?|d[ií]as?|dias?|semanas?|meses?)\b", d, re.I):
time_badge = d
break
floor_info = None
for d in details:
low = d.lower()
if "planta" in low or "ascensor" in low or "elevador" in low:
floor_info = d
break
item["tipologia_text"] = tipologia_text
item["tipologia"] = parse_tipologia(tipologia_text)
item["area_text"] = area_text
item["area_m2"] = norm_area(area_text)
item["floor_info"] = floor_info
item["listed_when"] = time_badge
item["estimated_published_at"] = estimate_published_at(time_badge, observed_at)
item["position_in_page"] = pos
tag_el = art.select_one(".listing-tags")
item["tag"] = tag_el.get_text(" | ", strip=True) if tag_el else None
ag_a = art.select_one("picture.logo-branding a")
item["agency_name"] = ag_a.get("title") if ag_a and ag_a.get("title") else None
item["agency_url"] = urljoin(BASE, ag_a.get("href")) if ag_a and ag_a.get("href") else None
img = art.select_one("picture img")
item["image_main_url"] = img.get("src") if img and img.get("src") else None
webp = art.select_one('source[type="image/webp"]')
item["image_main_webp"] = webp.get("srcset") if webp and webp.get("srcset") else None
cnt = art.select_one(".item-multimedia-pictures__counter")
if cnt:
mcnt = re.search(r"(\d+)", cnt.get_text(strip=True))
item["image_count"] = int(mcnt.group(1)) if mcnt else None
else:
item["image_count"] = None
item["online_booking"] = bool(art.select_one(".online-booking"))
item["has_map_button"] = bool(art.select_one(".btn-show-map"))
out.append(item)
return out
def safe_goto(page, url: str, **kwargs) -> bool:
try:
page.goto(url, **kwargs)
return True
except PWError as e:
msg = str(e)
if "ERR_CERT_VERIFIER_CHANGED" in msg:
page.wait_for_timeout(1500)
page.goto(url, **kwargs)
return True
raise
def accept_cookies_if_needed(page, log_fn=print):
try:
# 1) Botones normales por rol accesible
for label in COOKIE_BUTTON_TEXTS:
try:
btn = page.get_by_role("button", name=re.compile(label, re.I))
if btn.count() > 0:
btn.first.click(timeout=3000)
page.wait_for_timeout(1500)
log_fn(f"[DEBUG] Cookies aceptadas con botón: {label}")
return True
except Exception:
pass
# 2) Selectores por texto visible
for label in COOKIE_BUTTON_TEXTS:
try:
locator = page.locator(
f"button:has-text('{label}'), "
f"a:has-text('{label}'), "
f"text='{label}'"
)
if locator.count() > 0:
locator.first.click(timeout=3000)
page.wait_for_timeout(1500)
log_fn(f"[DEBUG] Cookies aceptadas con locator/texto: {label}")
return True
except Exception:
pass
# 3) XPath exacto fuera de frames
for label in COOKIE_BUTTON_TEXTS:
try:
xpath_locator = page.locator(f"xpath=//*[normalize-space()='{label}']")
if xpath_locator.count() > 0:
xpath_locator.first.click(timeout=3000)
page.wait_for_timeout(1500)
log_fn(f"[DEBUG] Cookies aceptadas con XPath exacto: {label}")
return True
except Exception:
pass
# 4) Búsqueda dentro de frames
for frame in page.frames:
for label in COOKIE_BUTTON_TEXTS:
try:
btn = frame.get_by_role("button", name=re.compile(label, re.I))
if btn.count() > 0:
btn.first.click(timeout=3000)
page.wait_for_timeout(1500)
log_fn(f"[DEBUG] Cookies aceptadas en frame con botón: {label}")
return True
except Exception:
pass
try:
locator = frame.locator(
f"button:has-text('{label}'), "
f"a:has-text('{label}'), "
f"text='{label}'"
)
if locator.count() > 0:
locator.first.click(timeout=3000)
page.wait_for_timeout(1500)
log_fn(f"[DEBUG] Cookies aceptadas en frame con texto: {label}")
return True
except Exception:
pass
try:
xpath_locator = frame.locator(f"xpath=//*[normalize-space()='{label}']")
if xpath_locator.count() > 0:
xpath_locator.first.click(timeout=3000)
page.wait_for_timeout(1500)
log_fn(f"[DEBUG] Cookies aceptadas en frame con XPath exacto: {label}")
return True
except Exception:
pass
except Exception as e:
log_fn(f"[DEBUG] Error intentando aceptar cookies: {e}")
log_fn("[DEBUG] No se detectó botón de cookies.")
return False
def detect_block_status(html: str, body_text: str = "") -> dict:
html_l = (html or "").lower()
body_l = (body_text or "").lower()
datadome = (
"captcha-delivery.com" in html_l
or "datadome" in html_l
or "geo.captcha-delivery.com" in html_l
)
idealista_blocked = (
"foi detetado um uso indevido" in body_l
or "o acesso foi bloqueado" in body_l
or "uso indevido" in body_l
or "acesso foi bloqueado" in body_l
or "access was blocked" in body_l
or "access blocked" in body_l
)
captcha_iframe = (
"title=\"datadome captcha\"" in html_l
or "captcha-delivery.com/captcha" in html_l
or ("iframe" in html_l and "captcha" in html_l)
)
return {
"datadome": datadome,
"idealista_blocked": idealista_blocked,
"captcha_iframe": captcha_iframe,
"blocked": datadome or idealista_blocked or captcha_iframe,
}
def classify_page_state(diagnosis: dict) -> str:
"""Clasifica la página sin confundir bloqueo con cero resultados reales."""
flags = diagnosis.get("flags", {}) or {}
selector_counts = diagnosis.get("selector_counts", {}) or {}
positive_selectors = sum(1 for v in selector_counts.values() if isinstance(v, int) and v > 0)
if flags.get("blocked") or flags.get("datadome") or flags.get("captcha_iframe"):
return "blocked_datadome"
if flags.get("access"):
return "access_denied"
if positive_selectors > 0:
return "ok_listings"
if flags.get("no_results"):
return "zero_real_results"
return "unknown_empty_page"
def make_diagnostic_row(
*,
entry_index: int | None,
input_url: str,
page_no: int,
diagnosis: dict,
status: str,
html_path: str | None = None,
png_path: str | None = None,
) -> dict:
flags = diagnosis.get("flags", {}) or {}
counts = diagnosis.get("selector_counts", {}) or {}
return {
"ts_utc": datetime.now(timezone.utc).isoformat(),
"entry_index": entry_index,
"input_url": input_url,
"page_no": page_no,
"status": status,
"title": diagnosis.get("title"),
"final_url": diagnosis.get("url"),
"cookies": flags.get("cookies"),
"captcha": flags.get("captcha"),
"access": flags.get("access"),
"no_results": flags.get("no_results"),
"datadome": flags.get("datadome"),
"idealista_blocked": flags.get("idealista_blocked"),
"captcha_iframe": flags.get("captcha_iframe"),
"blocked": flags.get("blocked"),
"article_item_count": counts.get("article.item"),
"article_data_element_id_count": counts.get("article[data-element-id]"),
"item_info_container_count": counts.get(".item-info-container"),
"data_element_id_count": counts.get("[data-element-id]"),
"html_path": html_path,
"screenshot_path": png_path,
"body_sample": (diagnosis.get("body_text") or "")[:1200].replace("\n", " "),
}
def diagnose_page(page, page_no, log_fn=print) -> dict:
try:
title = page.title()
except Exception:
title = None
try:
current_url = page.url
except Exception:
current_url = None
try:
html = page.content()
except Exception:
html = ""
try:
body_text = page.locator("body").inner_text(timeout=5000)
except Exception:
body_text = ""
body_lower = body_text.lower()
html_lower = html.lower()
selector_counts = {}
log_fn(f"[DEBUG] Página {page_no} · title: {title}")
log_fn(f"[DEBUG] Página {page_no} · url final: {current_url}")
for selector in LISTING_SELECTORS:
try:
count = page.locator(selector).count()
selector_counts[selector] = count
log_fn(f"[DEBUG] Selector '{selector}' encontrado: {count}")
except Exception as e:
selector_counts[selector] = -1
log_fn(f"[DEBUG] Selector '{selector}' error: {e}")
block_flags = detect_block_status(html, body_text)
flags = {
"cookies": any(x in body_lower for x in [
"cookies",
"política de cookies",
"politica de cookies",
"proveedores",
"aceptar y cerrar",
"aceitar e fechar",
]),
"captcha": (
"captcha" in body_lower
or "captcha" in html_lower
or block_flags["captcha_iframe"]
),
"access": (
"acceso denegado" in body_lower
or "access denied" in body_lower
or "forbidden" in body_lower
or "acesso bloqueado" in body_lower
or "o acesso foi bloqueado" in body_lower
or block_flags["idealista_blocked"]
),
"no_results": any(x in body_lower for x in [
"no hay resultados",
"sin resultados",
"no encontramos",
"não encontrámos",
"sem resultados",
"no hemos encontrado",
]),
"datadome": block_flags["datadome"],
"idealista_blocked": block_flags["idealista_blocked"],
"captcha_iframe": block_flags["captcha_iframe"],
"blocked": block_flags["blocked"],
}
log_fn(f"[DEBUG] Flags página {page_no}: {flags}")
log_fn("[DEBUG] Texto inicial body:")
log_fn(body_text[:1200].replace("\n", " "))
if flags["blocked"]:
log_fn("[BLOCK] Idealista/DataDome detectado. La página no entregó listados al navegador headless.")
return {
"title": title,
"url": current_url,
"html": html,
"body_text": body_text,
"selector_counts": selector_counts,
"flags": flags,
}
def wait_for_list_or_dump(
page,
page_no,
debug_dir="debug",
log_fn=print,
debug_prefix: str | None = None,
input_url: str | None = None,
entry_index: int | None = None,
) -> dict:
"""Espera listado o genera diagnóstico estructurado.
Devuelve un dict con status. Ya no devuelve True/False/"blocked",
porque eso impedía distinguir bloqueo, cero resultados reales y página vacía desconocida.
"""
debug_dir = Path(debug_dir)
debug_dir.mkdir(parents=True, exist_ok=True)
prefix = debug_prefix or "entry_unknown"
input_url = input_url or getattr(page, "url", None) or ""
try:
page.wait_for_selector(LISTING_SELECTOR_COMBINED, timeout=25000)
diagnosis = diagnose_page(page, page_no, log_fn=log_fn)
status = classify_page_state(diagnosis)
log_fn(f"[OK] Estado de página {page_no}: {status}.")
return {
"status": status,
"diagnosis": diagnosis,
"diagnostic_row": make_diagnostic_row(
entry_index=entry_index,
input_url=input_url,
page_no=page_no,
diagnosis=diagnosis,
status=status,
),
}
except PWTimeout:
diagnosis = diagnose_page(page, page_no, log_fn=log_fn)
html = diagnosis.get("html", "")
status = classify_page_state(diagnosis)
html_path = debug_dir / f"debug_{prefix}_page{page_no}.html"
png_path = debug_dir / f"debug_{prefix}_page{page_no}.png"
json_path = debug_dir / f"debug_{prefix}_page{page_no}_diagnostic.json"
try:
html_path.write_text(html, encoding="utf-8", errors="replace")
log_fn(f"[DEBUG] HTML guardado en: {html_path}")
except Exception as e:
log_fn(f"[DEBUG] No se pudo guardar HTML debug: {e}")
try:
page.screenshot(path=str(png_path), full_page=True)
log_fn(f"[DEBUG] Screenshot guardado en: {png_path}")
except Exception as e:
log_fn(f"[DEBUG] No se pudo guardar screenshot: {e}")
row = make_diagnostic_row(
entry_index=entry_index,
input_url=input_url,
page_no=page_no,
diagnosis=diagnosis,
status=status,
html_path=str(html_path),
png_path=str(png_path),
)
try:
json_path.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")
log_fn(f"[DEBUG] Diagnóstico JSON guardado en: {json_path}")
except Exception as e:
log_fn(f"[DEBUG] No se pudo guardar diagnóstico JSON: {e}")
if status == "blocked_datadome":
log_fn("[BLOCK] Idealista devolvió bloqueo/DataDome en lugar de listados.")
log_fn("[BLOCK] Se detiene esta URL para no insistir contra el bloqueo.")
elif status == "zero_real_results":
log_fn("[INFO] La página parece devolver cero resultados reales, no bloqueo.")
else:
log_fn(f"[WARN] Sin listado detectable en página {page_no}. Estado: {status}.")
return {
"status": status,
"diagnosis": diagnosis,
"diagnostic_row": row,
"html_path": str(html_path),
"png_path": str(png_path),
"json_path": str(json_path),
}
def get_next_url_from_page(page) -> str | None:
a = page.query_selector('a[rel="next"]')
if a and a.get_attribute("href"):
return urljoin(BASE, a.get_attribute("href"))
for t in ["Siguiente", "Seguinte", "Próxima", "Próximo"]:
a = page.get_by_role("link", name=re.compile(t, re.I))
if a and a.count() > 0:
href = a.first.get_attribute("href")
if href:
return urljoin(BASE, href)
candidates = []
curr = page.url
for el in page.query_selector_all("a[href*='pagina-']"):
href = el.get_attribute("href") or ""
m = re.search(r"pagina-(\d+)", href)
if m:
candidates.append((int(m.group(1)), urljoin(curr, href)))
if candidates:
candidates.sort()
return candidates[-1][1]
return None
def get_page_number_from_url(url: str | None) -> int:
m = re.search(r"pagina-(\d+)", url or "")
return int(m.group(1)) if m else 1
def fetch_pages_playwright(
start_url: str,
max_pages: int = 80,
wait_ms: int = 30000,
headless: bool = True,
lang: str = DEFAULT_LANG,
debug_dir: Path | None = None,
log_fn: LogFn | None = None,
entry_index: int | None = None,
) -> dict:
items: list[dict] = []
diagnostics: list[dict] = []
log = log_fn or print
debug_path = debug_dir or Path("debug")
final_status = "unknown_empty_page"
with sync_playwright() as p:
browser = p.chromium.launch(headless=headless, args=["--disable-blink-features=AutomationControlled"])
context = browser.new_context(
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
),
locale="es-ES" if lang == "es" else "pt-PT",
viewport={"width": 1366, "height": 900},
ignore_https_errors=True,
)
page = context.new_page()
current_url = start_url
seen_urls: set[str] = set()
seen_first_ids: set[str] = set()
try:
for _ in range(1, max_pages + 1):
safe_goto(page, current_url, wait_until="domcontentloaded", timeout=30000)
try:
page.wait_for_load_state("networkidle", timeout=15000)
except Exception:
pass
accept_cookies_if_needed(page, log_fn=log)
page.wait_for_timeout(2000)
try:
page.evaluate("window.scrollTo(0, document.body.scrollHeight * 0.25)")
page.wait_for_timeout(1000)
page.evaluate("window.scrollTo(0, document.body.scrollHeight * 0.60)")
page.wait_for_timeout(1000)
page.evaluate("window.scrollTo(0, document.body.scrollHeight * 0.95)")
page.wait_for_timeout(1500)
except Exception as e:
log(f"[DEBUG] No se pudo hacer scroll progresivo: {e}")
curr_num = get_page_number_from_url(page.url)
debug_prefix = f"entry{entry_index}" if entry_index is not None else "entry_unknown"
wait_result = wait_for_list_or_dump(
page,
curr_num,
debug_dir=debug_path,
log_fn=log,
debug_prefix=debug_prefix,
input_url=current_url,
entry_index=entry_index,
)
page_status = wait_result.get("status", "unknown_empty_page")
diagnostics.append(wait_result.get("diagnostic_row", {"status": page_status, "input_url": current_url}))
if page_status == "blocked_datadome":
log(f"[BLOCK] Se detiene la entrada {debug_prefix} por bloqueo Idealista/DataDome.")
final_status = "blocked_datadome"
break
if page_status == "access_denied":
log(f"[BLOCK] Se detiene la entrada {debug_prefix} por acceso denegado.")
final_status = "access_denied"
break
if page_status == "zero_real_results":
log(f"[INFO] Cero resultados reales detectados en página {curr_num}.")
final_status = "zero_real_results"
break
if page_status != "ok_listings":
log(f"[WARN] Sin listado detectable en página {curr_num}. Estado: {page_status}.")
final_status = page_status
break
if page.url in seen_urls:
_log(log_fn, f"[STOP] URL repetida: {page.url}")
final_status = "repeated_url"
break
seen_urls.add(page.url)
batch = parse_listing_html(page.content())
if not batch:
_log(log_fn, f"[STOP] Página {curr_num} con selector, pero sin anuncios parseables.")
final_status = "parse_empty_after_selector"
break
first_id = batch[0].get("listing_id")
if first_id and first_id in seen_first_ids:
_log(log_fn, f"[STOP] Primer listing repetido en p{curr_num}; posible bucle.")
final_status = "repeated_first_listing"
break
if first_id:
seen_first_ids.add(first_id)
for it in batch:
it["page_hint"] = curr_num
pos = it.get("position_in_page") or 0
it["global_position"] = ((curr_num - 1) * 30) + int(pos)
items.extend(batch)
final_status = "ok_listings"
_log(log_fn, f"[OK] Página {curr_num}: {len(batch)} anuncios.")
next_url = get_next_url_from_page(page)
if not next_url:
break
next_num = get_page_number_from_url(next_url)
if next_num <= curr_num:
_log(log_fn, f"[STOP] Paginación no avanza ({curr_num}{next_num}).")
break
current_url = next_url
base_wait = max(int(wait_ms), 5000)
jitter = random.uniform(0.70, 1.60)
real_wait_ms = int(base_wait * jitter)
log(
f"[WAIT] Pausa entre páginas: {real_wait_ms / 1000:.1f}s "
f"(base={wait_ms}ms, jitter={jitter:.2f})."
)
page.wait_for_timeout(real_wait_ms)
finally:
context.close()
browser.close()
if items and final_status == "blocked_datadome":
final_status = "partial_blocked"
elif items:
final_status = "ok_listings"
return {
"items": items,
"status": final_status,
"diagnostics": diagnostics,
"pages_seen": len(diagnostics),
}
def sanitize_filename(stem: str) -> str:
return re.sub(r"[^a-zA-Z0-9_\-\.]+", "_", stem).strip("_") or "consulta"
def normalize_input_to_url(s, lang="es"):
s = (str(s or "")).strip()
if not s:
return None
if s.startswith("http://") or s.startswith("https://"):
# No modificar URLs completas.
# Importante: si la URL trae ?shape=..., agregar "/" al final rompe el parámetro.
return s
s = s.strip("/")
return f"{BASE}/{lang}/arrendar-casas/{s}/"
def extract_slug_from_url(url: str | None) -> str:
parts = urlsplit(url or "")
path = parts.path.rstrip("/")
return path.split("/")[-1] if path else "sin_slug"
def load_first_column_urls_or_slugs(xlsx_path: str | Path, column_name: str | None = None) -> list[str]:
df = pd.read_excel(xlsx_path, sheet_name=0)
if df.empty:
return []
if column_name and column_name in df.columns:
col = df[column_name].astype(str)
else:
first_col = df.columns[0]
col = df[first_col].astype(str)
return [s.strip() for s in col.tolist() if str(s).strip() and str(s).strip().lower() != "nan"]
def clean_dataframe(df: pd.DataFrame) -> pd.DataFrame:
for col in LISTING_COLUMNS:
if col not in df.columns:
df[col] = None
df = df[LISTING_COLUMNS].copy()
return df.where(pd.notnull(df), None)
def run_scrape_job(
input_xlsx_path: str | Path,
output_dir: str | Path,
max_pages: int = 80,
wait_ms: int = 30000,
entry_wait_ms: int = 90000,
lang: str = DEFAULT_LANG,
diagnostic_mode: bool = False,
stop_on_first_block: bool = True,
log_fn: LogFn | None = None,
) -> dict:
input_xlsx_path = Path(input_xlsx_path)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
debug_dir = output_dir / "debug"
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
name_no_ext = sanitize_filename(input_xlsx_path.stem)
entries = load_first_column_urls_or_slugs(input_xlsx_path)
if not entries:
raise ValueError("El Excel de entrada no contiene URLs ni slugs válidos en la primera columna.")
_log(log_fn, f"[INFO] Entradas detectadas: {len(entries)}")
if diagnostic_mode:
_log(log_fn, "[DIAG] Modo diagnóstico activo: se generará evidencia y app.py evitará persistencia.")
master: list[dict] = []
districts_queried: list[str] = []
diagnostics_rows: list[dict] = []
blocked_entries: list[str] = []
zero_real_result_entries: list[str] = []
unknown_empty_entries: list[str] = []
per_district_dir = output_dir / "out_por_distrito"
per_district_dir.mkdir(parents=True, exist_ok=True)
for idx, entry in enumerate(entries, start=1):
base_url = normalize_input_to_url(entry, lang=lang)
if not base_url:
_log(log_fn, f"[WARN] Entrada vacía o inválida: {entry!r}")
continue
district_slug = extract_slug_from_url(base_url)
safe_stem = sanitize_filename(district_slug)
_log(log_fn, f"\n[RUN] {idx}/{len(entries)} · {entry}{base_url}")
fetch_result = fetch_pages_playwright(
base_url,
max_pages=max_pages,
wait_ms=wait_ms,
headless=True,
lang=lang,
debug_dir=debug_dir,
log_fn=log_fn,
entry_index=idx,
)
data = fetch_result.get("items", [])
entry_status = fetch_result.get("status", "unknown_empty_page")
diagnostics_rows.extend(fetch_result.get("diagnostics", []))
if entry_status == "blocked_datadome":
blocked_entries.append(district_slug)
_log(log_fn, f"[BLOCK] El distrito {district_slug} queda como blocked_datadome; NO se marca como consultado.")
elif entry_status == "zero_real_results":
zero_real_result_entries.append(district_slug)
districts_queried.append(district_slug)
_log(log_fn, f"[INFO] El distrito {district_slug} sí se marca como consultado: cero resultados reales.")
elif data:
districts_queried.append(district_slug)
else:
unknown_empty_entries.append(district_slug)
_log(log_fn, f"[WARN] El distrito {district_slug} no se marcará como consultado. Estado: {entry_status}.")
scraped_at = datetime.now(timezone.utc).isoformat()
for it in data:
it["district_slug"] = district_slug
it["source_input"] = entry
it["scraped_at"] = scraped_at
district_df = clean_dataframe(pd.DataFrame(data)) if data else clean_dataframe(pd.DataFrame())
(per_district_dir / f"idealista_{safe_stem}_{ts}.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
district_df.to_csv(per_district_dir / f"idealista_{safe_stem}_{ts}.csv", index=False, encoding="utf-8")
district_df.to_excel(per_district_dir / f"idealista_{safe_stem}_{ts}.xlsx", index=False)
master.extend(data)
if entry_status == "blocked_datadome" and stop_on_first_block:
_log(log_fn, "[BLOCK] stop_on_first_block=True: se corta la corrida completa.")
break
if idx < len(entries):
base_sleep = max(int(entry_wait_ms), 30000) / 1000
jitter = random.uniform(0.75, 1.50)
sleep_seconds = base_sleep * jitter
_log(
log_fn,
f"[WAIT] Pausa entre URLs/distritos: {sleep_seconds:.1f}s "
f"(base={entry_wait_ms}ms, jitter={jitter:.2f})."
)
time.sleep(sleep_seconds)
master_df = clean_dataframe(pd.DataFrame(master)) if master else clean_dataframe(pd.DataFrame())
diagnostics_df = pd.DataFrame(diagnostics_rows)
current_xlsx = output_dir / f"{name_no_ext}_consulta_actual_{ts}.xlsx"
current_csv = output_dir / f"{name_no_ext}_consulta_actual_{ts}.csv"
current_json = output_dir / f"{name_no_ext}_consulta_actual_{ts}.json"
diagnostics_csv = output_dir / f"{name_no_ext}_diagnostico_{ts}.csv"
diagnostics_json = output_dir / f"{name_no_ext}_diagnostico_{ts}.json"
with pd.ExcelWriter(current_xlsx, engine="openpyxl") as writer:
master_df.to_excel(writer, index=False, sheet_name="consulta_actual")
diagnostics_df.to_excel(writer, index=False, sheet_name="diagnostico_corrida")
master_df.to_csv(current_csv, index=False, encoding="utf-8")
current_json.write_text(json.dumps(master, ensure_ascii=False, indent=2), encoding="utf-8")
diagnostics_df.to_csv(diagnostics_csv, index=False, encoding="utf-8")
diagnostics_json.write_text(json.dumps(diagnostics_rows, ensure_ascii=False, indent=2), encoding="utf-8")
if blocked_entries and not master:
run_status = "blocked_datadome"
elif blocked_entries and master:
run_status = "partial_blocked"
elif zero_real_result_entries and not master:
run_status = "zero_real_results"
elif unknown_empty_entries and not master:
run_status = "unknown_empty_page"
else:
run_status = "success"
_log(log_fn, f"\n[OK] Consulta finalizada. Anuncios encontrados: {len(master_df)}")
_log(log_fn, f"[DIAG] Estado de corrida: {run_status}")
_log(log_fn, f"[DIAG] Bloqueos DataDome: {len(blocked_entries)}")
_log(log_fn, f"[DIAG] Cero resultados reales: {len(zero_real_result_entries)}")
return {
"timestamp": ts,
"entries_count": len(entries),
"rows_count": len(master_df),
"dataframe": master_df,
"diagnostics_dataframe": diagnostics_df,
"districts_queried": sorted(set(districts_queried)),
"run_status": run_status,
"blocked_entries": sorted(set(blocked_entries)),
"blocked_entries_count": len(set(blocked_entries)),
"zero_real_result_entries": sorted(set(zero_real_result_entries)),
"zero_real_result_entries_count": len(set(zero_real_result_entries)),
"unknown_empty_entries": sorted(set(unknown_empty_entries)),
"unknown_empty_entries_count": len(set(unknown_empty_entries)),
"diagnostics_csv": str(diagnostics_csv),
"diagnostics_json": str(diagnostics_json),
"current_xlsx": str(current_xlsx),
"current_csv": str(current_csv),
"current_json": str(current_json),
"output_dir": str(output_dir),
}