Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- app.py +44 -2
- scraper.py +292 -107
app.py
CHANGED
|
@@ -64,6 +64,8 @@ def run_pipeline(
|
|
| 64 |
entry_wait_ms: int,
|
| 65 |
lang: str,
|
| 66 |
send_telegram: bool,
|
|
|
|
|
|
|
| 67 |
probably_rented_threshold: int,
|
| 68 |
not_seen_threshold: int,
|
| 69 |
) -> tuple[str, str | None, str | None, str | None, str]:
|
|
@@ -91,11 +93,18 @@ def run_pipeline(
|
|
| 91 |
wait_ms=int(wait_ms),
|
| 92 |
entry_wait_ms=int(entry_wait_ms),
|
| 93 |
lang=lang,
|
|
|
|
|
|
|
| 94 |
log_fn=lambda msg: _append_log(logs, msg),
|
| 95 |
)
|
| 96 |
current_xlsx = scrape_result["current_xlsx"]
|
| 97 |
df = scrape_result["dataframe"]
|
| 98 |
districts_queried = scrape_result.get("districts_queried", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
debug_zip = zip_debug_folder(output_dir)
|
| 101 |
if debug_zip:
|
|
@@ -106,7 +115,16 @@ def run_pipeline(
|
|
| 106 |
|
| 107 |
|
| 108 |
_append_log(logs, "[2/6] Preparando persistencia incremental en Neon...")
|
| 109 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
with connect() as conn:
|
| 111 |
run_id = create_run(
|
| 112 |
conn,
|
|
@@ -121,6 +139,12 @@ def run_pipeline(
|
|
| 121 |
"space_runtime": "huggingface-docker",
|
| 122 |
"probably_rented_threshold": probably_rented_threshold,
|
| 123 |
"not_seen_threshold": not_seen_threshold,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
},
|
| 125 |
)
|
| 126 |
stats = upsert_listings(
|
|
@@ -131,7 +155,7 @@ def run_pipeline(
|
|
| 131 |
probably_rented_threshold=int(probably_rented_threshold),
|
| 132 |
not_seen_threshold=int(not_seen_threshold),
|
| 133 |
)
|
| 134 |
-
finish_run(conn, run_id, stats, status="success")
|
| 135 |
|
| 136 |
incremental_xlsx = str(output_dir / f"base_incremental_idealista_neon_{ts}.xlsx")
|
| 137 |
export_incremental_excel(conn, incremental_xlsx)
|
|
@@ -157,8 +181,12 @@ def run_pipeline(
|
|
| 157 |
_append_log(logs, "[3/6] Archivos Excel generados.")
|
| 158 |
summary = (
|
| 159 |
"Consulta Idealista finalizada.\n"
|
|
|
|
| 160 |
f"Anuncios encontrados: {len(df)}\n"
|
| 161 |
f"Distritos consultados: {len(districts_queried)}\n"
|
|
|
|
|
|
|
|
|
|
| 162 |
f"Nuevos insertados: {stats.get('inserted', 0)}\n"
|
| 163 |
f"Actualizados: {stats.get('updated', 0)}\n"
|
| 164 |
f"Reactivados: {stats.get('reactivated', 0)}\n"
|
|
@@ -181,8 +209,12 @@ def run_pipeline(
|
|
| 181 |
|
| 182 |
status_md = (
|
| 183 |
"### Resultado\n"
|
|
|
|
| 184 |
f"- **Anuncios encontrados:** {len(df)}\n"
|
| 185 |
f"- **Distritos consultados:** {len(districts_queried)}\n"
|
|
|
|
|
|
|
|
|
|
| 186 |
f"- **Nuevos insertados en Neon:** {stats.get('inserted', 0)}\n"
|
| 187 |
f"- **Actualizados en Neon:** {stats.get('updated', 0)}\n"
|
| 188 |
f"- **Reactivados:** {stats.get('reactivated', 0)}\n"
|
|
@@ -262,6 +294,14 @@ def build_app() -> gr.Blocks:
|
|
| 262 |
|
| 263 |
lang = gr.Dropdown(label="Idioma URL", choices=["es", "pt"], value="es")
|
| 264 |
send_telegram = gr.Checkbox(label="Enviar resultados por Telegram", value=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
with gr.Accordion("Parámetros de desplazamiento", open=False):
|
| 267 |
gr.Markdown("La categoría de velocidad se calcula cuando el anuncio llega a `probably_rented`: 1 corrida visible = muy rápido; 2-3 = rápido; 4-6 = normal; 7-10 = lento; 11+ = muy lento. Este criterio está calibrado para ejecución cada tercer día.")
|
|
@@ -297,6 +337,8 @@ def build_app() -> gr.Blocks:
|
|
| 297 |
entry_wait_ms,
|
| 298 |
lang,
|
| 299 |
send_telegram,
|
|
|
|
|
|
|
| 300 |
probably_rented_threshold,
|
| 301 |
not_seen_threshold,
|
| 302 |
],
|
|
|
|
| 64 |
entry_wait_ms: int,
|
| 65 |
lang: str,
|
| 66 |
send_telegram: bool,
|
| 67 |
+
diagnostic_mode: bool,
|
| 68 |
+
stop_on_first_block: bool,
|
| 69 |
probably_rented_threshold: int,
|
| 70 |
not_seen_threshold: int,
|
| 71 |
) -> tuple[str, str | None, str | None, str | None, str]:
|
|
|
|
| 93 |
wait_ms=int(wait_ms),
|
| 94 |
entry_wait_ms=int(entry_wait_ms),
|
| 95 |
lang=lang,
|
| 96 |
+
diagnostic_mode=bool(diagnostic_mode),
|
| 97 |
+
stop_on_first_block=bool(stop_on_first_block),
|
| 98 |
log_fn=lambda msg: _append_log(logs, msg),
|
| 99 |
)
|
| 100 |
current_xlsx = scrape_result["current_xlsx"]
|
| 101 |
df = scrape_result["dataframe"]
|
| 102 |
districts_queried = scrape_result.get("districts_queried", [])
|
| 103 |
+
run_status = scrape_result.get("run_status", "unknown")
|
| 104 |
+
blocked_entries_count = int(scrape_result.get("blocked_entries_count", 0))
|
| 105 |
+
zero_real_result_entries_count = int(scrape_result.get("zero_real_result_entries_count", 0))
|
| 106 |
+
unknown_empty_entries_count = int(scrape_result.get("unknown_empty_entries_count", 0))
|
| 107 |
+
skip_neon = bool(diagnostic_mode) or (blocked_entries_count > 0 and len(df) == 0)
|
| 108 |
|
| 109 |
debug_zip = zip_debug_folder(output_dir)
|
| 110 |
if debug_zip:
|
|
|
|
| 115 |
|
| 116 |
|
| 117 |
_append_log(logs, "[2/6] Preparando persistencia incremental en Neon...")
|
| 118 |
+
if skip_neon:
|
| 119 |
+
stats = {
|
| 120 |
+
"inserted": 0, "updated": 0, "unchanged": 0, "reactivated": 0,
|
| 121 |
+
"missing_updated": 0, "probably_rented": 0, "snapshots": 0,
|
| 122 |
+
}
|
| 123 |
+
if diagnostic_mode:
|
| 124 |
+
_append_log(logs, "[DIAG] Modo diagnóstico activo: se omite Neon.")
|
| 125 |
+
else:
|
| 126 |
+
_append_log(logs, "[BLOCK] Se omite Neon porque hubo bloqueo y no se obtuvieron anuncios reales.")
|
| 127 |
+
elif get_database_url():
|
| 128 |
with connect() as conn:
|
| 129 |
run_id = create_run(
|
| 130 |
conn,
|
|
|
|
| 139 |
"space_runtime": "huggingface-docker",
|
| 140 |
"probably_rented_threshold": probably_rented_threshold,
|
| 141 |
"not_seen_threshold": not_seen_threshold,
|
| 142 |
+
"diagnostic_mode": diagnostic_mode,
|
| 143 |
+
"stop_on_first_block": stop_on_first_block,
|
| 144 |
+
"run_status": run_status,
|
| 145 |
+
"blocked_entries_count": blocked_entries_count,
|
| 146 |
+
"zero_real_result_entries_count": zero_real_result_entries_count,
|
| 147 |
+
"unknown_empty_entries_count": unknown_empty_entries_count,
|
| 148 |
},
|
| 149 |
)
|
| 150 |
stats = upsert_listings(
|
|
|
|
| 155 |
probably_rented_threshold=int(probably_rented_threshold),
|
| 156 |
not_seen_threshold=int(not_seen_threshold),
|
| 157 |
)
|
| 158 |
+
finish_run(conn, run_id, stats, status="partial_blocked" if blocked_entries_count else "success")
|
| 159 |
|
| 160 |
incremental_xlsx = str(output_dir / f"base_incremental_idealista_neon_{ts}.xlsx")
|
| 161 |
export_incremental_excel(conn, incremental_xlsx)
|
|
|
|
| 181 |
_append_log(logs, "[3/6] Archivos Excel generados.")
|
| 182 |
summary = (
|
| 183 |
"Consulta Idealista finalizada.\n"
|
| 184 |
+
f"Estado técnico: {run_status}\n"
|
| 185 |
f"Anuncios encontrados: {len(df)}\n"
|
| 186 |
f"Distritos consultados: {len(districts_queried)}\n"
|
| 187 |
+
f"Bloqueos DataDome: {blocked_entries_count}\n"
|
| 188 |
+
f"Cero resultados reales: {zero_real_result_entries_count}\n"
|
| 189 |
+
f"Páginas vacías desconocidas: {unknown_empty_entries_count}\n"
|
| 190 |
f"Nuevos insertados: {stats.get('inserted', 0)}\n"
|
| 191 |
f"Actualizados: {stats.get('updated', 0)}\n"
|
| 192 |
f"Reactivados: {stats.get('reactivated', 0)}\n"
|
|
|
|
| 209 |
|
| 210 |
status_md = (
|
| 211 |
"### Resultado\n"
|
| 212 |
+
f"- **Estado técnico:** {run_status}\n"
|
| 213 |
f"- **Anuncios encontrados:** {len(df)}\n"
|
| 214 |
f"- **Distritos consultados:** {len(districts_queried)}\n"
|
| 215 |
+
f"- **Bloqueos DataDome:** {blocked_entries_count}\n"
|
| 216 |
+
f"- **Cero resultados reales:** {zero_real_result_entries_count}\n"
|
| 217 |
+
f"- **Páginas vacías desconocidas:** {unknown_empty_entries_count}\n"
|
| 218 |
f"- **Nuevos insertados en Neon:** {stats.get('inserted', 0)}\n"
|
| 219 |
f"- **Actualizados en Neon:** {stats.get('updated', 0)}\n"
|
| 220 |
f"- **Reactivados:** {stats.get('reactivated', 0)}\n"
|
|
|
|
| 294 |
|
| 295 |
lang = gr.Dropdown(label="Idioma URL", choices=["es", "pt"], value="es")
|
| 296 |
send_telegram = gr.Checkbox(label="Enviar resultados por Telegram", value=True)
|
| 297 |
+
diagnostic_mode = gr.Checkbox(
|
| 298 |
+
label="Modo diagnóstico: no persistir en Neon aunque haya base configurada",
|
| 299 |
+
value=True,
|
| 300 |
+
)
|
| 301 |
+
stop_on_first_block = gr.Checkbox(
|
| 302 |
+
label="Cortar corrida completa al primer bloqueo DataDome",
|
| 303 |
+
value=True,
|
| 304 |
+
)
|
| 305 |
|
| 306 |
with gr.Accordion("Parámetros de desplazamiento", open=False):
|
| 307 |
gr.Markdown("La categoría de velocidad se calcula cuando el anuncio llega a `probably_rented`: 1 corrida visible = muy rápido; 2-3 = rápido; 4-6 = normal; 7-10 = lento; 11+ = muy lento. Este criterio está calibrado para ejecución cada tercer día.")
|
|
|
|
| 337 |
entry_wait_ms,
|
| 338 |
lang,
|
| 339 |
send_telegram,
|
| 340 |
+
diagnostic_mode,
|
| 341 |
+
stop_on_first_block,
|
| 342 |
probably_rented_threshold,
|
| 343 |
not_seen_threshold,
|
| 344 |
],
|
scraper.py
CHANGED
|
@@ -380,6 +380,62 @@ def detect_block_status(html: str, body_text: str = "") -> dict:
|
|
| 380 |
}
|
| 381 |
|
| 382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
def diagnose_page(page, page_no, log_fn=print) -> dict:
|
| 384 |
try:
|
| 385 |
title = page.title()
|
|
@@ -477,27 +533,48 @@ def wait_for_list_or_dump(
|
|
| 477 |
debug_dir="debug",
|
| 478 |
log_fn=print,
|
| 479 |
debug_prefix: str | None = None,
|
| 480 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
debug_dir = Path(debug_dir)
|
| 482 |
debug_dir.mkdir(parents=True, exist_ok=True)
|
| 483 |
|
| 484 |
prefix = debug_prefix or "entry_unknown"
|
|
|
|
| 485 |
|
| 486 |
try:
|
| 487 |
page.wait_for_selector(LISTING_SELECTOR_COMBINED, timeout=25000)
|
| 488 |
-
|
| 489 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
|
| 491 |
except PWTimeout:
|
| 492 |
diagnosis = diagnose_page(page, page_no, log_fn=log_fn)
|
| 493 |
html = diagnosis.get("html", "")
|
| 494 |
-
|
| 495 |
|
| 496 |
html_path = debug_dir / f"debug_{prefix}_page{page_no}.html"
|
| 497 |
png_path = debug_dir / f"debug_{prefix}_page{page_no}.png"
|
|
|
|
| 498 |
|
| 499 |
try:
|
| 500 |
-
html_path.write_text(html, encoding="utf-8")
|
| 501 |
log_fn(f"[DEBUG] HTML guardado en: {html_path}")
|
| 502 |
except Exception as e:
|
| 503 |
log_fn(f"[DEBUG] No se pudo guardar HTML debug: {e}")
|
|
@@ -508,13 +585,38 @@ def wait_for_list_or_dump(
|
|
| 508 |
except Exception as e:
|
| 509 |
log_fn(f"[DEBUG] No se pudo guardar screenshot: {e}")
|
| 510 |
|
| 511 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
log_fn("[BLOCK] Idealista devolvió bloqueo/DataDome en lugar de listados.")
|
| 513 |
log_fn("[BLOCK] Se detiene esta URL para no insistir contra el bloqueo.")
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
| 515 |
|
| 516 |
-
|
| 517 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
|
| 519 |
def get_next_url_from_page(page) -> str | None:
|
| 520 |
a = page.query_selector('a[rel="next"]')
|
|
@@ -553,10 +655,13 @@ def fetch_pages_playwright(
|
|
| 553 |
debug_dir: Path | None = None,
|
| 554 |
log_fn: LogFn | None = None,
|
| 555 |
entry_index: int | None = None,
|
| 556 |
-
) ->
|
| 557 |
items: list[dict] = []
|
|
|
|
| 558 |
log = log_fn or print
|
| 559 |
debug_path = debug_dir or Path("debug")
|
|
|
|
|
|
|
| 560 |
with sync_playwright() as p:
|
| 561 |
browser = p.chromium.launch(headless=headless, args=["--disable-blink-features=AutomationControlled"])
|
| 562 |
context = browser.new_context(
|
|
@@ -573,100 +678,125 @@ def fetch_pages_playwright(
|
|
| 573 |
seen_urls: set[str] = set()
|
| 574 |
seen_first_ids: set[str] = set()
|
| 575 |
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
# Espera adicional para páginas dinámicas.
|
| 580 |
-
# Si Idealista deja peticiones abiertas, networkidle puede fallar;
|
| 581 |
-
# por eso no detenemos el scraper si falla esta espera.
|
| 582 |
-
try:
|
| 583 |
-
page.wait_for_load_state("networkidle", timeout=15000)
|
| 584 |
-
except Exception:
|
| 585 |
-
pass
|
| 586 |
-
|
| 587 |
-
accept_cookies_if_needed(page, log_fn=log)
|
| 588 |
-
|
| 589 |
-
# Pequeña espera después de cookies, porque el modal puede ocultar resultados.
|
| 590 |
-
page.wait_for_timeout(2000)
|
| 591 |
-
|
| 592 |
-
# Scroll progresivo para forzar carga de elementos diferidos/lazy load.
|
| 593 |
-
page.evaluate("window.scrollTo(0, document.body.scrollHeight * 0.25)")
|
| 594 |
-
page.wait_for_timeout(1000)
|
| 595 |
-
|
| 596 |
-
page.evaluate("window.scrollTo(0, document.body.scrollHeight * 0.60)")
|
| 597 |
-
page.wait_for_timeout(1000)
|
| 598 |
-
|
| 599 |
-
page.evaluate("window.scrollTo(0, document.body.scrollHeight * 0.95)")
|
| 600 |
-
page.wait_for_timeout(1500)
|
| 601 |
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
page,
|
| 607 |
-
curr_num,
|
| 608 |
-
debug_dir=debug_path,
|
| 609 |
-
log_fn=log,
|
| 610 |
-
debug_prefix=debug_prefix,
|
| 611 |
-
)
|
| 612 |
-
|
| 613 |
-
if wait_status == "blocked":
|
| 614 |
-
log(f"[BLOCK] Se detiene la entrada {debug_prefix} por bloqueo Idealista/DataDome.")
|
| 615 |
-
return []
|
| 616 |
-
|
| 617 |
-
if wait_status is False:
|
| 618 |
-
log(f"[WARN] Sin listado detectable en página {curr_num}. Se detiene este distrito.")
|
| 619 |
-
break
|
| 620 |
-
if page.url in seen_urls:
|
| 621 |
-
_log(log_fn, f"[STOP] URL repetida: {page.url}")
|
| 622 |
-
break
|
| 623 |
-
seen_urls.add(page.url)
|
| 624 |
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
_log(log_fn, f"[STOP] Página {curr_num} sin anuncios.")
|
| 628 |
-
break
|
| 629 |
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
it["global_position"] = ((curr_num - 1) * 30) + int(pos)
|
| 642 |
|
| 643 |
-
|
| 644 |
-
|
|
|
|
|
|
|
| 645 |
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
if next_num <= curr_num:
|
| 651 |
-
_log(log_fn, f"[STOP] Paginación no avanza ({curr_num}→{next_num}).")
|
| 652 |
-
break
|
| 653 |
-
current_url = next_url
|
| 654 |
-
|
| 655 |
-
base_wait = max(int(wait_ms), 5000)
|
| 656 |
-
jitter = random.uniform(0.70, 1.60)
|
| 657 |
-
real_wait_ms = int(base_wait * jitter)
|
| 658 |
-
|
| 659 |
-
log(
|
| 660 |
-
f"[WAIT] Pausa entre páginas: {real_wait_ms / 1000:.1f}s "
|
| 661 |
-
f"(base={wait_ms}ms, jitter={jitter:.2f})."
|
| 662 |
-
)
|
| 663 |
-
|
| 664 |
-
page.wait_for_timeout(real_wait_ms)
|
| 665 |
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
|
|
|
| 669 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
|
| 671 |
def sanitize_filename(stem: str) -> str:
|
| 672 |
return re.sub(r"[^a-zA-Z0-9_\-\.]+", "_", stem).strip("_") or "consulta"
|
|
@@ -718,6 +848,8 @@ def run_scrape_job(
|
|
| 718 |
wait_ms: int = 30000,
|
| 719 |
entry_wait_ms: int = 90000,
|
| 720 |
lang: str = DEFAULT_LANG,
|
|
|
|
|
|
|
| 721 |
log_fn: LogFn | None = None,
|
| 722 |
) -> dict:
|
| 723 |
input_xlsx_path = Path(input_xlsx_path)
|
|
@@ -732,8 +864,15 @@ def run_scrape_job(
|
|
| 732 |
raise ValueError("El Excel de entrada no contiene URLs ni slugs válidos en la primera columna.")
|
| 733 |
|
| 734 |
_log(log_fn, f"[INFO] Entradas detectadas: {len(entries)}")
|
|
|
|
|
|
|
|
|
|
| 735 |
master: list[dict] = []
|
| 736 |
districts_queried: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
per_district_dir = output_dir / "out_por_distrito"
|
| 738 |
per_district_dir.mkdir(parents=True, exist_ok=True)
|
| 739 |
|
|
@@ -743,11 +882,11 @@ def run_scrape_job(
|
|
| 743 |
_log(log_fn, f"[WARN] Entrada vacía o inválida: {entry!r}")
|
| 744 |
continue
|
| 745 |
district_slug = extract_slug_from_url(base_url)
|
| 746 |
-
|
| 747 |
safe_stem = sanitize_filename(district_slug)
|
| 748 |
_log(log_fn, f"\n[RUN] {idx}/{len(entries)} · {entry} → {base_url}")
|
| 749 |
|
| 750 |
-
|
| 751 |
base_url,
|
| 752 |
max_pages=max_pages,
|
| 753 |
wait_ms=wait_ms,
|
|
@@ -757,13 +896,22 @@ def run_scrape_job(
|
|
| 757 |
log_fn=log_fn,
|
| 758 |
entry_index=idx,
|
| 759 |
)
|
| 760 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
districts_queried.append(district_slug)
|
| 762 |
else:
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
f"[WARN] El distrito {district_slug} no se marcará como consultado porque no se obtuvieron anuncios."
|
| 766 |
-
)
|
| 767 |
|
| 768 |
scraped_at = datetime.now(timezone.utc).isoformat()
|
| 769 |
for it in data:
|
|
@@ -778,36 +926,73 @@ def run_scrape_job(
|
|
| 778 |
district_df.to_csv(per_district_dir / f"idealista_{safe_stem}_{ts}.csv", index=False, encoding="utf-8")
|
| 779 |
district_df.to_excel(per_district_dir / f"idealista_{safe_stem}_{ts}.xlsx", index=False)
|
| 780 |
master.extend(data)
|
| 781 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 782 |
if idx < len(entries):
|
| 783 |
base_sleep = max(int(entry_wait_ms), 30000) / 1000
|
| 784 |
jitter = random.uniform(0.75, 1.50)
|
| 785 |
sleep_seconds = base_sleep * jitter
|
| 786 |
-
|
| 787 |
_log(
|
| 788 |
log_fn,
|
| 789 |
f"[WAIT] Pausa entre URLs/distritos: {sleep_seconds:.1f}s "
|
| 790 |
f"(base={entry_wait_ms}ms, jitter={jitter:.2f})."
|
| 791 |
)
|
| 792 |
-
|
| 793 |
time.sleep(sleep_seconds)
|
| 794 |
|
| 795 |
master_df = clean_dataframe(pd.DataFrame(master)) if master else clean_dataframe(pd.DataFrame())
|
|
|
|
| 796 |
current_xlsx = output_dir / f"{name_no_ext}_consulta_actual_{ts}.xlsx"
|
| 797 |
current_csv = output_dir / f"{name_no_ext}_consulta_actual_{ts}.csv"
|
| 798 |
current_json = output_dir / f"{name_no_ext}_consulta_actual_{ts}.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 799 |
|
| 800 |
-
master_df.to_excel(current_xlsx, index=False)
|
| 801 |
master_df.to_csv(current_csv, index=False, encoding="utf-8")
|
| 802 |
current_json.write_text(json.dumps(master, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
_log(log_fn, f"\n[OK] Consulta finalizada. Anuncios encontrados: {len(master_df)}")
|
|
|
|
|
|
|
|
|
|
| 804 |
|
| 805 |
return {
|
| 806 |
"timestamp": ts,
|
| 807 |
"entries_count": len(entries),
|
| 808 |
"rows_count": len(master_df),
|
| 809 |
"dataframe": master_df,
|
|
|
|
| 810 |
"districts_queried": sorted(set(districts_queried)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 811 |
"current_xlsx": str(current_xlsx),
|
| 812 |
"current_csv": str(current_csv),
|
| 813 |
"current_json": str(current_json),
|
|
|
|
| 380 |
}
|
| 381 |
|
| 382 |
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def classify_page_state(diagnosis: dict) -> str:
|
| 386 |
+
"""Clasifica la página sin confundir bloqueo con cero resultados reales."""
|
| 387 |
+
flags = diagnosis.get("flags", {}) or {}
|
| 388 |
+
selector_counts = diagnosis.get("selector_counts", {}) or {}
|
| 389 |
+
positive_selectors = sum(1 for v in selector_counts.values() if isinstance(v, int) and v > 0)
|
| 390 |
+
|
| 391 |
+
if flags.get("blocked") or flags.get("datadome") or flags.get("captcha_iframe"):
|
| 392 |
+
return "blocked_datadome"
|
| 393 |
+
if flags.get("access"):
|
| 394 |
+
return "access_denied"
|
| 395 |
+
if positive_selectors > 0:
|
| 396 |
+
return "ok_listings"
|
| 397 |
+
if flags.get("no_results"):
|
| 398 |
+
return "zero_real_results"
|
| 399 |
+
return "unknown_empty_page"
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def make_diagnostic_row(
|
| 403 |
+
*,
|
| 404 |
+
entry_index: int | None,
|
| 405 |
+
input_url: str,
|
| 406 |
+
page_no: int,
|
| 407 |
+
diagnosis: dict,
|
| 408 |
+
status: str,
|
| 409 |
+
html_path: str | None = None,
|
| 410 |
+
png_path: str | None = None,
|
| 411 |
+
) -> dict:
|
| 412 |
+
flags = diagnosis.get("flags", {}) or {}
|
| 413 |
+
counts = diagnosis.get("selector_counts", {}) or {}
|
| 414 |
+
return {
|
| 415 |
+
"ts_utc": datetime.now(timezone.utc).isoformat(),
|
| 416 |
+
"entry_index": entry_index,
|
| 417 |
+
"input_url": input_url,
|
| 418 |
+
"page_no": page_no,
|
| 419 |
+
"status": status,
|
| 420 |
+
"title": diagnosis.get("title"),
|
| 421 |
+
"final_url": diagnosis.get("url"),
|
| 422 |
+
"cookies": flags.get("cookies"),
|
| 423 |
+
"captcha": flags.get("captcha"),
|
| 424 |
+
"access": flags.get("access"),
|
| 425 |
+
"no_results": flags.get("no_results"),
|
| 426 |
+
"datadome": flags.get("datadome"),
|
| 427 |
+
"idealista_blocked": flags.get("idealista_blocked"),
|
| 428 |
+
"captcha_iframe": flags.get("captcha_iframe"),
|
| 429 |
+
"blocked": flags.get("blocked"),
|
| 430 |
+
"article_item_count": counts.get("article.item"),
|
| 431 |
+
"article_data_element_id_count": counts.get("article[data-element-id]"),
|
| 432 |
+
"item_info_container_count": counts.get(".item-info-container"),
|
| 433 |
+
"data_element_id_count": counts.get("[data-element-id]"),
|
| 434 |
+
"html_path": html_path,
|
| 435 |
+
"screenshot_path": png_path,
|
| 436 |
+
"body_sample": (diagnosis.get("body_text") or "")[:1200].replace("\n", " "),
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
def diagnose_page(page, page_no, log_fn=print) -> dict:
|
| 440 |
try:
|
| 441 |
title = page.title()
|
|
|
|
| 533 |
debug_dir="debug",
|
| 534 |
log_fn=print,
|
| 535 |
debug_prefix: str | None = None,
|
| 536 |
+
input_url: str | None = None,
|
| 537 |
+
entry_index: int | None = None,
|
| 538 |
+
) -> dict:
|
| 539 |
+
"""Espera listado o genera diagnóstico estructurado.
|
| 540 |
+
|
| 541 |
+
Devuelve un dict con status. Ya no devuelve True/False/"blocked",
|
| 542 |
+
porque eso impedía distinguir bloqueo, cero resultados reales y página vacía desconocida.
|
| 543 |
+
"""
|
| 544 |
debug_dir = Path(debug_dir)
|
| 545 |
debug_dir.mkdir(parents=True, exist_ok=True)
|
| 546 |
|
| 547 |
prefix = debug_prefix or "entry_unknown"
|
| 548 |
+
input_url = input_url or getattr(page, "url", None) or ""
|
| 549 |
|
| 550 |
try:
|
| 551 |
page.wait_for_selector(LISTING_SELECTOR_COMBINED, timeout=25000)
|
| 552 |
+
diagnosis = diagnose_page(page, page_no, log_fn=log_fn)
|
| 553 |
+
status = classify_page_state(diagnosis)
|
| 554 |
+
log_fn(f"[OK] Estado de página {page_no}: {status}.")
|
| 555 |
+
return {
|
| 556 |
+
"status": status,
|
| 557 |
+
"diagnosis": diagnosis,
|
| 558 |
+
"diagnostic_row": make_diagnostic_row(
|
| 559 |
+
entry_index=entry_index,
|
| 560 |
+
input_url=input_url,
|
| 561 |
+
page_no=page_no,
|
| 562 |
+
diagnosis=diagnosis,
|
| 563 |
+
status=status,
|
| 564 |
+
),
|
| 565 |
+
}
|
| 566 |
|
| 567 |
except PWTimeout:
|
| 568 |
diagnosis = diagnose_page(page, page_no, log_fn=log_fn)
|
| 569 |
html = diagnosis.get("html", "")
|
| 570 |
+
status = classify_page_state(diagnosis)
|
| 571 |
|
| 572 |
html_path = debug_dir / f"debug_{prefix}_page{page_no}.html"
|
| 573 |
png_path = debug_dir / f"debug_{prefix}_page{page_no}.png"
|
| 574 |
+
json_path = debug_dir / f"debug_{prefix}_page{page_no}_diagnostic.json"
|
| 575 |
|
| 576 |
try:
|
| 577 |
+
html_path.write_text(html, encoding="utf-8", errors="replace")
|
| 578 |
log_fn(f"[DEBUG] HTML guardado en: {html_path}")
|
| 579 |
except Exception as e:
|
| 580 |
log_fn(f"[DEBUG] No se pudo guardar HTML debug: {e}")
|
|
|
|
| 585 |
except Exception as e:
|
| 586 |
log_fn(f"[DEBUG] No se pudo guardar screenshot: {e}")
|
| 587 |
|
| 588 |
+
row = make_diagnostic_row(
|
| 589 |
+
entry_index=entry_index,
|
| 590 |
+
input_url=input_url,
|
| 591 |
+
page_no=page_no,
|
| 592 |
+
diagnosis=diagnosis,
|
| 593 |
+
status=status,
|
| 594 |
+
html_path=str(html_path),
|
| 595 |
+
png_path=str(png_path),
|
| 596 |
+
)
|
| 597 |
+
|
| 598 |
+
try:
|
| 599 |
+
json_path.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 600 |
+
log_fn(f"[DEBUG] Diagnóstico JSON guardado en: {json_path}")
|
| 601 |
+
except Exception as e:
|
| 602 |
+
log_fn(f"[DEBUG] No se pudo guardar diagnóstico JSON: {e}")
|
| 603 |
+
|
| 604 |
+
if status == "blocked_datadome":
|
| 605 |
log_fn("[BLOCK] Idealista devolvió bloqueo/DataDome en lugar de listados.")
|
| 606 |
log_fn("[BLOCK] Se detiene esta URL para no insistir contra el bloqueo.")
|
| 607 |
+
elif status == "zero_real_results":
|
| 608 |
+
log_fn("[INFO] La página parece devolver cero resultados reales, no bloqueo.")
|
| 609 |
+
else:
|
| 610 |
+
log_fn(f"[WARN] Sin listado detectable en página {page_no}. Estado: {status}.")
|
| 611 |
|
| 612 |
+
return {
|
| 613 |
+
"status": status,
|
| 614 |
+
"diagnosis": diagnosis,
|
| 615 |
+
"diagnostic_row": row,
|
| 616 |
+
"html_path": str(html_path),
|
| 617 |
+
"png_path": str(png_path),
|
| 618 |
+
"json_path": str(json_path),
|
| 619 |
+
}
|
| 620 |
|
| 621 |
def get_next_url_from_page(page) -> str | None:
|
| 622 |
a = page.query_selector('a[rel="next"]')
|
|
|
|
| 655 |
debug_dir: Path | None = None,
|
| 656 |
log_fn: LogFn | None = None,
|
| 657 |
entry_index: int | None = None,
|
| 658 |
+
) -> dict:
|
| 659 |
items: list[dict] = []
|
| 660 |
+
diagnostics: list[dict] = []
|
| 661 |
log = log_fn or print
|
| 662 |
debug_path = debug_dir or Path("debug")
|
| 663 |
+
final_status = "unknown_empty_page"
|
| 664 |
+
|
| 665 |
with sync_playwright() as p:
|
| 666 |
browser = p.chromium.launch(headless=headless, args=["--disable-blink-features=AutomationControlled"])
|
| 667 |
context = browser.new_context(
|
|
|
|
| 678 |
seen_urls: set[str] = set()
|
| 679 |
seen_first_ids: set[str] = set()
|
| 680 |
|
| 681 |
+
try:
|
| 682 |
+
for _ in range(1, max_pages + 1):
|
| 683 |
+
safe_goto(page, current_url, wait_until="domcontentloaded", timeout=30000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
|
| 685 |
+
try:
|
| 686 |
+
page.wait_for_load_state("networkidle", timeout=15000)
|
| 687 |
+
except Exception:
|
| 688 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
|
| 690 |
+
accept_cookies_if_needed(page, log_fn=log)
|
| 691 |
+
page.wait_for_timeout(2000)
|
|
|
|
|
|
|
| 692 |
|
| 693 |
+
try:
|
| 694 |
+
page.evaluate("window.scrollTo(0, document.body.scrollHeight * 0.25)")
|
| 695 |
+
page.wait_for_timeout(1000)
|
| 696 |
+
page.evaluate("window.scrollTo(0, document.body.scrollHeight * 0.60)")
|
| 697 |
+
page.wait_for_timeout(1000)
|
| 698 |
+
page.evaluate("window.scrollTo(0, document.body.scrollHeight * 0.95)")
|
| 699 |
+
page.wait_for_timeout(1500)
|
| 700 |
+
except Exception as e:
|
| 701 |
+
log(f"[DEBUG] No se pudo hacer scroll progresivo: {e}")
|
| 702 |
+
|
| 703 |
+
curr_num = get_page_number_from_url(page.url)
|
| 704 |
+
debug_prefix = f"entry{entry_index}" if entry_index is not None else "entry_unknown"
|
| 705 |
+
|
| 706 |
+
wait_result = wait_for_list_or_dump(
|
| 707 |
+
page,
|
| 708 |
+
curr_num,
|
| 709 |
+
debug_dir=debug_path,
|
| 710 |
+
log_fn=log,
|
| 711 |
+
debug_prefix=debug_prefix,
|
| 712 |
+
input_url=current_url,
|
| 713 |
+
entry_index=entry_index,
|
| 714 |
+
)
|
| 715 |
+
page_status = wait_result.get("status", "unknown_empty_page")
|
| 716 |
+
diagnostics.append(wait_result.get("diagnostic_row", {"status": page_status, "input_url": current_url}))
|
| 717 |
|
| 718 |
+
if page_status == "blocked_datadome":
|
| 719 |
+
log(f"[BLOCK] Se detiene la entrada {debug_prefix} por bloqueo Idealista/DataDome.")
|
| 720 |
+
final_status = "blocked_datadome"
|
| 721 |
+
break
|
|
|
|
| 722 |
|
| 723 |
+
if page_status == "access_denied":
|
| 724 |
+
log(f"[BLOCK] Se detiene la entrada {debug_prefix} por acceso denegado.")
|
| 725 |
+
final_status = "access_denied"
|
| 726 |
+
break
|
| 727 |
|
| 728 |
+
if page_status == "zero_real_results":
|
| 729 |
+
log(f"[INFO] Cero resultados reales detectados en página {curr_num}.")
|
| 730 |
+
final_status = "zero_real_results"
|
| 731 |
+
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
|
| 733 |
+
if page_status != "ok_listings":
|
| 734 |
+
log(f"[WARN] Sin listado detectable en página {curr_num}. Estado: {page_status}.")
|
| 735 |
+
final_status = page_status
|
| 736 |
+
break
|
| 737 |
|
| 738 |
+
if page.url in seen_urls:
|
| 739 |
+
_log(log_fn, f"[STOP] URL repetida: {page.url}")
|
| 740 |
+
final_status = "repeated_url"
|
| 741 |
+
break
|
| 742 |
+
seen_urls.add(page.url)
|
| 743 |
+
|
| 744 |
+
batch = parse_listing_html(page.content())
|
| 745 |
+
if not batch:
|
| 746 |
+
_log(log_fn, f"[STOP] Página {curr_num} con selector, pero sin anuncios parseables.")
|
| 747 |
+
final_status = "parse_empty_after_selector"
|
| 748 |
+
break
|
| 749 |
+
|
| 750 |
+
first_id = batch[0].get("listing_id")
|
| 751 |
+
if first_id and first_id in seen_first_ids:
|
| 752 |
+
_log(log_fn, f"[STOP] Primer listing repetido en p{curr_num}; posible bucle.")
|
| 753 |
+
final_status = "repeated_first_listing"
|
| 754 |
+
break
|
| 755 |
+
if first_id:
|
| 756 |
+
seen_first_ids.add(first_id)
|
| 757 |
+
|
| 758 |
+
for it in batch:
|
| 759 |
+
it["page_hint"] = curr_num
|
| 760 |
+
pos = it.get("position_in_page") or 0
|
| 761 |
+
it["global_position"] = ((curr_num - 1) * 30) + int(pos)
|
| 762 |
+
|
| 763 |
+
items.extend(batch)
|
| 764 |
+
final_status = "ok_listings"
|
| 765 |
+
_log(log_fn, f"[OK] Página {curr_num}: {len(batch)} anuncios.")
|
| 766 |
+
|
| 767 |
+
next_url = get_next_url_from_page(page)
|
| 768 |
+
if not next_url:
|
| 769 |
+
break
|
| 770 |
+
next_num = get_page_number_from_url(next_url)
|
| 771 |
+
if next_num <= curr_num:
|
| 772 |
+
_log(log_fn, f"[STOP] Paginación no avanza ({curr_num}→{next_num}).")
|
| 773 |
+
break
|
| 774 |
+
current_url = next_url
|
| 775 |
+
|
| 776 |
+
base_wait = max(int(wait_ms), 5000)
|
| 777 |
+
jitter = random.uniform(0.70, 1.60)
|
| 778 |
+
real_wait_ms = int(base_wait * jitter)
|
| 779 |
+
|
| 780 |
+
log(
|
| 781 |
+
f"[WAIT] Pausa entre páginas: {real_wait_ms / 1000:.1f}s "
|
| 782 |
+
f"(base={wait_ms}ms, jitter={jitter:.2f})."
|
| 783 |
+
)
|
| 784 |
+
page.wait_for_timeout(real_wait_ms)
|
| 785 |
+
finally:
|
| 786 |
+
context.close()
|
| 787 |
+
browser.close()
|
| 788 |
+
|
| 789 |
+
if items and final_status == "blocked_datadome":
|
| 790 |
+
final_status = "partial_blocked"
|
| 791 |
+
elif items:
|
| 792 |
+
final_status = "ok_listings"
|
| 793 |
+
|
| 794 |
+
return {
|
| 795 |
+
"items": items,
|
| 796 |
+
"status": final_status,
|
| 797 |
+
"diagnostics": diagnostics,
|
| 798 |
+
"pages_seen": len(diagnostics),
|
| 799 |
+
}
|
| 800 |
|
| 801 |
def sanitize_filename(stem: str) -> str:
|
| 802 |
return re.sub(r"[^a-zA-Z0-9_\-\.]+", "_", stem).strip("_") or "consulta"
|
|
|
|
| 848 |
wait_ms: int = 30000,
|
| 849 |
entry_wait_ms: int = 90000,
|
| 850 |
lang: str = DEFAULT_LANG,
|
| 851 |
+
diagnostic_mode: bool = False,
|
| 852 |
+
stop_on_first_block: bool = True,
|
| 853 |
log_fn: LogFn | None = None,
|
| 854 |
) -> dict:
|
| 855 |
input_xlsx_path = Path(input_xlsx_path)
|
|
|
|
| 864 |
raise ValueError("El Excel de entrada no contiene URLs ni slugs válidos en la primera columna.")
|
| 865 |
|
| 866 |
_log(log_fn, f"[INFO] Entradas detectadas: {len(entries)}")
|
| 867 |
+
if diagnostic_mode:
|
| 868 |
+
_log(log_fn, "[DIAG] Modo diagnóstico activo: se generará evidencia y app.py evitará persistencia.")
|
| 869 |
+
|
| 870 |
master: list[dict] = []
|
| 871 |
districts_queried: list[str] = []
|
| 872 |
+
diagnostics_rows: list[dict] = []
|
| 873 |
+
blocked_entries: list[str] = []
|
| 874 |
+
zero_real_result_entries: list[str] = []
|
| 875 |
+
unknown_empty_entries: list[str] = []
|
| 876 |
per_district_dir = output_dir / "out_por_distrito"
|
| 877 |
per_district_dir.mkdir(parents=True, exist_ok=True)
|
| 878 |
|
|
|
|
| 882 |
_log(log_fn, f"[WARN] Entrada vacía o inválida: {entry!r}")
|
| 883 |
continue
|
| 884 |
district_slug = extract_slug_from_url(base_url)
|
| 885 |
+
|
| 886 |
safe_stem = sanitize_filename(district_slug)
|
| 887 |
_log(log_fn, f"\n[RUN] {idx}/{len(entries)} · {entry} → {base_url}")
|
| 888 |
|
| 889 |
+
fetch_result = fetch_pages_playwright(
|
| 890 |
base_url,
|
| 891 |
max_pages=max_pages,
|
| 892 |
wait_ms=wait_ms,
|
|
|
|
| 896 |
log_fn=log_fn,
|
| 897 |
entry_index=idx,
|
| 898 |
)
|
| 899 |
+
data = fetch_result.get("items", [])
|
| 900 |
+
entry_status = fetch_result.get("status", "unknown_empty_page")
|
| 901 |
+
diagnostics_rows.extend(fetch_result.get("diagnostics", []))
|
| 902 |
+
|
| 903 |
+
if entry_status == "blocked_datadome":
|
| 904 |
+
blocked_entries.append(district_slug)
|
| 905 |
+
_log(log_fn, f"[BLOCK] El distrito {district_slug} queda como blocked_datadome; NO se marca como consultado.")
|
| 906 |
+
elif entry_status == "zero_real_results":
|
| 907 |
+
zero_real_result_entries.append(district_slug)
|
| 908 |
+
districts_queried.append(district_slug)
|
| 909 |
+
_log(log_fn, f"[INFO] El distrito {district_slug} sí se marca como consultado: cero resultados reales.")
|
| 910 |
+
elif data:
|
| 911 |
districts_queried.append(district_slug)
|
| 912 |
else:
|
| 913 |
+
unknown_empty_entries.append(district_slug)
|
| 914 |
+
_log(log_fn, f"[WARN] El distrito {district_slug} no se marcará como consultado. Estado: {entry_status}.")
|
|
|
|
|
|
|
| 915 |
|
| 916 |
scraped_at = datetime.now(timezone.utc).isoformat()
|
| 917 |
for it in data:
|
|
|
|
| 926 |
district_df.to_csv(per_district_dir / f"idealista_{safe_stem}_{ts}.csv", index=False, encoding="utf-8")
|
| 927 |
district_df.to_excel(per_district_dir / f"idealista_{safe_stem}_{ts}.xlsx", index=False)
|
| 928 |
master.extend(data)
|
| 929 |
+
|
| 930 |
+
if entry_status == "blocked_datadome" and stop_on_first_block:
|
| 931 |
+
_log(log_fn, "[BLOCK] stop_on_first_block=True: se corta la corrida completa.")
|
| 932 |
+
break
|
| 933 |
+
|
| 934 |
if idx < len(entries):
|
| 935 |
base_sleep = max(int(entry_wait_ms), 30000) / 1000
|
| 936 |
jitter = random.uniform(0.75, 1.50)
|
| 937 |
sleep_seconds = base_sleep * jitter
|
| 938 |
+
|
| 939 |
_log(
|
| 940 |
log_fn,
|
| 941 |
f"[WAIT] Pausa entre URLs/distritos: {sleep_seconds:.1f}s "
|
| 942 |
f"(base={entry_wait_ms}ms, jitter={jitter:.2f})."
|
| 943 |
)
|
| 944 |
+
|
| 945 |
time.sleep(sleep_seconds)
|
| 946 |
|
| 947 |
master_df = clean_dataframe(pd.DataFrame(master)) if master else clean_dataframe(pd.DataFrame())
|
| 948 |
+
diagnostics_df = pd.DataFrame(diagnostics_rows)
|
| 949 |
current_xlsx = output_dir / f"{name_no_ext}_consulta_actual_{ts}.xlsx"
|
| 950 |
current_csv = output_dir / f"{name_no_ext}_consulta_actual_{ts}.csv"
|
| 951 |
current_json = output_dir / f"{name_no_ext}_consulta_actual_{ts}.json"
|
| 952 |
+
diagnostics_csv = output_dir / f"{name_no_ext}_diagnostico_{ts}.csv"
|
| 953 |
+
diagnostics_json = output_dir / f"{name_no_ext}_diagnostico_{ts}.json"
|
| 954 |
+
|
| 955 |
+
with pd.ExcelWriter(current_xlsx, engine="openpyxl") as writer:
|
| 956 |
+
master_df.to_excel(writer, index=False, sheet_name="consulta_actual")
|
| 957 |
+
diagnostics_df.to_excel(writer, index=False, sheet_name="diagnostico_corrida")
|
| 958 |
|
|
|
|
| 959 |
master_df.to_csv(current_csv, index=False, encoding="utf-8")
|
| 960 |
current_json.write_text(json.dumps(master, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 961 |
+
diagnostics_df.to_csv(diagnostics_csv, index=False, encoding="utf-8")
|
| 962 |
+
diagnostics_json.write_text(json.dumps(diagnostics_rows, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 963 |
+
|
| 964 |
+
if blocked_entries and not master:
|
| 965 |
+
run_status = "blocked_datadome"
|
| 966 |
+
elif blocked_entries and master:
|
| 967 |
+
run_status = "partial_blocked"
|
| 968 |
+
elif zero_real_result_entries and not master:
|
| 969 |
+
run_status = "zero_real_results"
|
| 970 |
+
elif unknown_empty_entries and not master:
|
| 971 |
+
run_status = "unknown_empty_page"
|
| 972 |
+
else:
|
| 973 |
+
run_status = "success"
|
| 974 |
+
|
| 975 |
_log(log_fn, f"\n[OK] Consulta finalizada. Anuncios encontrados: {len(master_df)}")
|
| 976 |
+
_log(log_fn, f"[DIAG] Estado de corrida: {run_status}")
|
| 977 |
+
_log(log_fn, f"[DIAG] Bloqueos DataDome: {len(blocked_entries)}")
|
| 978 |
+
_log(log_fn, f"[DIAG] Cero resultados reales: {len(zero_real_result_entries)}")
|
| 979 |
|
| 980 |
return {
|
| 981 |
"timestamp": ts,
|
| 982 |
"entries_count": len(entries),
|
| 983 |
"rows_count": len(master_df),
|
| 984 |
"dataframe": master_df,
|
| 985 |
+
"diagnostics_dataframe": diagnostics_df,
|
| 986 |
"districts_queried": sorted(set(districts_queried)),
|
| 987 |
+
"run_status": run_status,
|
| 988 |
+
"blocked_entries": sorted(set(blocked_entries)),
|
| 989 |
+
"blocked_entries_count": len(set(blocked_entries)),
|
| 990 |
+
"zero_real_result_entries": sorted(set(zero_real_result_entries)),
|
| 991 |
+
"zero_real_result_entries_count": len(set(zero_real_result_entries)),
|
| 992 |
+
"unknown_empty_entries": sorted(set(unknown_empty_entries)),
|
| 993 |
+
"unknown_empty_entries_count": len(set(unknown_empty_entries)),
|
| 994 |
+
"diagnostics_csv": str(diagnostics_csv),
|
| 995 |
+
"diagnostics_json": str(diagnostics_json),
|
| 996 |
"current_xlsx": str(current_xlsx),
|
| 997 |
"current_csv": str(current_csv),
|
| 998 |
"current_json": str(current_json),
|