← Ruta Perico
Etapa 04

Tu primer scraper

3ª categoría Publicada ~1 hora
04

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:

explorar.py
import httpx

URL = "https://ejemplo.com/hilo/?pag=1"

respuesta = httpx.get(URL, timeout=20)

print(respuesta.status_code)
print(len(respuesta.text))
CódigoQué haceSi vienes de JavaScript
import httpxCarga la librería HTTPEl fetch/axios de Python
URL = "..."ConstantePython no tiene const. MAYÚSCULAS es una convención entre humanos, el intérprete no la impone
httpx.get(...)Petición GETBloquea. No devuelve promesa, no hay await
.status_code200, 404…En JS es .status. Aquí snake_case
.textEl cuerpo como stringEn 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:

salida
status: 200
bytes:  1040

200, 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:

RespuestaParecíaEra
200 · 1.040 byteséxitodominio aparcado y en venta
403 · 126 bytesbloqueofaltaba mandar User-Agent
404 · 70.000 bytescontenidopágina de error con el sitio entero renderizado

De aquí sale la primera regla del oficio:

Regla

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:

python
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.

python
from bs4 import BeautifulSoup

sopa = BeautifulSoup(respuesta.text, "html.parser")

sopa.select("div.message")      # como querySelectorAll
sopa.select_one("div.autor")    # como querySelector

select() 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:

salida
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:

DatoSelector correcto
Nick limpiospan[itemprop="name"]
Fecha ISOatributo datetime de <time>
Cuerpo limpiodiv.message_msg
Prefiere el atributo al texto visible

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í.

scrape_forum.py
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ódigoQué es
from bs4 import BeautifulSoupImporta 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.

salida
#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.

python
for pagina in range(1, MAX_PAGINAS + 1):
    ...
    mensajes = sopa.select("div.message")
    if not mensajes:      # lista vacia = falso, no hace falta len() == 0
        break

Que 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:

salida
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 mensajes

El hilo tiene 137 mensajes y recogimos 997. Comparando páginas apareció el motivo:

diagnóstico
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=None

Al 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.

Pon siempre un tope

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.

scrape_forum.py
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.

salida
pagina 7: 17 nuevos  (total 137)
pagina 8: todo repetido, fin del hilo

TOTAL: 137 mensajes
La pausa no es opcional

time.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.

data/raw/foro.jsonl
{"numero": "#1", "autor": "globerino", "texto": "..."}
{"numero": "#2", "autor": "davalcues", "texto": "..."}
FormatoProblema para datos de scraping
CSVLos 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
JSONLSe 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.

scrape_forum.py
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ódigoQué 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=FalseSin 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

terminal
137 mensajes, 9.264 palabras -> data/raw/foro-apmforo.jsonl

El 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

  1. Empieza por una página, no por el bucle. Si arrancas con paginación y guardado, cuando falle no sabrás qué falla.
  2. 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.
  3. Tope en todo bucle contra un servidor ajeno.
  4. La condición de parada correcta suele ser «¿ya vi esto?», no «¿viene vacío?».