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:
- Seleccionar todas las etiquetas
<a>con atributohref. - Convertir los enlaces relativos en absolutos (
/page→https://site.com/page). - Filtrar: eliminar
mailto:,tel:,javascript:, anclas#, duplicados. - 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 (
); - decodificar las entidades HTML (
&→&,ñ→ñ,«→«); - 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:
- La cabecera HTTP
Content-Type: text/html; charset=windows-1252. - La etiqueta meta en el HTML:
<meta charset="utf-8">o<meta http-equiv="Content-Type" content="text/html; charset=windows-1252">. - El BOM (Byte Order Mark) al principio del archivo, en el caso de UTF.
- 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».
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:
import charset_normalizer
raw = resp.content # bytes crudos
best = charset_normalizer.from_bytes(raw).best()
html = str(best) # cadena Unicode ya correctaJavaScript / 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.
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
$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.
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.
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.