Lukeetah commited on
Commit
9b8d583
·
verified ·
1 Parent(s): a849e47

Update web_scraper_tool.py

Browse files
Files changed (1) hide show
  1. web_scraper_tool.py +219 -157
web_scraper_tool.py CHANGED
@@ -6,6 +6,55 @@ from urllib.parse import urlparse, urlunparse, urljoin
6
  import tempfile
7
  import os
8
  import re
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  class WebScrapperTool:
11
  def __init__(self):
@@ -13,17 +62,34 @@ class WebScrapperTool:
13
  self.session.headers.update({
14
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
15
  })
16
- self.font_path = self._find_font()
17
  if not self.font_path:
18
- print("Advertencia: No se encontró 'DejaVuSansCondensed.ttf'. Se usará Arial para PDFs (soporte Unicode limitado).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- def _find_font(self):
21
- font_name = 'DejaVuSansCondensed.ttf'
22
- if os.path.exists(font_name): return font_name
23
- if os.path.exists(os.path.join('fonts', font_name)): return os.path.join('fonts', font_name)
24
- return None
25
 
26
  def normalize_url(self, url: str) -> str:
 
27
  url = url.strip()
28
  parsed_url = urlparse(url)
29
  scheme = parsed_url.scheme
@@ -42,43 +108,38 @@ class WebScrapperTool:
42
  return urlunparse(parsed_url)
43
 
44
  def is_image_url(self, url: str) -> bool:
 
45
  image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.webp']
46
  parsed_url = urlparse(url)
47
  return any(parsed_url.path.lower().endswith(ext) for ext in image_extensions)
48
 
49
  def _get_content(self, url: str, is_for_image_download=False):
 
50
  try:
51
- # Si es para descargar una imagen específica, el stream es útil.
52
- # Si es para contenido general, stream=False es usualmente mejor para que response.content esté completo.
53
  stream_setting = True if is_for_image_download or self.is_image_url(url) else False
54
  response = self.session.get(url, timeout=20, allow_redirects=True, stream=stream_setting)
55
  response.raise_for_status()
56
-
57
  content_type_header = response.headers.get('content-type', '').lower()
58
 
59
- # Si es una URL de imagen o el content-type es de imagen
60
- if 'image' in content_type_header or (self.is_image_url(url) and not is_for_image_download): # Evitar doble descarga si llamamos para imagen
61
- raw_content = response.content # Leer todo
62
  return None, raw_content, content_type_header
63
-
64
- # Si se llamó específicamente para descargar una imagen (y no es html)
65
  if is_for_image_download and 'image' in content_type_header:
66
  return None, response.content, content_type_header
67
 
68
- # Para contenido textual
69
  try:
70
  content_text = response.content.decode('utf-8')
71
  except UnicodeDecodeError:
72
  content_text = response.text
73
-
74
  return content_text, response.content, content_type_header
75
  except requests.exceptions.Timeout:
76
  return None, None, f"Error: Timeout al acceder a la URL: {url}"
77
  except requests.exceptions.RequestException as e:
78
  return None, None, f"Error de conexión/HTTP ({url}): {str(e)}"
79
 
 
80
  def scrape_to_text(self, url: str):
81
- # ... (el método scrape_to_text permanece igual que en la versión anterior)
82
  text_content, _, content_type_or_error_msg = self._get_content(url)
83
 
84
  if text_content is None and not ('image' in content_type_or_error_msg):
@@ -88,7 +149,7 @@ class WebScrapperTool:
88
  final_text = ""
89
  if 'text/html' in content_type_or_error_msg and text_content:
90
  soup = BeautifulSoup(text_content, 'html.parser')
91
- for element in soup(["script", "style", "nav", "footer", "aside", "form", "button", "input", "header", "figure", "figcaption"]): # Remove figure/figcaption for pure text
92
  element.decompose()
93
  body = soup.find('body')
94
  if body:
@@ -96,11 +157,10 @@ class WebScrapperTool:
96
  final_text = "\n".join(text_items)
97
  else:
98
  final_text = "\n".join([s.strip() for s in soup.stripped_strings if s.strip()])
99
-
100
  elif 'text/plain' in content_type_or_error_msg and text_content:
101
  final_text = text_content
102
  elif self.is_image_url(url) or ('image' in content_type_or_error_msg):
103
- return {'status': 'error', 'message': f"La URL apunta a una imagen. El formato TXT es para contenido textual. Intente el formato PDF para imágenes.", 'url': url}
104
  elif text_content:
105
  final_text = text_content
106
  else:
@@ -120,159 +180,161 @@ class WebScrapperTool:
120
 
121
 
122
  def scrape_to_pdf(self, url: str):
123
- text_content, raw_content, content_type_or_error_msg = self._get_content(url)
 
124
 
125
- if text_content is None and raw_content is None:
126
- return {'status': 'error', 'message': content_type_or_error_msg, 'url': url}
127
 
128
- is_direct_image_url = 'image' in content_type_or_error_msg or self.is_image_url(url)
129
 
130
- pdf = FPDF()
131
- pdf.add_page()
132
- pdf.set_auto_page_break(auto=True, margin=15)
133
- current_font = 'Arial'
134
- if self.font_path:
135
- try:
136
- pdf.add_font('DejaVu', '', self.font_path, uni=True)
137
- current_font = 'DejaVu'
138
- except Exception as e_font:
139
- print(f"Error al cargar fuente DejaVu: {e_font}. Usando Arial.")
 
 
 
 
 
 
 
 
140
 
141
 
142
- if is_direct_image_url and raw_content: # Si la URL es directamente una imagen
143
- try:
144
- img_suffix = '.' + content_type_or_error_msg.split('/')[-1].split(';')[0].strip()
145
- if img_suffix == '.': img_suffix = '.jpg'
146
- valid_img_suffixes = ['.jpeg', '.jpg', '.png']
147
- if img_suffix not in valid_img_suffixes:
148
- if 'png' in img_suffix: img_suffix = '.png'
149
- else: img_suffix = '.jpg'
150
-
151
- with tempfile.NamedTemporaryFile(delete=False, suffix=img_suffix) as tmp_img:
152
- tmp_img.write(raw_content)
153
- img_path = tmp_img.name
154
-
155
  try:
156
- page_width = pdf.w - 2 * pdf.l_margin
157
- pdf.image(img_path, x=pdf.l_margin, y=pdf.t_margin, w=page_width)
158
- except RuntimeError as re_img:
159
- return {'status': 'error', 'message': f"Error al añadir imagen directa al PDF ({img_suffix}): {str(re_img)}", 'url': url}
160
- finally:
161
- if os.path.exists(img_path): os.unlink(img_path)
162
- except Exception as e_img:
163
- return {'status': 'error', 'message': f"Error procesando imagen directa para PDF: {str(e_img)}", 'url': url}
164
-
165
- elif 'text/html' in content_type_or_error_msg and text_content: # Si es una página HTML
166
- soup = BeautifulSoup(text_content, 'html.parser')
167
-
168
- # --- Escribir URL como título ---
169
- pdf.set_font(current_font, 'B', 12)
170
- pdf.multi_cell(0, 8, f"Contenido de: {url}")
171
- pdf.ln(6)
172
- pdf.set_font(current_font, '', 11)
173
-
174
- # --- Extraer y escribir texto ---
175
- # Remover scripts, estilos, etc. pero mantener la estructura para imágenes
176
- for element in soup(["script", "style", "nav", "footer", "aside", "form", "button", "input", "header"]):
177
- element.decompose()
178
 
179
- content_area = soup.find('main') or soup.find('article') or soup.find('body')
180
- if not content_area:
181
- return {'status': 'error', 'message': "No se encontró área de contenido principal (main, article, body).", 'url': url}
182
-
183
- for element in content_area.find_all(recursive=True): # Iterar sobre todos los elementos descendientes
184
- if isinstance(element, Tag):
185
- if element.name == 'img':
186
- img_src = element.get('src') or element.get('data-src') # Común para lazy loading
187
- if img_src:
188
- img_url_abs = urljoin(url, img_src) # Convertir a URL absoluta
189
- pdf.ln(5) # Espacio antes de la imagen
190
- try:
191
- print(f"Intentando descargar imagen: {img_url_abs}")
192
- _, img_data, img_content_type = self._get_content(img_url_abs, is_for_image_download=True)
193
- if img_data and 'image' in img_content_type:
194
- img_sfx = '.' + img_content_type.split('/')[-1].split(';')[0].strip()
195
- if img_sfx == '.': img_sfx = '.jpg'
196
-
197
- with tempfile.NamedTemporaryFile(delete=False, suffix=img_sfx) as tmp_img_file:
198
- tmp_img_file.write(img_data)
199
- tmp_img_path = tmp_img_file.name
200
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  try:
202
- page_w = pdf.w - 2 * pdf.l_margin
203
- pdf.image(tmp_img_path, x=None, y=None, w=page_w) # Ajustar al ancho
204
- pdf.ln(2) # Pequeño espacio después de la imagen
205
- print(f"Imagen {img_url_abs} añadida al PDF.")
206
- except RuntimeError as e_fpdf_img:
207
- print(f"Error FPDF al añadir imagen {img_url_abs}: {e_fpdf_img}")
208
- pdf.set_font(current_font, 'I', 9) # Cursiva y pequeño
209
- pdf.multi_cell(0,5, f"[Error al renderizar imagen: {img_url_abs} - {e_fpdf_img}]")
210
- pdf.set_font(current_font, '', 11) # Volver a fuente normal
211
- finally:
212
- if os.path.exists(tmp_img_path): os.unlink(tmp_img_path)
213
  else:
214
- print(f"No se pudo descargar o no es una imagen: {img_url_abs}")
215
- except Exception as e_dl_img:
216
- print(f"Excepción al descargar/procesar imagen {img_url_abs}: {e_dl_img}")
217
- pdf.set_font(current_font, 'I', 9)
218
- pdf.multi_cell(0,5, f"[Error al descargar imagen: {img_url_abs}]")
219
- pdf.set_font(current_font, '', 11)
220
- pdf.ln(5) # Espacio después del intento de imagen
221
-
222
- # Manejar texto dentro de párrafos, divs, etc.
223
- # Tomar texto solo de ciertos elementos o el texto 'directo' del elemento actual.
224
- # Esto evita duplicar texto si `element.stripped_strings` se usa en un nodo padre.
225
- # Tomar texto que es hijo directo del elemento actual y no está dentro de otro 'img' o bloque ya procesado.
226
- elif element.name in ['p', 'div', 'span', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'th', 'caption', 'article', 'section', 'blockquote']:
227
- # Procesar el texto que es hijo directo (string) de este elemento
228
- current_element_text = ""
229
- for content_child in element.contents:
230
- if isinstance(content_child, str) and content_child.strip():
231
- current_element_text += content_child.strip() + " "
232
-
233
- if current_element_text.strip():
234
- clean_para = self._clean_text_for_pdf(current_element_text.strip())
235
- if element.name.startswith('h'): # Estilo para encabezados
236
- pdf.set_font(current_font, 'B', 14 - int(element.name[1])) # h1=13, h2=12, etc.
237
- pdf.multi_cell(0, 7, clean_para)
238
- pdf.set_font(current_font, '', 11) # Reset
239
- else:
240
- pdf.multi_cell(0, 7, clean_para)
241
- pdf.ln(1) # Pequeño espacio entre párrafos de texto
242
-
243
- # Si después de todo no se añadió contenido, error
244
- if pdf.page_no() == 1 and pdf.y < 30: # Heurística: si no se ha escrito mucho en la primera página
245
- return {'status': 'error', 'message': "No se encontró contenido textual o imágenes extraíbles de la página HTML.", 'url': url}
246
 
 
 
 
 
 
 
247
 
248
- elif 'text/plain' in content_type_or_error_msg and text_content:
249
- pdf.set_font(current_font, 'B', 12)
250
- pdf.multi_cell(0, 8, f"Contenido de: {url}")
251
- pdf.ln(6)
252
- pdf.set_font(current_font, '', 11)
253
- clean_text = self._clean_text_for_pdf(text_content)
254
- pdf.multi_cell(0, 7, clean_text)
255
- else:
256
- return {'status': 'error', 'message': f"Tipo de contenido no soportado o vacío para PDF: {content_type_or_error_msg}", 'url': url}
 
257
 
258
- # Guardar el PDF
259
- try:
260
  with tempfile.NamedTemporaryFile(delete=False, mode='wb', suffix='.pdf') as tmp_file:
261
  pdf_output_bytes = pdf.output(dest='S')
262
  tmp_file.write(pdf_output_bytes)
263
  filepath = tmp_file.name
264
  return {'status': 'success', 'file': filepath, 'url': url}
265
- except Exception as e:
266
- import traceback
267
  tb_str = traceback.format_exc()
268
- error_message = f"Error final al generar PDF: {str(e)}\nDetalles: {tb_str}"
269
- if len(error_message) > 500: error_message = error_message[:497] + "..."
 
270
  return {'status': 'error', 'message': error_message, 'url': url}
271
 
272
- def _clean_text_for_pdf(self, text: str) -> str:
273
- clean = text.replace('\u2013', '-').replace('\u2014', '--')
274
- clean = clean.replace('\u2018', "'").replace('\u2019', "'")
275
- clean = clean.replace('\u201c', '"').replace('\u201d', '"')
276
- clean = clean.replace('\u2026', '...')
277
- clean = clean.replace('\u00A0', ' ')
278
- return "".join(c for c in clean if c.isprintable() or c in ('\n', '\r', '\t'))
 
6
  import tempfile
7
  import os
8
  import re
9
+ import traceback # Para un mejor logging de errores
10
+
11
+ # Helper para limpiar texto para FPDF, especialmente con fuentes no Unicode
12
+ def clean_problematic_chars(text, use_unicode_font=False):
13
+ """
14
+ Limpia o reemplaza caracteres que suelen causar problemas en FPDF,
15
+ especialmente si no se usa una fuente Unicode completa.
16
+ """
17
+ if use_unicode_font:
18
+ # Con una fuente Unicode, menos reemplazos son necesarios, pero algunos
19
+ # caracteres de control o muy específicos aún pueden causar problemas.
20
+ # El reemplazo de espacios de no ruptura es generalmente seguro.
21
+ text = text.replace('\u00A0', ' ') # No-breaking space
22
+ # Podrías añadir más reemplazos específicos para fuentes Unicode si encuentras problemas
23
+ else:
24
+ # Para fuentes no Unicode (latin-1 like)
25
+ replacements = {
26
+ '\u20AC': 'EUR', # Euro sign
27
+ '\u00A3': 'GBP', # Pound sign
28
+ '\u00A5': 'JPY', # Yen sign
29
+ '\u2013': '-', # En Dash
30
+ '\u2014': '--', # Em Dash
31
+ '\u2018': "'", # Left single quotation mark
32
+ '\u2019': "'", # Right single quotation mark
33
+ '\u201C': '"', # Left double quotation mark
34
+ '\u201D': '"', # Right double quotation mark
35
+ '\u2026': '...', # Horizontal ellipsis
36
+ '\u00A0': ' ', # No-breaking space
37
+ '\u00A9': '(C)', # Copyright
38
+ '\u00AE': '(R)', # Registered trademark
39
+ # Añade más según sea necesario
40
+ }
41
+ for problematic, replacement in replacements.items():
42
+ text = text.replace(problematic, replacement)
43
+
44
+ # Filtrar cualquier cosa que no sea imprimible o no esté en latin-1 aproximado
45
+ # Esto es agresivo y puede perder caracteres.
46
+ text = "".join(c for c in text if c.isprintable() or c in ('\n', '\r', '\t'))
47
+ try:
48
+ # Intenta codificar a latin-1 y decodificar para eliminar caracteres no compatibles
49
+ text = text.encode('latin-1', 'ignore').decode('latin-1')
50
+ except Exception:
51
+ # Si falla, recurre a una limpieza aún más básica
52
+ text = "".join(c for c in text if ord(c) < 256 or c in ('\n', '\r', '\t'))
53
+
54
+
55
+ # Limpieza general de caracteres de control restantes (excepto tab, lf, cr)
56
+ return "".join(c for c in text if c.isprintable() or c in ('\n', '\r', '\t'))
57
+
58
 
59
  class WebScrapperTool:
60
  def __init__(self):
 
62
  self.session.headers.update({
63
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
64
  })
65
+ self.font_path, self.font_name_for_fpdf = self._find_and_setup_font()
66
  if not self.font_path:
67
+ print("ADVERTENCIA: No se encontró 'DejaVuSansCondensed.ttf'. Se usará Arial para PDFs (soporte Unicode limitado).")
68
+ print("Para un mejor soporte de caracteres internacionales, descargue DejaVuSansCondensed.ttf y colóquelo en la raíz del proyecto o en una carpeta 'fonts'.")
69
+ else:
70
+ print(f"INFO: Usando fuente {self.font_name_for_fpdf} desde {self.font_path} para PDFs.")
71
+ self.using_unicode_font = bool(self.font_path)
72
+
73
+
74
+ def _find_and_setup_font(self):
75
+ # Devuelve (ruta_completa_fuente, nombre_familia_para_fpdf) o (None, 'Arial')
76
+ font_file_name = 'DejaVuSansCondensed.ttf'
77
+ font_family_name = 'DejaVu' # Nombre que usaremos en FPDF
78
+
79
+ # Buscar en el directorio actual
80
+ if os.path.exists(font_file_name):
81
+ return os.path.abspath(font_file_name), font_family_name
82
+
83
+ # Buscar en una subcarpeta 'fonts'
84
+ fonts_dir_path = os.path.join(os.path.dirname(__file__), 'fonts', font_file_name)
85
+ if os.path.exists(fonts_dir_path):
86
+ return os.path.abspath(fonts_dir_path), font_family_name
87
+
88
+ return None, 'Arial' # Fallback a fuente core de FPDF
89
 
 
 
 
 
 
90
 
91
  def normalize_url(self, url: str) -> str:
92
+ # ... (sin cambios)
93
  url = url.strip()
94
  parsed_url = urlparse(url)
95
  scheme = parsed_url.scheme
 
108
  return urlunparse(parsed_url)
109
 
110
  def is_image_url(self, url: str) -> bool:
111
+ # ... (sin cambios)
112
  image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.webp']
113
  parsed_url = urlparse(url)
114
  return any(parsed_url.path.lower().endswith(ext) for ext in image_extensions)
115
 
116
  def _get_content(self, url: str, is_for_image_download=False):
117
+ # ... (sin cambios significativos, quizás logging)
118
  try:
 
 
119
  stream_setting = True if is_for_image_download or self.is_image_url(url) else False
120
  response = self.session.get(url, timeout=20, allow_redirects=True, stream=stream_setting)
121
  response.raise_for_status()
 
122
  content_type_header = response.headers.get('content-type', '').lower()
123
 
124
+ if 'image' in content_type_header or (self.is_image_url(url) and not is_for_image_download):
125
+ raw_content = response.content
 
126
  return None, raw_content, content_type_header
 
 
127
  if is_for_image_download and 'image' in content_type_header:
128
  return None, response.content, content_type_header
129
 
 
130
  try:
131
  content_text = response.content.decode('utf-8')
132
  except UnicodeDecodeError:
133
  content_text = response.text
 
134
  return content_text, response.content, content_type_header
135
  except requests.exceptions.Timeout:
136
  return None, None, f"Error: Timeout al acceder a la URL: {url}"
137
  except requests.exceptions.RequestException as e:
138
  return None, None, f"Error de conexión/HTTP ({url}): {str(e)}"
139
 
140
+
141
  def scrape_to_text(self, url: str):
142
+ # ... (sin cambios)
143
  text_content, _, content_type_or_error_msg = self._get_content(url)
144
 
145
  if text_content is None and not ('image' in content_type_or_error_msg):
 
149
  final_text = ""
150
  if 'text/html' in content_type_or_error_msg and text_content:
151
  soup = BeautifulSoup(text_content, 'html.parser')
152
+ for element in soup(["script", "style", "nav", "footer", "aside", "form", "button", "input", "header", "figure", "figcaption"]):
153
  element.decompose()
154
  body = soup.find('body')
155
  if body:
 
157
  final_text = "\n".join(text_items)
158
  else:
159
  final_text = "\n".join([s.strip() for s in soup.stripped_strings if s.strip()])
 
160
  elif 'text/plain' in content_type_or_error_msg and text_content:
161
  final_text = text_content
162
  elif self.is_image_url(url) or ('image' in content_type_or_error_msg):
163
+ return {'status': 'error', 'message': f"La URL apunta a una imagen. El formato TXT es para contenido textual.", 'url': url}
164
  elif text_content:
165
  final_text = text_content
166
  else:
 
180
 
181
 
182
  def scrape_to_pdf(self, url: str):
183
+ try: # Envolver todo el proceso de PDF para capturar errores de forma más general
184
+ text_content, raw_content, content_type_or_error_msg = self._get_content(url)
185
 
186
+ if text_content is None and raw_content is None:
187
+ return {'status': 'error', 'message': content_type_or_error_msg, 'url': url}
188
 
189
+ is_direct_image_url = 'image' in content_type_or_error_msg or self.is_image_url(url)
190
 
191
+ pdf = FPDF()
192
+ pdf.add_page()
193
+ pdf.set_auto_page_break(auto=True, margin=15)
194
+
195
+ current_fpdf_font_name = self.font_name_for_fpdf # Nombre de familia para FPDF
196
+
197
+ if self.using_unicode_font:
198
+ try:
199
+ pdf.add_font(self.font_name_for_fpdf, '', self.font_path, uni=True)
200
+ print(f"INFO: Fuente Unicode '{self.font_name_for_fpdf}' registrada en FPDF.")
201
+ except Exception as e_font:
202
+ print(f"ERROR al registrar fuente Unicode '{self.font_name_for_fpdf}' desde '{self.font_path}': {e_font}")
203
+ traceback.print_exc()
204
+ print("ADVERTENCIA: Recurriendo a fuente Arial debido a error con fuente Unicode.")
205
+ current_fpdf_font_name = 'Arial' # Fallback
206
+ self.using_unicode_font = False # Actualizar estado
207
+ else: # No estamos usando fuente Unicode (no se encontró o falló al cargarla)
208
+ print("INFO: No se está usando una fuente Unicode. El soporte de caracteres será limitado.")
209
 
210
 
211
+ if is_direct_image_url and raw_content:
212
+ # ... (lógica de imagen directa sin cambios significativos en limpieza de texto)
 
 
 
 
 
 
 
 
 
 
 
213
  try:
214
+ img_suffix = '.' + content_type_or_error_msg.split('/')[-1].split(';')[0].strip()
215
+ if img_suffix == '.': img_suffix = '.jpg'
216
+ valid_img_suffixes = ['.jpeg', '.jpg', '.png']
217
+ if img_suffix not in valid_img_suffixes:
218
+ if 'png' in img_suffix: img_suffix = '.png'
219
+ else: img_suffix = '.jpg'
220
+
221
+ with tempfile.NamedTemporaryFile(delete=False, suffix=img_suffix) as tmp_img:
222
+ tmp_img.write(raw_content)
223
+ img_path = tmp_img.name
224
+
225
+ try:
226
+ page_width = pdf.w - 2 * pdf.l_margin
227
+ pdf.image(img_path, x=pdf.l_margin, y=pdf.t_margin, w=page_width)
228
+ except RuntimeError as re_img:
229
+ return {'status': 'error', 'message': f"Error al añadir imagen directa al PDF ({img_suffix}): {str(re_img)}", 'url': url}
230
+ finally:
231
+ if os.path.exists(img_path): os.unlink(img_path)
232
+ except Exception as e_img:
233
+ return {'status': 'error', 'message': f"Error procesando imagen directa para PDF: {str(e_img)}", 'url': url}
 
 
234
 
235
+ elif 'text/html' in content_type_or_error_msg and text_content:
236
+ soup = BeautifulSoup(text_content, 'html.parser')
237
+
238
+ pdf.set_font(current_fpdf_font_name, 'B', 12)
239
+ cleaned_url_title = clean_problematic_chars(f"Contenido de: {url}", self.using_unicode_font)
240
+ pdf.multi_cell(0, 8, cleaned_url_title)
241
+ pdf.ln(6)
242
+ pdf.set_font(current_fpdf_font_name, '', 11)
243
+
244
+ for element in soup(["script", "style", "nav", "footer", "aside", "form", "button", "input", "header"]):
245
+ element.decompose()
246
+
247
+ content_area = soup.find('main') or soup.find('article') or soup.find('body')
248
+ if not content_area:
249
+ return {'status': 'error', 'message': "No se encontró área de contenido principal.", 'url': url}
250
+
251
+ for element in content_area.find_all(recursive=True):
252
+ if isinstance(element, Tag):
253
+ if element.name == 'img':
254
+ img_src = element.get('src') or element.get('data-src')
255
+ if img_src:
256
+ img_url_abs = urljoin(url, img_src)
257
+ pdf.ln(5)
258
+ try:
259
+ # print(f"Intentando descargar imagen: {img_url_abs}")
260
+ _, img_data, img_content_type = self._get_content(img_url_abs, is_for_image_download=True)
261
+ if img_data and 'image' in img_content_type:
262
+ img_sfx = '.' + img_content_type.split('/')[-1].split(';')[0].strip()
263
+ if img_sfx == '.': img_sfx = '.jpg'
264
+
265
+ with tempfile.NamedTemporaryFile(delete=False, suffix=img_sfx) as tmp_img_file:
266
+ tmp_img_file.write(img_data)
267
+ tmp_img_path = tmp_img_file.name
268
+
269
+ try:
270
+ page_w = pdf.w - 2 * pdf.l_margin
271
+ pdf.image(tmp_img_path, x=None, y=None, w=page_w)
272
+ pdf.ln(2)
273
+ # print(f"Imagen {img_url_abs} añadida al PDF.")
274
+ except RuntimeError as e_fpdf_img:
275
+ print(f"Error FPDF al añadir imagen {img_url_abs}: {e_fpdf_img}")
276
+ pdf.set_font(current_fpdf_font_name, 'I', 9)
277
+ err_img_msg = clean_problematic_chars(f"[Error render img: {img_url_abs} - {e_fpdf_img}]", self.using_unicode_font)
278
+ pdf.multi_cell(0,5, err_img_msg)
279
+ pdf.set_font(current_fpdf_font_name, '', 11)
280
+ finally:
281
+ if os.path.exists(tmp_img_path): os.unlink(tmp_img_path)
282
+ # else: print(f"No se pudo descargar o no es una imagen: {img_url_abs}")
283
+ except Exception as e_dl_img:
284
+ print(f"Excepción al descargar/procesar imagen {img_url_abs}: {e_dl_img}")
285
+ pdf.set_font(current_fpdf_font_name, 'I', 9)
286
+ err_dl_msg = clean_problematic_chars(f"[Error download img: {img_url_abs}]", self.using_unicode_font)
287
+ pdf.multi_cell(0,5, err_dl_msg)
288
+ pdf.set_font(current_fpdf_font_name, '', 11)
289
+ pdf.ln(5)
290
+
291
+ elif element.name in ['p', 'div', 'span', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'th', 'caption', 'article', 'section', 'blockquote']:
292
+ current_element_text = ""
293
+ for content_child in element.contents:
294
+ if isinstance(content_child, str) and content_child.strip():
295
+ current_element_text += content_child.strip() + " "
296
+
297
+ if current_element_text.strip():
298
+ clean_para = clean_problematic_chars(current_element_text.strip(), self.using_unicode_font)
299
+ if element.name.startswith('h') and len(element.name) == 2 : # h1, h2 .. h6
300
  try:
301
+ header_level = int(element.name[1])
302
+ font_size = max(8, 16 - header_level * 1.5) # Ajusta el tamaño base y el decremento
303
+ pdf.set_font(current_fpdf_font_name, 'B', font_size)
304
+ except ValueError: # por si acaso element.name no es h[numero]
305
+ pdf.set_font(current_fpdf_font_name, 'B', 11) # fallback a negrita normal
 
 
 
 
 
 
306
  else:
307
+ pdf.set_font(current_fpdf_font_name, '', 11) # Texto normal
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
+ pdf.multi_cell(0, 7, clean_para)
310
+ pdf.set_font(current_fpdf_font_name, '', 11) # Reset font a normal para el siguiente elemento
311
+ pdf.ln(1)
312
+
313
+ if pdf.page_no() == 1 and pdf.y < 30: # (y después de la URL del título)
314
+ return {'status': 'error', 'message': "No se encontró contenido textual o imágenes extraíbles de la página HTML.", 'url': url}
315
 
316
+ elif 'text/plain' in content_type_or_error_msg and text_content:
317
+ pdf.set_font(current_fpdf_font_name, 'B', 12)
318
+ cleaned_url_title = clean_problematic_chars(f"Contenido de: {url}", self.using_unicode_font)
319
+ pdf.multi_cell(0, 8, cleaned_url_title)
320
+ pdf.ln(6)
321
+ pdf.set_font(current_fpdf_font_name, '', 11)
322
+ clean_text_content = clean_problematic_chars(text_content, self.using_unicode_font)
323
+ pdf.multi_cell(0, 7, clean_text_content)
324
+ else:
325
+ return {'status': 'error', 'message': f"Tipo de contenido no soportado o vacío para PDF: {content_type_or_error_msg}", 'url': url}
326
 
 
 
327
  with tempfile.NamedTemporaryFile(delete=False, mode='wb', suffix='.pdf') as tmp_file:
328
  pdf_output_bytes = pdf.output(dest='S')
329
  tmp_file.write(pdf_output_bytes)
330
  filepath = tmp_file.name
331
  return {'status': 'success', 'file': filepath, 'url': url}
332
+
333
+ except Exception as e_pdf_gen: # Captura general para la generación de PDF
334
  tb_str = traceback.format_exc()
335
+ error_message = f"Error al generar PDF: {str(e_pdf_gen)}. Detalles: {tb_str}"
336
+ if len(error_message) > 600: error_message = error_message[:597] + "..." # Aumentar un poco el límite del mensaje de error
337
+ print(f"ERROR CRÍTICO en scrape_to_pdf: {error_message}")
338
  return {'status': 'error', 'message': error_message, 'url': url}
339
 
340
+ # _clean_text_for_pdf ya no es un método de clase, es la función `clean_problematic_chars` al inicio del archivo.