Os 350 links que apontavam para o nada — o dia em que o hreflang virou ruído
Descobertas·

Os 350 links que apontavam para o nada — o dia em que o hreflang virou ruído

11 min de leitura← Voltar para timeline

Primeiro: o que é hreflang

Imagine um livro que existe em dois idiomas. No começo de cada capítulo, uma nota diz: “este mesmo capítulo, em inglês, está na página X”.

hreflang é essa nota dentro do HTML da página. Uma linha no cabeçalho:

<link rel="alternate" hreflang="pt" href="/post/exemplo/" />
<link rel="alternate" hreflang="en" href="/en/post/example/" />
<link rel="alternate" hreflang="x-default" href="/post/exemplo/" />

Ela conta ao Google: “esta página também existe em outro idioma, naquele outro endereço”. A tag não traduz nada e não redireciona ninguém. É pura declaração — uma dica para o mecanismo de busca escolher qual versão mostrar para quem. Quem busca em inglês recebe o link em inglês, quem busca em português recebe o em português. Sem a dica, o Google entrega a versão que ele achar melhor, e o visitante cai no idioma errado.

O x-default é a terceira opção: “se a pessoa não fala nem português nem inglês, mande para esta aqui” — no nosso caso, a versão portuguesa, que é a padrão.

A única regra que importa

Para a dica funcionar, o outro lado precisa confirmá-la.

Se a página em português declara “meu irmão em inglês mora em /en/post/example/”, a página em /en/post/example/ precisa declarar de volta “meu irmão em português mora em /post/exemplo/”. Isso se chama reciprocidade, e a documentação oficial do Google é direta: par sem retorno, as duas tags são ignoradas.

Repare no que isso significa na prática: hreflang quebrado não aparece no console do navegador, não deixa o build vermelho, não gera alerta de nenhum tipo. O único lugar onde o problema existe é do lado do Google — onde ninguém está olhando. Um site pode passar anos “declarando” versões traduzidas que o buscador joga fora silenciosamente.

Foi exatamente o nosso caso.

O que estava acontecendo aqui

O blog gera as tags de idioma automaticamente para toda página — cada uma das mais de mil páginas do site declara a versão portuguesa, a inglesa e a padrão. Na auditoria, somaram-se 350 links nesse estado: 169 indo de português para inglês, 166 no caminho contrário, 15 x-default.

Boa parte deles apontava para endereço que nunca existiu. Exemplo concreto: o post 2026-07-28-semana-da-qualidade-portfolio tem, em inglês, o nome traduzido — 2026-07-28-quality-week-portfolio. Mas o gerador de tags não sabia disso. Ele montava o endereço inglês por regra: pega o caminho português, cola /en na frente. Resultado: um link para /en/2026-07-28-semana-da-qualidade-portfolio/ — página que não existe em lugar nenhum. O Google lê, não acha o outro lado, descarta o par. Centenas de vezes.

A regra que gerava esses links foi escrita antes de o blog ser bilíngue de verdade. Na época havia duas ou três páginas fixas, e “cola /en na frente” resolvia. O blog cresceu, passou a ter um arquivo por idioma, e a premissa continuou lá, valendo como lei, sem ninguém ter decidido isso em voz alta. E a realidade tem três dobras que a regra não previa:

Nome de arquivo traduzido. Parte dos posts nasceu com slug idêntico nos dois idiomas; outra parte veio com o título traduzido no nome do arquivo inglês. A concatenação não tem como adivinhar em qual dos dois casos ela está.

Página que só existe em um idioma. O painel de revisão dos rascunhos ocultos é puramente português — não existe (nem precisa existir) versão em inglês. Emitir alternate dali é inventar uma página.

Alias de rota. As páginas fixas não espelham o nome: /arquivo mora em /en/archive, /sobre em /en/about. Colar o prefixo devolve /en/arquivo, que dá 404.

Como funciona o pareamento de verdade

Aqui está o ponto que quase todo mundo (eu incluso) imagina errado: os dois idiomas não se reconhecem pelo endereço.

Cada post tem, no cabeçalho do arquivo, uma data de publicação e um projeto. O sistema que casa os idiomas usa esse par — mesma data de publicação, mesmo projeto. Se o arquivo português e o inglês batem nos dois campos, eles são irmãos, não importa o nome que cada um tenha no disco.

A cura foi levar essa verdade para dentro do gerador de tags. Em vez de o layout (que só enxerga o caminho da página) adivinhar o endereço do irmão, quem sabe quem é o irmão é a página que carrega a lista de posts — ela consulta o pareamento por data + projeto e entrega o caminho certo, pronto, para o layout. O layout parou de derivar e passou a receber.

Dois detalhes apareceram pelo caminho.

O primeiro: o prop que transporta o irmão precisa de três valores, não dois. “A página tem irmão, o caminho é este” (um texto). “Esta página não tem irmão, não emita alternate” (nulo). E “ninguém me informou nada” (ausente — cai na regra antiga, que funciona, para as páginas fixas). O bug de antes morava justamente em tratar os dois últimos como a mesma coisa: sem separar “sem irmão” de “não informei”, o site continuava inventando link.

O segundo: ordem dos ramos. Os previews dos posts ocultos têm caminho próprio (/ocultos/preview/pt/...). Na primeira versão da correção, o caso geral rodava antes do caso de preview e montava um inglês a partir de um português que já era composto. Sobraram uns 37 links quebrados — todos no painel de revisão, justamente o lugar do site que nunca aparece no menu. Reordenar os casos fechou, e ficou a regra: um caso que não aparece na navegação continua aparecendo no build.

Dois achados que não eram hreflang

A mesma varredura esbarrou em mais duas coisas.

O manifesto do site (o arquivo que diz ao celular como instalar o blog como aplicativo) pedia dois ícones, e o iPhone pedia um terceiro. Os três caminhos davam 404 desde sempre. O motivo: o .gitignore tinha uma linha global *.png, colocada para nunca commitar print de teste sem querer. O efeito colateral é que era impossível commitar os ícones sem forçar — e ninguém nunca forçou. Os arquivos simplesmente nunca existiram no repositório. Corrigido com uma exceção para a pasta (!public/icons/*.png) e dois ícones desenhados de verdade (192×192 e 512×512).

E o vigia dos links, uma vez escrito, falhou no próprio teste: o teste de regressão navegava para uma rota que nunca existiu no projeto e tratava o 404 como defeito do site. O vigia acusava o inocente e o culpado era o vigia. Cortado para o smoke honesto, a cobertura exaustiva ficou com o script que caminha pelo build e confere, um a um, cada alternate declarado contra cada arquivo gerado.

As duas checagens que ficaram

Para o problema não voltar calado, duas checagens agora rodam no pipeline:

  1. Sincronia PT/EN — compara cada post português com os ingleses pelo par data + projeto. Post novo sem tradução reprova o build com aviso explícito, antes de qualquer tag ser emitida.
  2. Validador de alternates — roda depois do build, lê cada HTML gerado, extrai todo link rel=alternate e confirma que o destino existe. Qualquer caminho inventado vira exit 1 e o deploy para. Cento e quatorze linhas, zero complacência.

Detalhe técnico que quase passou: comparar caminho de arquivo com link do HTML exige decodificar os escapes de porcentagem (%C3%AD vira o “í” correspondente) e normalizar os acentos — o mesmo “í” pode ter dois formatos de bytes. Sem isso, o validador acusava de quebrado um link perfeitamente válido. E falso positivo ensina a desligar o guardião mais rápido que qualquer bug real.

Métricas

Item Antes Depois
Links de alternate apontando para 404 350 0
Alternate declarados no build ~3.5k 3.876
Ícones do manifesto existentes no pacote 0 de 2 2 de 2
Página de erro indexável sim não
Guardião rodando no CI não existia pós-build, bloqueante
Build de prova – 1.293 páginas, testes unitários 89 em 89

Aprendizados

A explicação vem antes da cura. Entender o que a tag faz do lado do Google — pedir confirmação de volta — foi o que mostrou por que o nosso caminho estava errado. Toda conversa com um sistema externo (buscador, e-mail, pagamento) começa melhor pelo “como isso funciona lá do outro lado” do que pelo “por que meu código está errado”.

Invariância silenciosa é a mais cara. “Todo português tem inglês no mesmo path” nunca foi decidido em voz alta. Premissa que ninguém escreveu não tem dono, e é ela que vira três linhas de concatenação num layout.

Declaração sem verificação é decoração. hreflang é um pedido, não uma ordem. O verificador de verdade fica do lado de fora, no Google. Enquanto ninguém conferisse o caminho de volta, o verde do build não significava nada.

Ausência é um valor. “Sem irmão” e “não informei” precisaram ser separados para o sistema parar de inventar. Metade dos bugs de integração que eu arrumo são duas intenções diferentes disputando um estado só.

O validador também precisa de teste. Escrevi o guardião e o teste que falhava por culpa dele no mesmo dia. Confiar na ferramenta sem ler a acusação dela é só terceirizar o bug.

O que vem a seguir

O que fica aberto é o outro lado da moeda: um post novo pode nascer com hreflang quebrado de um jeito que nem a derivação nem o nome divergente cobrem — tradução publicada com data diferente do original. O pareamento casa pelo par exato; se a data escorregar entre os dois arquivos, o irmão some sem ninguém avisar. A próxima checagem que faz sentido escrever não é “esse link existe”, e sim “esse post tem irmão”.

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$