Formatos y fuentes de datos 8 min de lectura

Parseo de HTML, enlaces y texto: problemas, codificaciones y ejemplos en varios lenguajes

Cómo extraer texto y enlaces del HTML: codificaciones, marcado roto, URL relativas y ejemplos de solución en Python, JavaScript, PHP, Go y Java.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 14 marzo 2025

Extraer datos de páginas web pasa, en esencia, por tres operaciones de parseo: analizar el marcado HTML, recopilar los enlaces y aislar el contenido textual. A primera vista la tarea parece sencilla, pero en la práctica casi siempre afloran obstáculos: marcado «sucio», contenido dinámico, bloqueos y — un clásico especialmente doloroso en los sitios en español — los problemas con las codificaciones, las tildes y las eñes.

En este artículo repasamos los tres escenarios básicos (HTML, enlaces, texto), los problemas típicos, y mostramos ejemplos de implementación en varios lenguajes.


1. Parseo de HTML

El parseo de HTML consiste en construir, a partir de la cadena de marcado, un árbol de elementos (DOM) por el que se puede navegar y del que se pueden seleccionar los nodos necesarios. Lo más habitual es trabajar con:

  • selectores CSS.product .price, div#content > p;
  • XPath//div[@class="product"]//span[@class="price"];
  • expresiones regulares — admisibles solo en casos muy simples; para HTML de verdad no conviene usarlas (HTML no es un lenguaje regular).

La idea clave: no parsee HTML con expresiones regulares. Use un parser especializado, capaz de reconstruir el marcado «roto» igual que lo hace el navegador.

2. Parseo de enlaces

La recopilación de enlaces es la base de cualquier rastreador (crawler). Normalmente consiste en:

  1. Seleccionar todas las etiquetas <a> con atributo href.
  2. Convertir los enlaces relativos en absolutos (/pagehttps://site.com/page).
  3. Filtrar: eliminar mailto:, tel:, javascript:, anclas #, duplicados.
  4. Normalizar las URL (mayúsculas y minúsculas del host, barra final, orden de los parámetros de la consulta).

El principal error de los novatos es tratar el href como si ya fuera una dirección absoluta lista para usar. En los sitios reales los enlaces pueden ser relativos, relativos al protocolo (//cdn.site.com/...) o depender de la etiqueta <base href>.

3. Extracción de texto

Extraer el texto significa obtener el contenido «humano» sin etiquetas. Aquí lo importante es:

  • eliminar <script>, <style> y los elementos ocultos;
  • tratar correctamente los espacios, los saltos de línea y los espacios de no separación (&nbsp;);
  • decodificar las entidades HTML (&amp;&, &ntilde;ñ, &laquo;«);
  • en lo posible, separar el contenido principal de la navegación, la publicidad y el pie de página (la tarea conocida como «content extraction»).

4. Con qué problemas puede encontrarse

HTML «sucio» e inválido. Etiquetas sin cerrar, anidamiento incorrecto, comillas ausentes. Un buen parser perdona esos errores; las expresiones regulares, no.

Contenido dinámico (JavaScript). Si los datos se cargan mediante AJAX/JS, no están en el HTML original. Hace falta o bien un navegador headless (Selenium, Playwright, Puppeteer), o bien atacar directamente la API o las peticiones XHR que realiza la página.

Maquetación cambiante. Los selectores se rompen con cada rediseño. Conviene elegir señales estables (id, atributos semánticos, microdatos) en lugar de cadenas de clases largas y frágiles.

Protección anti-bots. Captchas, comprobación del User-Agent, rate-limiting, bloqueo por IP, Cloudflare. Se requieren pausas entre peticiones, rotación de cabeceras y proxies, respeto del robots.txt y sentido común.

Codificaciones y caracteres del español. El dolor más frecuente de la web hispanohablante; le dedicamos un apartado propio más abajo.

Rendimiento. Con grandes volúmenes importan la asincronía, los pools de conexiones y el análisis en flujo (al estilo SAX) en lugar de cargar todo el DOM en memoria.

Aspectos legales y éticos. Las condiciones de uso del sitio, los datos personales, los derechos de autor, la carga que soporta el servidor ajeno.


5. Codificaciones, tildes y eñes

La mayoría de los problemas con caracteres «corruptos» (año en lugar de año, Espa�a en lugar de España) se reducen a una sola causa: los bytes se leyeron con una codificación distinta de aquella con la que fueron escritos.

En la web hispanohablante conviven tres codificaciones principales:

  • UTF-8 — el estándar moderno, el valor por defecto de la web actual;
  • Windows-1252 (CP1252) — codificación legacy, todavía viva en sitios antiguos;
  • ISO-8859-1 (Latin-1) — el estándar clásico de los noventa; en la práctica, los navegadores lo tratan como Windows-1252.

Cómo determinar la codificación correcta

El orden de prioridades, tal como lo aplica el navegador:

  1. La cabecera HTTP Content-Type: text/html; charset=windows-1252.
  2. La etiqueta meta en el HTML: <meta charset="utf-8"> o <meta http-equiv="Content-Type" content="text/html; charset=windows-1252">.
  3. El BOM (Byte Order Mark) al principio del archivo, en el caso de UTF.
  4. La autodetección por estadística de bytes (bibliotecas chardet, charset-normalizer).

Un matiz importante: no se puede determinar la codificación a partir de una cadena ya decodificada. La codificación se determina sobre los bytes crudos de la respuesta, y solo después se decodifica a cadena.

Errores típicos

  • Decodificar Windows-1252 como UTF-8 → error o «basura».
  • El charset declarado en la cabecera no coincide con el real: el servidor «miente».
  • Codificaciones mezcladas dentro de una misma página.
  • Doble decodificación o codificación (mojibake).
  • Olvidar indicar la codificación al guardar el resultado en un archivo o en la base de datos.

La receta universal: leemos los bytes → determinamos la codificación → decodificamos a Unicode → dentro del programa trabajamos solo en Unicode → a la salida codificamos en UTF-8.


6. Ejemplos de implementación en varios lenguajes

En todos los ejemplos la tarea es la misma: cargar la página, determinar correctamente la codificación, recopilar todos los enlaces y extraer el texto del título.

Python — requests + BeautifulSoup

La pareja más popular. requests intenta detectar la codificación por sí mismo, y BeautifulSoup digiere bien el HTML «sucio».

python
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin

url = "https://example.com"
resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"})

# Detección correcta de la codificación por el contenido (no solo por la cabecera)
resp.encoding = resp.apparent_encoding  # usa charset-normalizer/chardet

soup = BeautifulSoup(resp.text, "lxml")  # el parser lxml es rápido y robusto

# Extracción del texto
title = soup.title.get_text(strip=True)
print("Título:", title)

# Parseo de enlaces: de relativos a absolutos, con filtrado
links = set()
for a in soup.select("a[href]"):
    href = a["href"].strip()
    if href.startswith(("mailto:", "tel:", "javascript:", "#")):
        continue
    links.add(urljoin(url, href))

print(f"Enlaces encontrados: {len(links)}")

Si hay que determinar la codificación a mano a partir de los bytes:

python
import charset_normalizer

raw = resp.content                      # bytes crudos
best = charset_normalizer.from_bytes(raw).best()
html = str(best)                        # cadena Unicode ya correcta

JavaScript / Node.js — axios + cheerio

cheerio es una API al estilo jQuery para el servidor. La codificación conviene determinarla de forma explícita con iconv-lite, ya que Node espera UTF-8 por defecto.

javascript
const axios = require("axios");
const cheerio = require("cheerio");
const iconv = require("iconv-lite");
const chardet = require("chardet");

(async () => {
  const url = "https://example.com";
  // Recibimos exactamente los bytes
  const { data } = await axios.get(url, {
    responseType: "arraybuffer",
    headers: { "User-Agent": "Mozilla/5.0" },
  });

  // Detectamos la codificación y decodificamos
  const encoding = chardet.detect(data) || "utf-8";
  const html = iconv.decode(Buffer.from(data), encoding);

  const $ = cheerio.load(html);

  // Texto
  console.log("Título:", $("title").text().trim());

  // Enlaces
  const links = new Set();
  $("a[href]").each((_, el) => {
    const href = ($(el).attr("href") || "").trim();
    if (/^(mailto:|tel:|javascript:|#)/.test(href)) return;
    links.add(new URL(href, url).href); // de relativos a absolutos
  });
  console.log("Enlaces encontrados:", links.size);
})();

PHP — DOMDocument

El DOMDocument integrado sabe parsear HTML «de fábrica». Con los caracteres acentuados es clave pasarle la codificación correcta; el truco más fiable se muestra a continuación.

php
<?php
$url = "https://example.com";
$html = file_get_contents($url);

// Si la página está en windows-1252, la pasamos a UTF-8 antes de parsear
$encoding = mb_detect_encoding($html, ["UTF-8", "Windows-1252", "ISO-8859-1"], true);
if ($encoding && $encoding !== "UTF-8") {
    $html = mb_convert_encoding($html, "UTF-8", $encoding);
}

$dom = new DOMDocument();
libxml_use_internal_errors(true); // silenciamos los errores del HTML «sucio»
// Truco para que DOMDocument no rompa el UTF-8: declaramos la codificación explícitamente
$dom->loadHTML('<?xml encoding="UTF-8">' . $html);
libxml_clear_errors();

$xpath = new DOMXPath($dom);

// Texto
$titleNode = $xpath->query("//title")->item(0);
echo "Título: " . trim($titleNode->textContent) . PHP_EOL;

// Enlaces
$links = [];
foreach ($xpath->query("//a[@href]") as $a) {
    $href = trim($a->getAttribute("href"));
    if (preg_match('/^(mailto:|tel:|javascript:|#)/', $href)) continue;
    $links[$href] = true; // deduplicación por clave
}
echo "Enlaces encontrados: " . count($links) . PHP_EOL;

Go — net/http + goquery

goquery replica la API de jQuery. Para las codificaciones existe el paquete golang.org/x/net/html/charset, que lee por sí solo el charset de las cabeceras y de las etiquetas meta.

go
package main

import (
    "fmt"
    "net/http"
    "net/url"

    "github.com/PuerkitoBio/goquery"
    "golang.org/x/net/html/charset"
)

func main() {
    target := "https://example.com"
    resp, err := http.Get(target)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    // Convierte el flujo a UTF-8 automáticamente según el charset de la cabecera o del meta
    utf8Reader, err := charset.NewReader(resp.Body, resp.Header.Get("Content-Type"))
    if err != nil {
        panic(err)
    }

    doc, err := goquery.NewDocumentFromReader(utf8Reader)
    if err != nil {
        panic(err)
    }

    // Texto
    fmt.Println("Título:", doc.Find("title").First().Text())

    // Enlaces
    base, _ := url.Parse(target)
    links := map[string]bool{}
    doc.Find("a[href]").Each(func(_ int, s *goquery.Selection) {
        href, _ := s.Attr("href")
        if u, err := base.Parse(href); err == nil {
            links[u.String()] = true
        }
    })
    fmt.Println("Enlaces encontrados:", len(links))
}

Java — Jsoup

Jsoup es una de las bibliotecas más cómodas: descarga, parseo, selectores y detección de la codificación en un solo paquete.

java
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.util.HashSet;
import java.util.Set;

public class Parser {
    public static void main(String[] args) throws Exception {
        String url = "https://example.com";
        // Jsoup detecta la codificación por sí solo a partir de cabeceras y etiquetas meta
        Document doc = Jsoup.connect(url)
                .userAgent("Mozilla/5.0")
                .get();

        // Texto
        System.out.println("Título: " + doc.title());

        // Enlaces (absUrl los convierte en absolutos)
        Set<String> links = new HashSet<>();
        for (Element a : doc.select("a[href]")) {
            String href = a.absUrl("href");
            if (href.isBlank()) continue;
            links.add(href);
        }
        System.out.println("Enlaces encontrados: " + links.size());
    }
}

7. Recomendaciones breves

  • No parsee HTML con expresiones regulares: use un parser completo (lxml, cheerio, goquery, Jsoup, DOMDocument).
  • Determine la codificación a partir de los bytes, no de la cadena, y pase todo a UTF-8 cuanto antes.
  • Convierta siempre los enlaces relativos en absolutos (urljoin, new URL(base), absUrl).
  • Con los sitios dinámicos, prepárese para usar un navegador headless o para trabajar con la API interna.
  • Respete el robots.txt, haga pausas y no tumbe el servidor ajeno.