Feed RSS quebrado por um "&": como gerar um XML que todo leitor abre
Gere o feed RSS com escape de XML de verdade, data no formato certo e só posts publicados, e valide com xmllint antes de quebrar o leitor de quem assina.
O blog da oficina publicou "Freios & suspensão: o que trocar primeiro" numa terça. No navegador, o post estava lindo. No Feedly e no Thunderbird de quem assinava, o feed parou de atualizar naquele dia e ficou parado por três semanas, até um leitor mandar e-mail perguntando se o blog tinha acabado. Um único & sem escape tornou o XML inválido, e leitor de feed não perdoa XML inválido como o navegador perdoa HTML torto.
A resposta curta: gere o feed com uma função de escape de XML aplicada em todo texto que vem do banco (título, resumo, corpo, URL), use data no formato RFC 822 que a especificação do RSS pede, filtre só o que está publicado e valide o arquivo gerado com xmllint ou com o validador do W3C sempre que mexer no gerador.
Por que um caractere derruba o feed inteiro
HTML é tolerante: o navegador encontra um & solto, adivinha o que você quis dizer e segue. XML é o oposto. A especificação manda que o parser pare no primeiro erro de boa formação, e é isso que os leitores de feed fazem. Não existe "ignorar o item com problema": o documento inteiro é rejeitado, inclusive os 19 posts antigos que estavam certos.
Os culpados mais comuns são sempre os mesmos. O & e o < no texto, que no XML só podem aparecer como & e <. Entidades do HTML como e é, que não existem no XML (só cinco entidades são predefinidas: amp, lt, gt, quot e apos). E caracteres de controle invisíveis, como o que vem colado do Word ou de um PDF, que o XML 1.0 simplesmente proíbe.
O segundo grupo de problemas não quebra o XML, mas quebra o feed. Data num formato que o leitor não entende faz o post aparecer com data errada ou fora de ordem. Rascunho incluído no feed vai parar na caixa de quem assina e não pode ser desfeito, porque o leitor já guardou o item. E URL relativa no <link> não abre em lugar nenhum.
O que o RSS 2.0 exige de verdade
A especificação é curta e vale a leitura. O <channel> precisa de title, link e description. Cada <item> precisa de pelo menos title ou description, e na prática você quer os dois, mais link, guid e pubDate.
pubDatesegue o formato de data da RFC 822, comoTue, 13 Oct 2026 15:00:00 GMT. Em JavaScript,toUTCString()devolve exatamente esse formato.guidé o identificador que o leitor usa para saber se já mostrou aquele item. Se ele mudar, o post reaparece como novo. Use a URL definitiva, comisPermaLink="true", ou um identificador estável.- Corpo em HTML vai escapado dentro de
descriptionou dentro decontent:encoded, do módulo de conteúdo, que precisa ser declarado no elemento raiz.
Como gerar o feed sem concatenar no escuro
- Centralize o escape numa função. Todo valor dinâmico passa por ela, sem exceção. Escape "na mão" em alguns campos é exatamente como o
&do título escapou. - Remova caracteres de controle proibidos antes de escapar. Tabulação, quebra de linha e retorno de carro são permitidos; os outros abaixo do espaço, não.
- Escape o HTML do corpo em vez de usar CDATA. CDATA parece mais simples, mas quebra se o texto contiver a sequência
]]>, o que acontece em post que fala de XML. Com o escape, não existe sequência proibida. - Filtre por status e ordene por data. Só
published, os 20 mais recentes, do mais novo para o mais antigo. - Use URLs absolutas no
linke noguid, apontando para a mesma URL canônica da página. - Sirva com o tipo certo:
application/rss+xmlouapplication/xml, em UTF-8.
O gerador da oficina, em Node.js
const SITE = 'https://oficina.exemplo.com.br';
function escapeXml(valor) {
return String(valor)
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function item(post) {
const url = SITE + '/blog/' + post.slug;
return [
' <item>',
' <title>' + escapeXml(post.title) + '</title>',
' <link>' + escapeXml(url) + '</link>',
' <guid isPermaLink="true">' + escapeXml(url) + '</guid>',
' <pubDate>' + new Date(post.publishedAt).toUTCString() + '</pubDate>',
' <description>' + escapeXml(post.summary) + '</description>',
' <content:encoded>' + escapeXml(post.bodyHtml) + '</content:encoded>',
' </item>'
].join('\n');
}
function gerarFeed(posts) {
const publicados = posts
.filter((p) => p.status === 'published')
.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
.slice(0, 20);
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">',
' <channel>',
' <title>Blog da Oficina</title>',
' <link>' + SITE + '/blog</link>',
' <description>Guias práticos de manutenção de carro</description>',
' <language>pt-br</language>',
' <atom:link href="' + SITE + '/feed.xml" rel="self" type="application/rss+xml"/>',
publicados.map(item).join('\n'),
' </channel>',
'</rss>'
].join('\n');
}
module.exports = { gerarFeed, escapeXml };
Com o título da terça, o item sai como <title>Freios & suspensão: o que trocar primeiro</title>, que todo leitor exibe como o original. O corpo HTML vira uma sequência de <p> no arquivo, e o leitor desfaz o escape antes de renderizar. O atom:link com rel="self" não é obrigatório no RSS 2.0, mas o validador do W3C recomenda, e ele ajuda leitores a saber o endereço oficial do feed.
Se o seu blog é em PHP, o mesmo princípio vale com XMLWriter ou DOMDocument, que escapam sozinhos. O post sobre gerar XML em PHP sem concatenar texto mostra esse caminho.
Como validar antes de quem assina perceber
Gere o arquivo e passe pelo xmllint, que vem com a libxml2 e está disponível em praticamente toda distribuição Linux e no macOS. Ele não diz nada quando está tudo certo e aponta linha e coluna quando não está:
$ node -e "const { gerarFeed } = require('./feed'); process.stdout.write(gerarFeed(require('./posts.json')))" > public/feed.xml
$ xmllint --noout public/feed.xml
public/feed.xml:12: parser error : xmlParseEntityRef: no name
<title>Freios & suspensão: o que trocar primeiro</title>
A saída acima é a do gerador antigo, sem escape. Coloque esse comando no pipeline de build com um post de teste que tenha título com &, <, aspas e a sequência ]]>. Se o build falhar, o feed não sobe quebrado.
O xmllint confere boa formação, não as regras do RSS. Para isso, use o W3C Feed Validation Service com a URL publicada. Ele avisa sobre data em formato errado, guid repetido e elementos faltando. Depois, confira o cabeçalho de resposta com curl -I https://oficina.exemplo.com.br/feed.xml e veja se o Content-Type é XML com charset=utf-8. Acento quebrado no leitor é quase sempre codificação declarada de um jeito e servida de outro, assunto de acentos que quebram no banco.
Erros comuns
- Escapar só o título. O próximo
&vem no resumo ou numa URL com parâmetros, e o feed quebra de novo, em outro campo. - Usar entidades do HTML.
no corpo é válido no navegador e erro fatal no XML, porque a entidade não está definida. - Confiar em CDATA. Um post que mostra código com
]]>fecha o bloco no meio e invalida o documento. - Data em formato brasileiro.
13/10/2026nopubDatefaz leitores exibirem data errada ou ordenarem o item no lugar errado. - Mudar o
guidao trocar de domínio ou de slug. Todos os posts antigos reaparecem como novos na caixa de quem assina. - Incluir rascunho. O leitor guarda o item na hora; apagar do feed depois não apaga da caixa de ninguém.
Checklist
- Todo valor dinâmico do feed passa por uma única função de escape de XML.
- Caracteres de controle proibidos são removidos antes do escape.
- O corpo HTML vai escapado em
content:encoded, com o namespace declarado. pubDateestá no formato RFC 822, e oguidé estável.- Só posts publicados entram, com
linkabsoluto apontando para a URL canônica. xmllint --nooutroda no build com um post de teste cheio de caracteres especiais.- A URL publicada passa no validador de feeds do W3C.
- O servidor responde com
Content-Typede XML em UTF-8.
Rode agora xmllint --noout no feed que está no ar hoje (ou cole a URL no validador do W3C): ele passa limpo, ou o seu feed só funciona porque você está olhando no navegador?
Referência: https://www.rssboard.org/rss-specification