Programação Por Equipe DevBras

Capa do artigo: Feed RSS quebrado por um "&": como gerar um XML que todo leitor abre

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 &amp; e &lt;. Entidades do HTML como &nbsp; e &eacute;, 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.

  • pubDate segue o formato de data da RFC 822, como Tue, 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, com isPermaLink="true", ou um identificador estável.
  • Corpo em HTML vai escapado dentro de description ou dentro de content:encoded, do módulo de conteúdo, que precisa ser declarado no elemento raiz.

Como gerar o feed sem concatenar no escuro

  1. 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.
  2. 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.
  3. 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.
  4. Filtre por status e ordene por data. Só published, os 20 mais recentes, do mais novo para o mais antigo.
  5. Use URLs absolutas no link e no guid, apontando para a mesma URL canônica da página.
  6. Sirva com o tipo certo: application/rss+xml ou application/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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

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 &amp; suspensão: o que trocar primeiro</title>, que todo leitor exibe como o original. O corpo HTML vira uma sequência de &lt;p&gt; 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. &nbsp; 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/2026 no pubDate faz leitores exibirem data errada ou ordenarem o item no lugar errado.
  • Mudar o guid ao 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.
  • pubDate está no formato RFC 822, e o guid é estável.
  • Só posts publicados entram, com link absoluto apontando para a URL canônica.
  • xmllint --noout roda 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-Type de 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?

Avalie este artigo

Referência: https://www.rssboard.org/rss-specification