574 lines
20 KiB
Python
574 lines
20 KiB
Python
"""Parser v2: PDF → chunks semánticos con estructura, overlap y parent-child.
|
|
|
|
Módulo importable. Funciones principales:
|
|
- parse_libro_v2(libro_id, pdf_path) → (children, parents)
|
|
- detect_structure(pages, libro_id) → structured_pages
|
|
- generate_chunks_v2(structured_pages, libro_id) → (children, parents)
|
|
|
|
CLI: python parser_v2.py <libro_id> [--all] [--status]
|
|
"""
|
|
|
|
import fitz # PyMuPDF
|
|
import json
|
|
import os
|
|
import re
|
|
import unicodedata
|
|
from datetime import datetime
|
|
|
|
LIBROS_DIR = os.path.join(os.path.dirname(__file__), "..", "LIBROS")
|
|
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "parsed")
|
|
CATALOG_PATH = os.path.join(os.path.dirname(__file__), "catalog.json")
|
|
|
|
# --- Configuración de chunking ---
|
|
TARGET_SIZE = 280 # palabras objetivo por child chunk
|
|
MIN_SIZE = 150 # mínimo aceptable
|
|
MAX_SIZE = 380 # máximo antes de forzar corte
|
|
OVERLAP_SIZE = 60 # palabras de overlap entre chunks
|
|
PARENT_WINDOW = 3 # cantidad de children por parent chunk
|
|
MAX_PARENT_WORDS = 1200 # máximo de palabras por parent
|
|
|
|
# --- Patrones de estructura por libro ---
|
|
|
|
STRUCTURE_PATTERNS = {
|
|
"farreras-2020": {
|
|
"capitulo": [
|
|
r'^SECCIÓN\s+[IVXLCDM]+\b',
|
|
r'^Capítulo\s+\d+',
|
|
r'^CAPÍTULO\s+\d+',
|
|
],
|
|
"seccion": [
|
|
r'^\d+\.\d+[\s\.]+[A-ZÁÉÍÓÚÑ]', # 23.4 Glaucoma...
|
|
r'^[A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{5,60}$', # MODELOS DE REGRESIÓN (línea sola en mayúsculas)
|
|
],
|
|
},
|
|
"garcia-feijoo-2012": {
|
|
"capitulo": [
|
|
r'PA\s*R\s*T\s*E\s+\d+', # PA R T E 1 : BÁSICO
|
|
r'^PARTE\s+\d+',
|
|
],
|
|
"seccion": [
|
|
r'^\d+\s*\|\s*.+', # 1 | Embriología. Desarrollo...
|
|
r'^\d+\.\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]{3,}', # 1. Embriología... (requiere palabra real, no "3. Mixto.")
|
|
],
|
|
},
|
|
"diamante-orl": {
|
|
"capitulo": [
|
|
r'^SECCIÓN\s+[IVXLCDM]+',
|
|
r'^Sección\s+[IVXLCDM]+',
|
|
r'^CAPÍTULO\s+\d+',
|
|
],
|
|
"seccion": [
|
|
r'^\d+\.\s+[A-ZÁÉÍÓÚÑ]',
|
|
r'^[A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{5,50}$',
|
|
],
|
|
},
|
|
"_default": {
|
|
"capitulo": [
|
|
r'^SECCIÓN\s+[IVXLCDM]+',
|
|
r'^CAPÍTULO\s+\d+',
|
|
r'^Capítulo\s+\d+',
|
|
r'^PARTE\s+\d+',
|
|
],
|
|
"seccion": [
|
|
r'^\d+\.\d+[\s\.]+[A-ZÁÉÍÓÚÑ]',
|
|
r'^[A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{5,60}$',
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
def load_catalog():
|
|
with open(CATALOG_PATH, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def save_catalog(catalog):
|
|
with open(CATALOG_PATH, "w", encoding="utf-8") as f:
|
|
json.dump(catalog, f, ensure_ascii=False, indent=2)
|
|
|
|
|
|
def normalize_for_search(text: str) -> str:
|
|
"""Quita acentos y pasa a minúsculas para full-text search."""
|
|
nfkd = unicodedata.normalize('NFKD', text)
|
|
return ''.join(c for c in nfkd if not unicodedata.combining(c)).lower()
|
|
|
|
|
|
def clean_text(text: str) -> str:
|
|
"""Limpia texto extraído de PDF."""
|
|
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
text = re.sub(r'^\s*\d{1,4}\s*$', '', text, flags=re.MULTILINE)
|
|
text = re.sub(r'(?i)^.*medicina interna.*edici[oó]n.*$', '', text, flags=re.MULTILINE)
|
|
text = re.sub(r'(?i)^.*booksmedicos\.org.*$', '', text, flags=re.MULTILINE)
|
|
text = re.sub(r'(?i)^.*© Elsevier\. Fotocopiar sin autorización es un delito\..*$', '', text, flags=re.MULTILINE)
|
|
text = re.sub(r'(?i)^.*© \d{4}.*Elsevier.*$', '', text, flags=re.MULTILINE)
|
|
text = re.sub(r'[ \t]+', ' ', text)
|
|
text = '\n'.join(line.strip() for line in text.split('\n'))
|
|
return text.strip()
|
|
|
|
|
|
def classify_content_type(text: str) -> str:
|
|
"""Clasifica el tipo de contenido de un chunk."""
|
|
lines = text.strip().split('\n')
|
|
if not lines:
|
|
return "body"
|
|
|
|
# Tablas: muchas líneas con | o tabulaciones
|
|
tab_lines = sum(1 for l in lines if '|' in l or '\t' in l)
|
|
if tab_lines > len(lines) * 0.3 and tab_lines >= 3:
|
|
return "tabla"
|
|
|
|
# Listas: muchas líneas empezando con -, *, •, números
|
|
list_lines = sum(1 for l in lines if re.match(r'^\s*[-*•●■]\s', l) or re.match(r'^\s*\d+[\.\)]\s', l))
|
|
if list_lines > len(lines) * 0.3 and list_lines >= 3:
|
|
return "lista"
|
|
|
|
# Definiciones: empieza con patrones típicos
|
|
first_100 = text[:200].lower()
|
|
if any(p in first_100 for p in ['concepto', 'definición', 'se define como', 'se denomina', 'es la']):
|
|
return "definicion"
|
|
|
|
return "body"
|
|
|
|
|
|
def _clean_spaced_text(text: str) -> str:
|
|
"""Reconstruye texto con letras espaciadas del PDF.
|
|
|
|
"PA R T E 3 : A M E T R O P Í A S" → "PARTE 3 : AMETROPÍAS"
|
|
"B Á S I C O" → "BÁSICO"
|
|
"""
|
|
# Buscar secuencias donde hay letras sueltas separadas por un espacio
|
|
# Patrón: al menos 2 pares de "letra espacio" seguidos de una letra final
|
|
letter = r'[A-ZÁÉÍÓÚÑa-záéíóúñ]'
|
|
pattern = rf'({letter} ){{2,}}{letter}'
|
|
|
|
def collapse_spaced(match):
|
|
return match.group(0).replace(' ', '')
|
|
|
|
result = re.sub(pattern, collapse_spaced, text)
|
|
return result
|
|
|
|
|
|
def detect_structure(pages: list, libro_id: str) -> list:
|
|
"""Detecta títulos de capítulo y sección en las páginas.
|
|
|
|
Args:
|
|
pages: Lista de {page, text}
|
|
libro_id: ID del libro para seleccionar patrones
|
|
|
|
Returns:
|
|
Lista de {page, text, titulo_capitulo, titulo_seccion}
|
|
"""
|
|
patterns = STRUCTURE_PATTERNS.get(libro_id, STRUCTURE_PATTERNS["_default"])
|
|
cap_patterns = [re.compile(p, re.MULTILINE) for p in patterns["capitulo"]]
|
|
sec_patterns = [re.compile(p, re.MULTILINE) for p in patterns["seccion"]]
|
|
|
|
current_capitulo = ""
|
|
current_seccion = ""
|
|
structured = []
|
|
|
|
for page_data in pages:
|
|
text = page_data["text"]
|
|
lines = text.split('\n')
|
|
|
|
for line in lines:
|
|
line_stripped = line.strip()
|
|
if not line_stripped or len(line_stripped) < 3:
|
|
continue
|
|
|
|
# Detectar capítulo
|
|
for pat in cap_patterns:
|
|
if pat.match(line_stripped):
|
|
# Limpiar: reconstruir texto con espacios intercalados
|
|
# "PA R T E 3 : A M E T R O P Í A S" → "PARTE 3: AMETROPÍAS"
|
|
clean_cap = _clean_spaced_text(line_stripped)
|
|
clean_cap = re.sub(r'\s{2,}', ' ', clean_cap).strip()
|
|
current_capitulo = clean_cap[:120]
|
|
current_seccion = ""
|
|
break
|
|
|
|
# Detectar sección (solo si no fue detectado como capítulo)
|
|
is_capitulo = any(pat.match(line_stripped) for pat in cap_patterns)
|
|
if not is_capitulo:
|
|
for pat in sec_patterns:
|
|
if pat.match(line_stripped):
|
|
# Solo aceptar como sección si es línea corta (título, no contenido)
|
|
# y no contiene punto seguido de más texto (indica oración, no título)
|
|
if len(line_stripped) < 80 and line_stripped.count('.') <= 2:
|
|
current_seccion = line_stripped[:100]
|
|
break
|
|
|
|
structured.append({
|
|
"page": page_data["page"],
|
|
"text": text,
|
|
"titulo_capitulo": current_capitulo,
|
|
"titulo_seccion": current_seccion,
|
|
})
|
|
|
|
return structured
|
|
|
|
|
|
def _find_sentence_boundary(words: list, target_idx: int) -> int:
|
|
"""Busca el final de oración más cercano al target_idx.
|
|
|
|
Retorna el índice del último word que termina una oración,
|
|
buscando en un rango de ±30 palabras del target.
|
|
"""
|
|
search_start = max(0, target_idx - 30)
|
|
search_end = min(len(words), target_idx + 30)
|
|
|
|
best = target_idx
|
|
best_dist = 999
|
|
|
|
for i in range(search_start, search_end):
|
|
word = words[i]
|
|
if word.endswith(('.', '?', '!', ':')) and not re.match(r'^\d+\.$', word):
|
|
dist = abs(i - target_idx)
|
|
if dist < best_dist:
|
|
best = i
|
|
best_dist = dist
|
|
|
|
return best + 1 # Retorna posición después del punto
|
|
|
|
|
|
def generate_chunks_v2(structured_pages: list, libro_id: str,
|
|
target_size: int = TARGET_SIZE,
|
|
overlap: int = OVERLAP_SIZE) -> tuple:
|
|
"""Genera child chunks y parent chunks a partir de páginas estructuradas.
|
|
|
|
Args:
|
|
structured_pages: Lista de {page, text, titulo_capitulo, titulo_seccion}
|
|
libro_id: ID del libro
|
|
target_size: Palabras objetivo por chunk (default 280)
|
|
overlap: Palabras de overlap (default 60)
|
|
|
|
Returns:
|
|
(child_chunks, parent_chunks)
|
|
"""
|
|
# Construir un buffer continuo con metadata por página
|
|
all_words = [] # Lista plana de palabras
|
|
word_meta = [] # Metadata por palabra: (page, capitulo, seccion)
|
|
|
|
for sp in structured_pages:
|
|
page = sp["page"]
|
|
cap = sp["titulo_capitulo"]
|
|
sec = sp["titulo_seccion"]
|
|
words = sp["text"].split()
|
|
|
|
for w in words:
|
|
all_words.append(w)
|
|
word_meta.append((page, cap, sec))
|
|
|
|
if not all_words:
|
|
return [], []
|
|
|
|
# Generar child chunks con overlap
|
|
children = []
|
|
pos = 0
|
|
chunk_index = 0
|
|
|
|
while pos < len(all_words):
|
|
# Determinar fin del chunk
|
|
end_target = pos + target_size
|
|
|
|
if end_target >= len(all_words):
|
|
# Último chunk: tomar todo lo que queda
|
|
end = len(all_words)
|
|
else:
|
|
# Buscar límite de oración cerca del target
|
|
end = _find_sentence_boundary(all_words, end_target)
|
|
|
|
# Si el chunk es demasiado grande, forzar corte
|
|
if end - pos > MAX_SIZE:
|
|
end = _find_sentence_boundary(all_words, pos + MAX_SIZE)
|
|
if end - pos > MAX_SIZE + 50:
|
|
end = pos + MAX_SIZE # Corte duro como último recurso
|
|
|
|
# Chunk demasiado pequeño al final: merge con anterior
|
|
if end - pos < MIN_SIZE and children:
|
|
prev = children[-1]
|
|
prev["text"] = prev["text"] + " " + " ".join(all_words[pos:end])
|
|
prev["word_count"] = len(prev["text"].split())
|
|
prev["page_end"] = word_meta[end - 1][0]
|
|
break
|
|
|
|
chunk_text = " ".join(all_words[pos:end])
|
|
page_start = word_meta[pos][0]
|
|
page_end = word_meta[end - 1][0]
|
|
|
|
# Metadata: tomar la del inicio del chunk (más representativa)
|
|
capitulo = word_meta[pos][1]
|
|
seccion = word_meta[pos][2]
|
|
|
|
# Si hay cambio de capítulo/sección dentro del chunk, usar el más nuevo
|
|
for i in range(pos, min(end, pos + 50)):
|
|
if word_meta[i][1] and word_meta[i][1] != capitulo:
|
|
capitulo = word_meta[i][1]
|
|
if word_meta[i][2] and word_meta[i][2] != seccion:
|
|
seccion = word_meta[i][2]
|
|
|
|
tipo = classify_content_type(chunk_text)
|
|
|
|
children.append({
|
|
"id": f"{libro_id}_v2_{chunk_index:05d}",
|
|
"libro_id": libro_id,
|
|
"page_start": page_start,
|
|
"page_end": page_end,
|
|
"text": chunk_text,
|
|
"word_count": end - pos,
|
|
"titulo_capitulo": capitulo,
|
|
"titulo_seccion": seccion,
|
|
"tipo_contenido": tipo,
|
|
"parent_id": None, # Se asigna después
|
|
"chunk_index": chunk_index,
|
|
"version": 2,
|
|
})
|
|
|
|
chunk_index += 1
|
|
|
|
# Avanzar con overlap
|
|
next_pos = end - overlap
|
|
if next_pos <= pos:
|
|
next_pos = end # Evitar loop infinito
|
|
pos = next_pos
|
|
|
|
# Generar parent chunks (ventana de PARENT_WINDOW children)
|
|
parents = []
|
|
parent_idx = 0
|
|
i = 0
|
|
|
|
while i < len(children):
|
|
window_end = min(i + PARENT_WINDOW, len(children))
|
|
window = children[i:window_end]
|
|
|
|
# Concatenar textos de los children (sin overlap duplicado)
|
|
parent_text_parts = []
|
|
for j, child in enumerate(window):
|
|
if j == 0:
|
|
parent_text_parts.append(child["text"])
|
|
else:
|
|
# Quitar overlap del inicio de este child (ya está en el anterior)
|
|
child_words = child["text"].split()
|
|
# El overlap son las últimas ~OVERLAP_SIZE palabras del child anterior
|
|
skip = min(overlap, len(child_words) // 3) # No skipear más de 1/3
|
|
parent_text_parts.append(" ".join(child_words[skip:]))
|
|
|
|
parent_text = " ".join(parent_text_parts)
|
|
parent_words = len(parent_text.split())
|
|
|
|
# Si el parent es muy grande, solo tomar lo necesario
|
|
if parent_words > MAX_PARENT_WORDS:
|
|
parent_text = " ".join(parent_text.split()[:MAX_PARENT_WORDS])
|
|
parent_words = MAX_PARENT_WORDS
|
|
|
|
parent_id = f"{libro_id}_v2_parent_{parent_idx:05d}"
|
|
|
|
parents.append({
|
|
"id": parent_id,
|
|
"libro_id": libro_id,
|
|
"page_start": window[0]["page_start"],
|
|
"page_end": window[-1]["page_end"],
|
|
"text": parent_text,
|
|
"word_count": parent_words,
|
|
"titulo_capitulo": window[0]["titulo_capitulo"],
|
|
"titulo_seccion": window[0]["titulo_seccion"],
|
|
"child_ids": [c["id"] for c in window],
|
|
"version": 2,
|
|
})
|
|
|
|
# Asignar parent_id a los children
|
|
for child in window:
|
|
child["parent_id"] = parent_id
|
|
|
|
parent_idx += 1
|
|
i += PARENT_WINDOW
|
|
|
|
return children, parents
|
|
|
|
|
|
def parse_pdf_v2(pdf_path: str, libro_id: str) -> tuple:
|
|
"""Parsea un PDF completo a chunks v2.
|
|
|
|
Args:
|
|
pdf_path: Ruta al archivo PDF
|
|
libro_id: ID del libro
|
|
|
|
Returns:
|
|
(children, parents) — listas de dicts
|
|
"""
|
|
doc = fitz.open(pdf_path)
|
|
total_pages = doc.page_count
|
|
print(f" Parseando {os.path.basename(pdf_path)} ({total_pages} págs)...")
|
|
|
|
# Extraer texto por página
|
|
pages = []
|
|
for i in range(total_pages):
|
|
page = doc[i]
|
|
text = page.get_text()
|
|
text = clean_text(text)
|
|
|
|
if len(text.strip()) > 20:
|
|
pages.append({"page": i + 1, "text": text})
|
|
|
|
if (i + 1) % 500 == 0:
|
|
print(f" ... {i + 1}/{total_pages} páginas")
|
|
|
|
doc.close()
|
|
print(f" {len(pages)} páginas con texto extraído")
|
|
|
|
# Detectar estructura
|
|
structured = detect_structure(pages, libro_id)
|
|
|
|
# Generar chunks
|
|
children, parents = generate_chunks_v2(structured, libro_id)
|
|
print(f" Resultado: {len(children)} children, {len(parents)} parents")
|
|
|
|
return children, parents
|
|
|
|
|
|
def parse_libro_v2(libro_id: str, pdf_path: str = None,
|
|
on_progress: callable = None) -> tuple:
|
|
"""Parsea un libro por ID o ruta directa. Función principal importable.
|
|
|
|
Args:
|
|
libro_id: ID del libro en el catálogo
|
|
pdf_path: Ruta al PDF (opcional, se busca en catálogo si no se da)
|
|
on_progress: Callback(step, pct, msg) para reportar progreso
|
|
|
|
Returns:
|
|
(children, parents) — listas de dicts
|
|
"""
|
|
def report(pct, msg):
|
|
if on_progress:
|
|
on_progress("parse", pct, msg)
|
|
print(f" [{pct}%] {msg}")
|
|
|
|
# Resolver ruta del PDF
|
|
if not pdf_path:
|
|
catalog = load_catalog()
|
|
libro = next((l for l in catalog["libros"] if l["id"] == libro_id), None)
|
|
if not libro:
|
|
raise ValueError(f"Libro '{libro_id}' no encontrado en catálogo")
|
|
pdf_path = os.path.join(LIBROS_DIR, libro["archivo"])
|
|
|
|
if not os.path.exists(pdf_path):
|
|
raise FileNotFoundError(f"PDF no encontrado: {pdf_path}")
|
|
|
|
report(0, f"Iniciando parseo de {libro_id}")
|
|
|
|
# Parsear
|
|
children, parents = parse_pdf_v2(pdf_path, libro_id)
|
|
|
|
# Guardar JSONs
|
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
|
|
children_path = os.path.join(OUTPUT_DIR, f"{libro_id}_v2_chunks.json")
|
|
with open(children_path, "w", encoding="utf-8") as f:
|
|
json.dump(children, f, ensure_ascii=False, indent=2)
|
|
|
|
parents_path = os.path.join(OUTPUT_DIR, f"{libro_id}_v2_parents.json")
|
|
with open(parents_path, "w", encoding="utf-8") as f:
|
|
json.dump(parents, f, ensure_ascii=False, indent=2)
|
|
|
|
report(100, f"Guardado: {len(children)} children -> {children_path}")
|
|
|
|
# Actualizar catálogo
|
|
catalog = load_catalog()
|
|
libro_entry = next((l for l in catalog["libros"] if l["id"] == libro_id), None)
|
|
if libro_entry:
|
|
libro_entry["chunks_v2"] = len(children)
|
|
libro_entry["parents_v2"] = len(parents)
|
|
libro_entry["fecha_parseo_v2"] = datetime.now().isoformat()
|
|
save_catalog(catalog)
|
|
|
|
return children, parents
|
|
|
|
|
|
# --- CLI ---
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
if len(sys.argv) < 2:
|
|
print("\nparser_v2 — Chunking semántico con estructura, overlap y parent-child")
|
|
print("\nUso:")
|
|
print(" python parser_v2.py status - Ver estado")
|
|
print(" python parser_v2.py <libro_id> - Parsear un libro")
|
|
print(" python parser_v2.py all - Parsear todos los digitales")
|
|
print(" python parser_v2.py <libro_id> --preview - Preview: 10 chunks sin guardar")
|
|
catalog = load_catalog()
|
|
print("\nLibros disponibles:")
|
|
for lib in catalog["libros"]:
|
|
v2 = f" (v2: {lib.get('chunks_v2', 0)} chunks)" if lib.get('chunks_v2') else ""
|
|
print(f" {lib['id']}: {lib['titulo']} ({lib['estado']}){v2}")
|
|
sys.exit(0)
|
|
|
|
cmd = sys.argv[1]
|
|
|
|
if cmd == "status":
|
|
catalog = load_catalog()
|
|
print(f"\n{'='*60}")
|
|
print(f" ESTADO DE PARSEO V2")
|
|
print(f"{'='*60}")
|
|
for lib in catalog["libros"]:
|
|
v2_chunks = lib.get("chunks_v2", 0)
|
|
v2_parents = lib.get("parents_v2", 0)
|
|
if v2_chunks:
|
|
icon = "[v2]"
|
|
info = f" → {v2_chunks} children, {v2_parents} parents"
|
|
elif lib["estado"] == "parseado":
|
|
icon = "[v1]"
|
|
info = f" → {lib.get('chunks_generados', 0)} chunks v1"
|
|
else:
|
|
icon = "[ ]"
|
|
info = ""
|
|
print(f" {icon} {lib['titulo']} ({lib['paginas']} págs, {lib['tipo_pdf']}){info}")
|
|
|
|
elif cmd == "all":
|
|
catalog = load_catalog()
|
|
for lib in catalog["libros"]:
|
|
if lib["tipo_pdf"] == "digital" and lib["estado"] == "parseado":
|
|
print(f"\n{'='*60}")
|
|
print(f" {lib['titulo']}")
|
|
print(f"{'='*60}")
|
|
parse_libro_v2(lib["id"])
|
|
elif lib["tipo_pdf"] == "escaneado":
|
|
print(f"\n SKIP: {lib['titulo']} (escaneado)")
|
|
|
|
elif "--preview" in sys.argv:
|
|
libro_id = cmd
|
|
catalog = load_catalog()
|
|
libro = next((l for l in catalog["libros"] if l["id"] == libro_id), None)
|
|
if not libro:
|
|
print(f"Libro '{libro_id}' no encontrado")
|
|
sys.exit(1)
|
|
pdf_path = os.path.join(LIBROS_DIR, libro["archivo"])
|
|
children, parents = parse_pdf_v2(pdf_path, libro_id)
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f" PREVIEW: {len(children)} children, {len(parents)} parents")
|
|
print(f"{'='*60}")
|
|
|
|
# Mostrar stats
|
|
sizes = [c["word_count"] for c in children]
|
|
print(f" Tamaño promedio: {sum(sizes)/len(sizes):.0f} palabras")
|
|
print(f" Min: {min(sizes)}, Max: {max(sizes)}")
|
|
|
|
caps = set(c["titulo_capitulo"] for c in children if c["titulo_capitulo"])
|
|
secs = set(c["titulo_seccion"] for c in children if c["titulo_seccion"])
|
|
print(f" Capítulos detectados: {len(caps)}")
|
|
print(f" Secciones detectadas: {len(secs)}")
|
|
|
|
# Mostrar 10 chunks del medio (no prólogos)
|
|
start = len(children) // 3
|
|
for c in children[start:start+10]:
|
|
print(f"\n--- [{c['id']}] págs {c['page_start']}-{c['page_end']} ({c['word_count']} words) ---")
|
|
print(f" Cap: {c['titulo_capitulo'][:60] if c['titulo_capitulo'] else '(no detectado)'}")
|
|
print(f" Sec: {c['titulo_seccion'][:60] if c['titulo_seccion'] else '(no detectado)'}")
|
|
print(f" Tipo: {c['tipo_contenido']}")
|
|
print(f" Parent: {c['parent_id']}")
|
|
preview_text = c['text'][:200].encode('ascii', 'replace').decode('ascii')
|
|
print(f" Texto: {preview_text}...")
|
|
|
|
else:
|
|
parse_libro_v2(cmd)
|