PHDMAFIN commited on
Commit
9e93472
·
verified ·
1 Parent(s): b6142f6

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +44 -2
  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 get_database_url():
 
 
 
 
 
 
 
 
 
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
- log_fn(f"[OK] Listado detectado en página {page_no}.")
489
- return True
 
 
 
 
 
 
 
 
 
 
 
 
490
 
491
  except PWTimeout:
492
  diagnosis = diagnose_page(page, page_no, log_fn=log_fn)
493
  html = diagnosis.get("html", "")
494
- flags = diagnosis.get("flags", {})
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
- if flags.get("blocked"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return "blocked"
 
 
 
515
 
516
- log_fn(f"[WARN] Sin listado detectable en página {page_no}.")
517
- return False
 
 
 
 
 
 
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
- ) -> list[dict]:
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
- for _ in range(1, max_pages + 1):
577
- safe_goto(page, current_url, wait_until="domcontentloaded", timeout=30000)
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
- curr_num = get_page_number_from_url(page.url)
603
- debug_prefix = f"entry{entry_index}" if entry_index is not None else "entry_unknown"
604
-
605
- wait_status = wait_for_list_or_dump(
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
- batch = parse_listing_html(page.content())
626
- if not batch:
627
- _log(log_fn, f"[STOP] Página {curr_num} sin anuncios.")
628
- break
629
 
630
- first_id = batch[0].get("listing_id")
631
- if first_id and first_id in seen_first_ids:
632
- _log(log_fn, f"[STOP] Primer listing repetido en p{curr_num}; posible bucle.")
633
- break
634
- if first_id:
635
- seen_first_ids.add(first_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
 
637
- for it in batch:
638
- it["page_hint"] = curr_num
639
- pos = it.get("position_in_page") or 0
640
- # Supone 30 anuncios por página como aproximación operativa para ordenar globalmente.
641
- it["global_position"] = ((curr_num - 1) * 30) + int(pos)
642
 
643
- items.extend(batch)
644
- _log(log_fn, f"[OK] Página {curr_num}: {len(batch)} anuncios.")
 
 
645
 
646
- next_url = get_next_url_from_page(page)
647
- if not next_url:
648
- break
649
- next_num = get_page_number_from_url(next_url)
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
- context.close()
667
- browser.close()
668
- return items
 
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
- data = fetch_pages_playwright(
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
- if data:
 
 
 
 
 
 
 
 
 
 
 
761
  districts_queried.append(district_slug)
762
  else:
763
- _log(
764
- log_fn,
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),