Vamos a extraer un hilo de foro entero: 137 mensajes repartidos en varias páginas, guardados en un fichero que el resto del pipeline pueda leer.
Lo montamos en cuatro pasos, y cada uno se ejecuta antes de pasar al siguiente. La razón es práctica: si escribes de una vez el bucle de páginas, los selectores y el guardado, cuando falle no sabrás cuál de los tres falla.
Paso 1 — Bajar una página y mirarla
Antes de escribir un solo selector hay que ver qué devuelve el servidor. Cuatro líneas:
import httpx
URL = "https://ejemplo.com/hilo/?pag=1"
respuesta = httpx.get(URL, timeout=20)
print(respuesta.status_code)
print(len(respuesta.text))| Código | Qué hace | Si vienes de JavaScript |
|---|---|---|
import httpx | Carga la librería HTTP | El fetch/axios de Python |
URL = "..." | Constante | Python no tiene const. MAYÚSCULAS es una convención entre humanos, el intérprete no la impone |
httpx.get(...) | Petición GET | Bloquea. No devuelve promesa, no hay await |
.status_code | 200, 404… | En JS es .status. Aquí snake_case |
.text | El cuerpo como string | En JS harías await res.text(). Aquí ya está |
Los tres fallos que se disfrazan de éxito
La primera fuente de este proyecto devolvió esto:
status: 200
bytes: 1040200, o sea éxito. Pero 1.040 bytes para un artículo con decenas de frases no cuadra. Al imprimir el cuerpo apareció una página de «this domain may be for sale»: el dominio había muerto.
Probando fuentes alternativas salieron otros dos:
| Respuesta | Parecía | Era |
|---|---|---|
200 · 1.040 bytes | éxito | dominio aparcado y en venta |
403 · 126 bytes | bloqueo | faltaba mandar User-Agent |
404 · 70.000 bytes | contenido | página de error con el sitio entero renderizado |
De aquí sale la primera regla del oficio:
Un 200 no significa que haya contenido. El scraper tiene que comprobar que encontró lo que buscaba, no que la petición no falló. Sin eso, una fuente muerta se manifiesta como cero resultados y ningún error — y buscas el fallo en tu selector durante media hora.
Lo del 403 tiene arreglo y no es hacer trampa. Wikimedia, entre otros, pide expresamente que te identifiques:
CABECERAS = {
"User-Agent": "mi-proyecto/0.1 (aprendizaje; tu@email.com)"
}
respuesta = httpx.get(URL, timeout=20, headers=CABECERAS)Paso 2 — Extraer los campos
BeautifulSoup parsea HTML: le das el texto de una página y te devuelve un árbol que se consulta con selectores CSS. Quien conozca cheerio lo tiene visto.
from bs4 import BeautifulSoup
sopa = BeautifulSoup(respuesta.text, "html.parser")
sopa.select("div.message") # como querySelectorAll
sopa.select_one("div.autor") # como querySelectorselect() devuelve una lista normal de Python, no un tipo especial de colección: se recorre, se filtra y se corta como cualquier otra lista, sin convertir nada antes. Quien esté acostumbrado a la NodeList del DOM del navegador se ahorra ese paso.
El primer intento sacó los mensajes, pero sucios:
autor: globerinoWORLD + OLIMPIC
texto: #1 • 22/Jul/2007, 18:36 Me parece que podíamos hacer...El nick venía pegado al rango del foro, y el número y la fecha del mensaje estaban dentro del cuerpo. No es un bug del código: es cómo está montado el HTML. Mirando la estructura real aparecieron elementos mejores a los que apuntar:
| Dato | Selector correcto |
|---|---|
| Nick limpio | span[itemprop="name"] |
| Fecha ISO | atributo datetime de <time> |
| Cuerpo limpio | div.message_msg |
El texto de <time> ponía 22/Jul/2007, 18:36. Su atributo datetime traía 2007-07-22T18:36:36+02:00. El segundo es ISO: ordenable, sin ambigüedad de idioma y sin parsear meses en castellano. Cuando un dato existe como atributo, cógelo de ahí.
def texto_de(elemento, separador=" "):
"""Texto plano de un elemento, o cadena vacia si no existe.
Sin esto haria falta un 'if elemento is not None' antes de cada campo.
"""
if elemento is None:
return ""
return elemento.get_text(separador, strip=True)
def extraer_mensaje(mensaje):
"""Convierte un <div class="message"> en un diccionario de campos."""
tiempo = mensaje.select_one("time")
return {
"numero": texto_de(mensaje.select_one("div.viewTopicHeader a")),
"autor": texto_de(mensaje.select_one('span[itemprop="name"]')),
"fecha": tiempo.get("datetime", "") if tiempo else "",
"texto": texto_de(mensaje.select_one("div.message_msg"), "\n"),
}| Código | Qué es |
|---|---|
from bs4 import BeautifulSoup | Importa una cosa concreta del módulo. El import { X } from de JS |
elemento["datetime"] | Los atributos se leen como un diccionario. En JS, getAttribute() |
elemento.get("datetime", "") | Igual, pero devuelve "" si no existe en vez de reventar |
f"hay {len(x)}" | f-string: interpola el valor de una expresión dentro de la cadena. Las template literals de JS, con f delante en vez de backticks |
Ese .get() no es cosmético: si un mensaje antiguo no tiene <time>, con [...] el scraper muere a media página y pierdes todo lo anterior.
Y el "\n" como separador del cuerpo tampoco: en este foro las frases citadas van en líneas propias. Con un espacio se habrían fundido en un párrafo y habríamos perdido la señal más útil de la fuente.
#1 globerino 2007-07-22T18:36:36+02:00
Me parece que podíamos hacer un recopilatorio con las frases
más típicas del segoviano, a botepronto me vienen a la memoria:
"déficit de oxígeno"
"fuerza inusitada"
"campos magnéticos"Paso 3 — Todas las páginas, y el bug que enseña más que el código
Nadie te dice cuántas páginas tiene un hilo. El paginador puede mentir, y leerlo es frágil. El patrón habitual es pedir páginas hasta que una venga vacía.
for pagina in range(1, MAX_PAGINAS + 1):
...
mensajes = sopa.select("div.message")
if not mensajes: # lista vacia = falso, no hace falta len() == 0
breakQue una lista vacía sea «falsa» se llama truthiness, y en Python alcanza a [], {}, "", 0 y None. if not lista es lo idiomático; if len(lista) == 0 se considera ruidoso.
Con eso, la ejecución dio esto:
pagina 6: 20 mensajes (total 120)
pagina 7: 17 mensajes (total 137) ← el hilo acaba aqui
pagina 8: 20 mensajes (total 157) ← ¿?
pagina 9: 20 mensajes (total 177)
...
pagina 50: 20 mensajes (total 997)
TOTAL: 997 mensajesEl hilo tiene 137 mensajes y recogimos 997. Comparando páginas apareció el motivo:
pag 1: primero=#1 url_final=1
pag 7: primero=#121 url_final=7
pag 8: primero=#1 url_final=None ← redirige a la pagina 1
pag 50: primero=#1 url_final=NoneAl pedir una página que no existe, el foro redirige en silencio al principio del hilo. Nunca dice «no hay más»: devuelve contenido perfectamente válido, del sitio equivocado. Es el mismo patrón que el 200 del dominio muerto — el fallo disfrazado de éxito, otra vez.
Lo único que impidió un bucle infinito fue un MAX_PAGINAS = 50 puesto por costumbre. Cuando escribas un bucle contra un servidor que no controlas, pon un tope aunque estés seguro de la condición de salida. La condición puede no cumplirse nunca.
La solución: fiarse del contenido, no de la forma
En vez de preguntar «¿viene vacía?», se pregunta «¿ya había visto estos mensajes?». Si el mensaje #1 reaparece, hemos dado la vuelta.
vistos = set()
for pagina in range(1, MAX_PAGINAS + 1):
...
nuevos = []
for m in mensajes:
datos = extraer_mensaje(m)
if datos["numero"] in vistos:
continue # ya lo teniamos: hemos dado la vuelta
vistos.add(datos["numero"])
nuevos.append(datos)
if not nuevos:
print(f"pagina {pagina}: todo repetido, fin del hilo")
break
todos.extend(nuevos)
time.sleep(PAUSA)Un set y no una lista porque comprobar in es instantáneo sin importar cuántos elementos lleve; con una lista sería recorrerla entera en cada mensaje.
Esta comprobación es más robusta que detectar el redirect: funciona aunque el servidor no redirija, aunque repita la última página en vez de la primera, o aunque devuelva las páginas desordenadas.
pagina 7: 17 nuevos (total 137)
pagina 8: todo repetido, fin del hilo
TOTAL: 137 mensajestime.sleep(1) entre peticiones. Estás pidiendo páginas a un foro pequeño que alguien paga de su bolsillo. Sin pausa le metes siete peticiones en medio segundo; con pausa tardas siete segundos más y no molestas a nadie.
Paso 4 — Guardar en JSONL
JSONL (JSON Lines) es un objeto JSON completo por línea, sin corchetes envolviendo el conjunto.
{"numero": "#1", "autor": "globerino", "texto": "..."}
{"numero": "#2", "autor": "davalcues", "texto": "..."}| Formato | Problema para datos de scraping |
|---|---|
| CSV | Los mensajes llevan saltos de línea, comas y comillas. Escaparlo bien es un infierno |
| JSON (array) | Hay que tenerlo todo en memoria y escribirlo de golpe. Si el scraper muere a media descarga, el fichero queda corrupto y pierdes todo |
| JSONL | Se escribe línea a línea, se puede añadir al final, y una línea corrupta no invalida el resto |
Ese último punto es el que importa cuando llevas cuarenta minutos scrapeando.
def guardar(mensajes, ruta):
"""Escribe un objeto JSON por linea (formato JSONL)."""
ruta.parent.mkdir(parents=True, exist_ok=True)
hoy = datetime.date.today().isoformat()
# 'with' cierra el fichero pase lo que pase, incluso si esto revienta
with open(ruta, "w", encoding="utf-8") as fichero:
for m in mensajes:
m["fuente"] = "apmforo.mforos.com"
m["extraido"] = hoy
# ensure_ascii=False para que los acentos se escriban tal cual
fichero.write(json.dumps(m, ensure_ascii=False) + "\n")
if __name__ == "__main__":
mensajes = descargar_hilo()
if not mensajes:
raise SystemExit("no se extrajo ningun mensaje: revisa los selectores")
guardar(mensajes, SALIDA)| Código | Qué hace |
|---|---|
with open(...) as f: | Context manager: garantiza que el fichero se cierra pase lo que pase. Un try/finally en una línea. En Python se abren ficheros así siempre |
ensure_ascii=False | Sin esto Python escribe déficit en vez de déficit. Funciona igual, pero el fichero se vuelve ilegible — y estos ficheros los vas a mirar mucho |
raise SystemExit(...) | Sale con error y mensaje. Aplica la regla de arriba: cero resultados es un fallo, no un fichero vacío |
if __name__ == "__main__": | Solo se ejecuta al lanzar el fichero directamente, no al importarlo. Permite reutilizar las funciones desde otro script |
Resultado
137 mensajes, 9.264 palabras -> data/raw/foro-apmforo.jsonlEl cuerpo queda crudo a propósito: los mensajes son comentarios de foro con frases citadas dentro, y separarlas es trabajo de la etapa de limpieza. Capturar y limpiar son fases distintas, y mezclarlas significa re-scrapear cada vez que cambias de idea sobre qué es una frase.
Lo que se lleva uno de esta etapa
- Empieza por una página, no por el bucle. Si arrancas con paginación y guardado, cuando falle no sabrás qué falla.
- Los fallos se disfrazan de éxito. Dominio muerto con
200, foro devolviendo la página 1 cuando le pides la 8. Ninguno da error. Verifica el contenido, no el código de estado. - Tope en todo bucle contra un servidor ajeno.
- La condición de parada correcta suele ser «¿ya vi esto?», no «¿viene vacío?».