Mojibake é o texto corrompido que você obtém quando bytes são decodificados com o conjunto de caracteres errado: “Café” em vez de “Café”, ou um título em japonês transformado em uma fileira de letras latinas soltas. Em um navegador isso é raro, porque os navegadores seguem um procedimento preciso e padronizado para determinar a codificação de uma página. Em pipelines de scraping isso é comum, porque a biblioteca HTTP geralmente faz algo mais simples.
Medimos com que frequência isso importa em sites reais, e a resposta foi: com mais frequência do que a maioria das equipes supõe. Este guia cobre o que encontramos, por que isso acontece, e código de decodificação que segue a mesma ordem que os navegadores seguem.
Principais conclusões
- Em 1º de outubro de 2026 buscamos as páginas iniciais dos 50 principais domínios em seis domínios de país (Japão, Coreia, China, Rússia, Taiwan e Alemanha). 193 retornaram uma página.
- Nove das 193 não eram UTF-8: Shift_JIS no Japão, EUC-KR na Coreia, windows-1251 na Rússia e ISO-8859-1 na Alemanha.
- O maior problema não foram as codificações legadas, mas sim um padrão de biblioteca. O requests do Python decodificou 37 das 193 páginas, cerca de 19%, como ISO-8859-1, corrompendo todo caractere não ASCII, porque o servidor não nomeou um charset em seu cabeçalho.
- Rótulos legados não significam o que seus nomes dizem. Os navegadores decodificam “ISO-8859-1” como windows-1252 e “Shift_JIS” como a variante Windows, e decodificadores estritos erram alguns caracteres.
- Decodifique na ordem do navegador: marca de ordem de bytes, cabeçalho HTTP, tag meta e, então, validade e detecção.
O que medimos
Pegamos os 50 principais domínios terminados em .jp, .kr, .cn, .ru, .tw e .de da lista Tranco de domínios populares, buscamos cada página inicial uma vez e mantivemos as 193 que retornaram uma página HTML com status 200. Para cada página, registramos como ela declarava sua codificação, qual era de fato, e o que o .text da biblioteca requests produziu.
| Domínio de país | Páginas | Não UTF-8 | Corrompido pelo .text do requests |
|---|---|---|---|
| Japão (.jp) | 36 | 4 (Shift_JIS) | 8 |
| Coreia (.kr) | 31 | 2 (EUC-KR) | 4 |
| China (.cn) | 33 | 0 | 10 |
| Rússia (.ru) | 25 | 2 (windows-1251) | 4 |
| Taiwan (.tw) | 29 | 0 | 6 |
| Alemanha (.de) | 39 | 1 (ISO-8859-1) | 5 |
| Total | 193 | 9 | 37 |
O UTF-8 claramente venceu: 184 das 193 páginas o usavam. Mas a coluna à direita mostra que vencer a guerra das codificações não encerrou o problema. A maioria das páginas corrompidas era UTF-8. Elas declaravam isso em uma tag <meta> em vez do cabeçalho HTTP, e a biblioteca nunca olhou lá.
Por que a biblioteca errou
Quando uma resposta diz Content-Type: text/html sem charset, o requests define a codificação como ISO-8859-1, seguindo o antigo padrão do HTTP/1.1 para tipos de texto. Ele não lê a tag <meta charset> da página. Todo byte acima de 127 é então decodificado como um caractere Latin-1, então texto UTF-8 em qualquer idioma sai corrompido:
| Original | Bytes decodificados como Latin-1 |
|---|---|
| Café | Café |
| 価格 (japonês, “preço”) | ä¾¡æ ¼ |
| JRA日本中央競馬会 (uma página Shift_JIS) | JRA seguido de caracteres de controle e letras latinas soltas |
Nada gera um erro. O texto ainda é uma string válida, ainda é interpretado como HTML, e os seletores ainda funcionam. O dano aparece depois, como nomes de produtos impossíveis de pesquisar, deduplicação quebrada e lixo nos dados de treinamento de um modelo, o que torna isso um caso clássico de uma requisição bem-sucedida retornando o conteúdo errado.
Rótulos legados não significam o que dizem
A segunda armadilha é mais sutil. Quando uma página de fato declara uma codificação legada, os navegadores não usam o padrão estrito daquele nome. O WHATWG Encoding Standard, que os navegadores implementam, mapeia vários rótulos para superconjuntos:
| Rótulo declarado | O que os navegadores de fato decodificam |
|---|---|
| iso-8859-1, latin1, ascii | windows-1252 |
| gb2312, gbk | gb18030 |
| shift_jis | Shift_JIS com as extensões Windows (code page 932) |
| euc-kr | EUC-KR com as extensões Windows (code page 949) |
| big5 | Big5 com os caracteres suplementares de Hong Kong |
Decodificar com o codec estrito de mesmo nome produz erros ou, pior, caracteres diferentes. Em um site japonês de entretenimento que declarava Shift_JIS, o codec estrito shift_jis do Python transformou todo til de largura total ”~” (U+FF5E) em um til ondulado ”〜” (U+301C), tantas vezes quanto a página usava o caractere. O índice do Encoding Standard mapeia esse par de bytes para o til de largura total, que é o que os visitantes veem. Os dois parecem quase idênticos e são comparados como strings diferentes, então uma faixa de preço como “1,000~2,000” para silenciosamente de corresponder.
Os outros mapeamentos falham de forma mais ruidosa. Uma página rotulada gb2312 que usa um caractere fora desse padrão, ou uma página euc-kr com uma das sílabas Hangul estendidas, gera um erro de decodificação sob os codecs estritos do Python. E uma página rotulada ISO-8859-1 que usa o símbolo do euro recebe um caractere de controle invisível em vez disso, porque o euro existe apenas no windows-1252.
Decodifique do jeito que os navegadores fazem
O HTML Standard define a ordem: uma marca de ordem de bytes vence, depois a codificação nomeada pela camada de transporte (o cabeçalho HTTP), depois uma declaração <meta> encontrada ao escanear o início do documento, que os autores são obrigados a colocar dentro dos primeiros 1024 bytes. Seguindo a mesma ordem, com os mapeamentos de rótulos do navegador e um mecanismo de detecção como alternativa, chegamos a isto:
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)"
Passe para ela os bytes brutos (response.content no requests) e o cabeçalho Content-Type, nunca response.text. Ela retorna o texto e uma anotação de como a codificação foi escolhida, o que vale a pena armazenar junto a cada página para que uma decisão ruim possa ser rastreada depois.
Executado sobre as 193 páginas, decodificou 192 sem um único caractere de substituição. A página restante declarava UTF-8 e continha duas sequências de bytes inválidas, que um navegador também exibe como caracteres de substituição. Também produziu o texto correto para as 37 páginas que o padrão da biblioteca havia corrompido, e para as 9 páginas em codificações legadas. Testado em casos extremos construídos, ele decodifica uma página rotulada gb2312 contendo um caractere exclusivo de GBK, uma página euc-kr com uma sílaba Hangul estendida, uma página ISO-8859-1 com um símbolo de euro e uma página Shift_JIS não declarada, todas sem erros.
O que mais apareceu
- Declarações no lugar errado. Em nossa primeira passagem, 17 páginas colocaram sua
<meta charset>após os primeiros 1024 bytes, contrariando a especificação HTML. Treze também nomeavam o charset no cabeçalho, e as outras quatro eram UTF-8 válido, então nada quebrou, mas um decodificador que dependesse apenas da tag meta teria perdido isso. - Declarações conflitantes. Um site taiwanês enviava UTF-8 no cabeçalho e Big5 em sua tag meta. O cabeçalho vence, assim como nos navegadores, e nesse caso estava correto.
- Marcas de ordem de bytes. Quatro páginas começavam com uma marca de ordem de bytes UTF-8. Algumas sem charset no cabeçalho ainda eram decodificadas como Latin-1 pela biblioteca, e mesmo quando o cabeçalho estava correto, a biblioteca deixava a marca invisível no início do texto.
Regras práticas
- Decodifique os bytes você mesmo. Mantenha a resposta bruta, e se você arquivar respostas brutas, poderá redecodificá-las depois se suas regras mudarem.
- Nunca assuma Latin-1. Trate um charset ausente no cabeçalho como “procure mais adiante”, não como ISO-8859-1.
- Armazene tudo como UTF-8. Decodifique uma vez na borda do pipeline, registre qual codificação foi usada, e mantenha UTF-8 a partir daí.
- Observe caracteres de substituição. Um aumento súbito de U+FFFD em um campo é um alarme barato e confiável, e pertence junto às verificações descritas em desvio de esquema em dados raspados.
- Normalize depois de decodificar. Caracteres corretos ainda podem ser escritos de mais de uma forma, como dígitos de largura total ou tis diferentes. Normalize antes de comparar ou deduplicar, como abordado em normalizando preços, números e datas entre localidades.
Conclusão
Quase toda a web agora é UTF-8, e os pipelines ainda a corrompem, porque a falha mais comum não é uma codificação exótica, mas sim um padrão de biblioteca que ignora a própria declaração da página. Em nossa amostra, esse padrão corrompeu cerca de uma em cada cinco páginas, e nenhuma delas gerou um erro.
Decodifique a partir dos bytes, na ordem que os navegadores usam, com os rótulos mapeados do jeito que os navegadores os mapeiam, e registre qual regra decidiu. Isso transforma um problema invisível de qualidade de dados em um problema resolvido.
Fontes e referências
- WHATWG, Encoding Standard, incluindo a tabela de rótulos e o índice do Shift_JIS.
- WHATWG, HTML Standard: parsing HTML documents, sobre a determinação da codificação de caracteres.
- Tranco, lista Q2K34, agregando rankings de domínios de 1 a 30 de setembro de 2026.
- charset_normalizer, versão 3.5.2, e requests 2.32.5, usados no teste.
- Páginas iniciais buscadas pela Shifter em 1º de outubro de 2026, usando o código acima.