Mojibake es el texto ilegible que se obtiene cuando los bytes se decodifican con el conjunto de caracteres equivocado: “Café” en lugar de “Café”, o un título en japonés convertido en una hilera de letras latinas sueltas. En un navegador esto es raro, porque los navegadores siguen un procedimiento preciso y estandarizado para determinar la codificación de una página. En los pipelines de scraping es habitual, porque la librería HTTP suele hacer algo más simple.
Medimos con qué frecuencia esto importa en sitios reales, y la respuesta fue: con más frecuencia de la que la mayoría de los equipos asume. Esta guía cubre lo que encontramos, por qué ocurre y código de decodificación que sigue el mismo orden que usan los navegadores.
Puntos clave
- El 1 de octubre de 2026 descargamos las páginas de inicio de los 50 dominios principales en seis dominios de país (Japón, Corea, China, Rusia, Taiwán y Alemania). 193 devolvieron una página.
- Nueve de las 193 no eran UTF-8: Shift_JIS en Japón, EUC-KR en Corea, windows-1251 en Rusia e ISO-8859-1 en Alemania.
- El problema mayor no fueron las codificaciones heredadas, sino un valor por defecto de una librería. La librería requests de Python decodificó 37 de las 193 páginas, alrededor del 19%, como ISO-8859-1, corrompiendo cada carácter no ASCII, porque el servidor no indicaba un charset en su cabecera.
- Las etiquetas heredadas no significan lo que sus nombres dicen. Los navegadores decodifican “ISO-8859-1” como windows-1252 y “Shift_JIS” como la variante de Windows, y los decodificadores estrictos interpretan mal algunos caracteres.
- Decodifica en el orden del navegador: marca de orden de bytes, cabecera HTTP, etiqueta meta, y luego validez y detección.
Qué medimos
Tomamos los 50 dominios principales terminados en .jp, .kr, .cn, .ru, .tw y .de de la lista Tranco de dominios populares, descargamos cada página de inicio una vez, y conservamos las 193 que devolvieron una página HTML con estado 200. Para cada página registramos cómo declaraba su codificación, cuál era en realidad, y qué producía el atributo .text de la librería requests.
| Dominio de país | Páginas | No UTF-8 | Corrompidas por .text de requests |
|---|---|---|---|
| Japón (.jp) | 36 | 4 (Shift_JIS) | 8 |
| Corea (.kr) | 31 | 2 (EUC-KR) | 4 |
| China (.cn) | 33 | 0 | 10 |
| Rusia (.ru) | 25 | 2 (windows-1251) | 4 |
| Taiwán (.tw) | 29 | 0 | 6 |
| Alemania (.de) | 39 | 1 (ISO-8859-1) | 5 |
| Total | 193 | 9 | 37 |
UTF-8 ha ganado claramente: 184 de 193 páginas lo usaban. Pero la columna de la derecha muestra que ganar la guerra de las codificaciones no ha acabado con el problema. La mayoría de las páginas corrompidas eran UTF-8. Lo declaraban en una etiqueta <meta> en lugar de en la cabecera HTTP, y la librería nunca miró ahí.
Por qué la librería se equivocó
Cuando una respuesta dice Content-Type: text/html sin charset, requests establece la codificación como ISO-8859-1, siguiendo el antiguo valor por defecto de HTTP/1.1 para los tipos de texto. No lee la etiqueta <meta charset> de la página. Cada byte por encima de 127 se decodifica entonces como un carácter Latin-1, así que el texto UTF-8 en cualquier idioma sale corrompido:
| Original | Bytes decodificados como Latin-1 |
|---|---|
| Café | Café |
| 価格 (japonés, “precio”) | ä¾¡æ ¼ |
| JRA日本中央競馬会 (una página Shift_JIS) | JRA seguido de caracteres de control y letras latinas sueltas |
No se produce ningún error. El texto sigue siendo una cadena válida, sigue analizándose como HTML, y los selectores siguen coincidiendo. El daño aparece después, como nombres de producto imposibles de buscar, deduplicación rota y basura en los datos de entrenamiento de un modelo, lo que lo convierte en un caso clásico de una solicitud exitosa que devuelve el contenido equivocado.
Las etiquetas heredadas no significan lo que dicen
La segunda trampa es más sutil. Cuando una página sí declara una codificación heredada, los navegadores no usan el estándar estricto de ese nombre. El WHATWG Encoding Standard, que implementan los navegadores, asigna varias etiquetas a superconjuntos:
| Etiqueta declarada | Lo que los navegadores realmente decodifican |
|---|---|
| iso-8859-1, latin1, ascii | windows-1252 |
| gb2312, gbk | gb18030 |
| shift_jis | Shift_JIS con las extensiones de Windows (página de códigos 932) |
| euc-kr | EUC-KR con las extensiones de Windows (página de códigos 949) |
| big5 | Big5 con los caracteres suplementarios de Hong Kong |
Decodificar con el códec estricto del mismo nombre produce errores o, peor aún, caracteres distintos. En un sitio japonés de entretenimiento que declaraba Shift_JIS, el códec estricto shift_jis de Python convirtió cada tilde de ancho completo ”~” (U+FF5E) en un guion ondulado ”〜” (U+301C), tantas veces como la página usaba ese carácter. El índice del Encoding Standard asigna ese par de bytes a la tilde de ancho completo, que es lo que ven los visitantes. Los dos se parecen casi idénticos y se comparan como cadenas distintas, así que un rango de precios como “1,000~2,000” deja de coincidir en silencio.
Las otras asignaciones fallan más ruidosamente. Una página etiquetada como gb2312 que usa un carácter fuera de ese estándar, o una página euc-kr con una de las sílabas de Hangul extendidas, genera un error de decodificación con los códecs estrictos de Python. Y una página etiquetada como ISO-8859-1 que usa el símbolo del euro obtiene un carácter de control invisible en su lugar, porque el euro solo existe en windows-1252.
Decodifica como lo hacen los navegadores
El HTML Standard define el orden: una marca de orden de bytes gana, luego la codificación nombrada por la capa de transporte (la cabecera HTTP), luego una declaración <meta> encontrada escaneando el inicio del documento, que los autores están obligados a colocar dentro de los primeros 1024 bytes. Siguiendo el mismo orden, con las asignaciones de etiquetas del navegador y una detección de respaldo, se obtiene esto:
import codecs
import re
from charset_normalizer import from_bytes
# Legacy labels mean what browsers decode them as (WHATWG Encoding Standard), not their strict namesakes.
BROWSER_DECODER = {
"iso-8859-1": "cp1252", "latin1": "cp1252", "ascii": "cp1252", "us-ascii": "cp1252",
"gb2312": "gb18030", "gbk": "gb18030", "x-gbk": "gb18030",
"shift_jis": "cp932", "sjis": "cp932", "x-sjis": "cp932", "windows-31j": "cp932",
"euc-kr": "cp949", "ks_c_5601-1987": "cp949",
"big5": "big5hkscs",
}
HEADER_CHARSET = re.compile(r"charset\s*=\s*[\"']?([^;\"'\s]+)", re.I)
META_CHARSET = re.compile(rb"""<meta[^>]+charset\s*=\s*["']?\s*([A-Za-z0-9_.:-]+)""", re.I)
def python_codec(label):
"""Map a declared charset label to a Python codec name, or None if it is unknown."""
label = label.strip().lower()
label = BROWSER_DECODER.get(label, label)
try:
return codecs.lookup(label).name
except LookupError:
return None
def decode_html(body, content_type=""):
"""Decode HTML bytes in the browser's order: BOM, HTTP header, <meta>, then UTF-8, then detection."""
if body.startswith(codecs.BOM_UTF8):
return body[3:].decode("utf-8", "replace"), "utf-8 (BOM)"
header = HEADER_CHARSET.search(content_type or "")
meta = META_CHARSET.search(body[:1024]) # the HTML spec requires the declaration there
labels = (("header", header and header.group(1)), ("meta", meta and meta.group(1).decode("ascii", "replace")))
for source, label in labels:
codec = label and python_codec(label)
if codec:
return body.decode(codec, "replace"), f"{codec} ({source})"
try:
return body.decode("utf-8"), "utf-8 (valid)"
except UnicodeDecodeError:
guess = from_bytes(body).best()
codec = guess.encoding if guess else "cp1252"
return body.decode(codec, "replace"), f"{codec} (detected)"
Pásale los bytes en bruto (response.content en requests) y la cabecera Content-Type, nunca response.text. Devuelve el texto y una nota de cómo se eligió la codificación, que vale la pena guardar junto a cada página para poder rastrear después una mala decisión.
Al ejecutarlo sobre las 193 páginas, decodificó 192 sin un solo carácter de reemplazo. La página restante declaraba UTF-8 y contenía dos secuencias de bytes inválidas, que un navegador también muestra como caracteres de reemplazo. También produjo el texto correcto para las 37 páginas que el valor por defecto de la librería había corrompido, y para las 9 páginas en codificaciones heredadas. Probado con casos límite construidos, decodifica sin errores una página etiquetada como gb2312 que contiene un carácter exclusivo de GBK, una página euc-kr con una sílaba de Hangul extendida, una página ISO-8859-1 con un símbolo de euro y una página Shift_JIS sin declarar.
Qué más apareció
- Declaraciones en el lugar equivocado. En nuestra primera pasada, 17 páginas colocaban su
<meta charset>después de los primeros 1024 bytes, en contra de la especificación HTML. Trece también nombraban el charset en la cabecera, y las otras cuatro eran UTF-8 válido, así que nada se rompió, pero un decodificador que dependiera solo de la etiqueta meta lo habría pasado por alto. - Declaraciones en conflicto. Un sitio taiwanés enviaba UTF-8 en la cabecera y Big5 en su etiqueta meta. La cabecera gana, como ocurre en los navegadores, y en este caso tenía razón.
- Marcas de orden de bytes. Cuatro páginas empezaban con una marca de orden de bytes UTF-8. Algunas sin charset en la cabecera fueron igualmente decodificadas como Latin-1 por la librería, e incluso donde la cabecera era correcta, la librería dejó la marca invisible al inicio del texto.
Reglas prácticas
- Decodifica los bytes tú mismo. Conserva la respuesta en bruto, y si archivas las respuestas en bruto, puedes volver a decodificarlas más tarde si cambian tus reglas.
- Nunca asumas Latin-1. Trata un charset ausente en la cabecera como “sigue buscando”, no como ISO-8859-1.
- Almacena todo como UTF-8. Decodifica una vez en el borde del pipeline, registra qué codificación se usó, y mantén UTF-8 a partir de ahí.
- Vigila los caracteres de reemplazo. Un aumento repentino de U+FFFD en un campo es una alarma barata y fiable, y encaja junto a las comprobaciones descritas en desviación de esquema en datos extraídos.
- Normaliza después de decodificar. Los caracteres correctos aún se pueden escribir de más de una forma, como dígitos de ancho completo o tildes distintas. Normaliza antes de comparar o deduplicar, tal como se trata en normalizar precios, números y fechas entre configuraciones regionales.
La conclusión
Casi toda la web es UTF-8 ahora, y los pipelines aún lo corrompen, porque el fallo más común no es una codificación exótica sino un valor por defecto de una librería que ignora la declaración propia de la página. En nuestra muestra, ese valor por defecto corrompió alrededor de una página de cada cinco, y ninguna de ellas generó un error.
Decodifica a partir de los bytes, en el orden que usan los navegadores, con las etiquetas asignadas tal como las asignan los navegadores, y registra qué regla decidió. Eso convierte un problema invisible de calidad de datos en uno resuelto.
Fuentes y referencias
- WHATWG, Encoding Standard, incluyendo la tabla de etiquetas y el índice de Shift_JIS.
- WHATWG, HTML Standard: parsing HTML documents, sobre la determinación de la codificación de caracteres.
- Tranco, list Q2K34, que agrega clasificaciones de dominios del 1 al 30 de septiembre de 2026.
- charset_normalizer, versión 3.5.2, y requests 2.32.5, usados en la prueba.
- Páginas de inicio descargadas por Shifter el 1 de octubre de 2026, usando el código anterior.