Ir para o conteúdo
OneKitly

Retirar HTML com critério: o que um removedor de etiquetas pode e não pode fazer

Publicado a 14/07/2026 · 15 min de leitura · Ferramentas de texto e idioma

Daniel Okonkwo

Daniel OkonkwoProgramador front-end e redator de Tecnologia na OneKitly

Desempenho web · Formatos de ficheiro

Verificado a partir de 5 fontes

Ver perfil
Em resumo

Retirar etiquetas e sanear HTML são trabalhos diferentes. Um removedor de etiquetas produz texto simples para exibir, guardar ou contar; um saneador produz HTML seguro para inserir numa página. Confundir os dois é como se escrevem as falhas de injeção, e uma regex nunca fará o segundo trabalho porque não modela o analisador: dê ao padrão que reconhece um sinal de menor, uma sequência de caracteres que não são maior e um sinal de maior o fragmento <a title="fast > cheap" href="/x">our guide</a>, e ele para no primeiro sinal de maior que encontra, emitindo cheap" href="/x">our guide. Onde a regex e o tokenizador do navegador divergem, quem está errada é a regex, e essas divergências não podem ser enumeradas: por isso as listas negras falham como classe e as defesas reais são o escape contextual no ponto de inserção ou um saneador de lista branca baseado num analisador. Como ferramenta de formatação, porém, um removedor tem trabalho a sério. Num fragmento de 390 caracteres, o padrão ingénuo devolve 183 caracteres que contêm o corpo da folha de estilos, uma linha de JavaScript estragada, a cauda de um comentário, entidades por descodificar e dois itens de lista fundidos em StandardExpress. Elimine o conteúdo dos elementos de texto em bruto, transforme os finais de bloco em linhas em branco e br num salto de linha, descodifique as entidades uma vez: 118 caracteres limpos.

Retirar etiquetas e sanear HTML são dois trabalhos diferentes. Um fragmento real passado por uma regex ingénua e por um limpador consciente da formatação, com o conteúdo de script e style, os cortes de bloco, os comentários, os CDATA e a ordem das entidades mostrados na saída.

Dois trabalhos que parecem um só

Retirar etiquetas significa produzir texto simples: algo para mostrar num resultado de pesquisa, cujas palavras contar, para pôr num email de texto simples, para dar a um resumidor ou para guardar numa coluna que nunca é renderizada como marcação. Sanear significa produzir HTML: marcação que vai ser inserida numa página viva e que, por isso, tem de ser segura ali. Os dois parecem-se porque ambos recebem HTML e devolvem algo mais curto. Não são o mesmo trabalho, não têm o mesmo critério de sucesso, e só um deles é um controlo de segurança.

Este guia trata do primeiro trabalho, bem feito. A questão de segurança é respondida na última secção, brevemente e sem ambiguidade, porque a resposta honesta é curta: um removedor de etiquetas não é uma fronteira de segurança, e nenhuma quantidade de padrões extra o transforma numa. Tudo o que está pelo meio é o trabalho de formatação que um limpador tem mesmo de fazer e que a maioria das implementações falha: eliminar o conteúdo de script e style, transformar a estrutura em quebras de linha, tratar os comentários e os CDATA, e descodificar as entidades no sítio certo.

O que uma regex de etiquetas ingénua faz a um fragmento real

O fragmento de teste tem 390 caracteres de marcação de página vulgar: um contentor article, uma folha de estilos em linha, um cabeçalho com um e comercial codificado, um parágrafo partido por um br e com uma entidade de travessão curto, um comentário HTML cujo texto contém um sinal de maior, uma lista de dois itens, uma ligação cujo atributo title contém um sinal de maior codificado, e um script em linha. Nada de exótico, nada de hostil: é assim uma exportação de um CMS.

Execute o clássico de uma linha — reconhecer um sinal de menor, qualquer sequência de caracteres que não sejam maior, e depois um maior, em global — e voltam 183 caracteres. Entre eles: o texto literal .lead{font-weight:700}, ou seja, o corpo da folha de estilos transformado em prosa. A cadeia var ok = a d;, ou seja, o script amputado a meio porque a regex leu os operadores de comparação como uma etiqueta. O fragmento 0 --> , ou seja, a cauda do comentário, que sobreviveu porque a regex parou no sinal de maior que ele continha. Duas entidades por descodificar. E as palavras Standard e Express fundidas em StandardExpress, junto a uma frase que acaba em Monday.Delivery porque o br não contribuiu com nada.

O mesmo fragmento através de um limpador que sabe o que significam os elementos devolve 118 caracteres, em cinco blocos: o cabeçalho, o parágrafo de duas linhas com o corte do br respeitado, os dois itens de lista nas suas próprias linhas, e a frase final, com o e comercial e o travessão curto descodificados, e sem rasto da folha de estilos, do script ou do comentário. A diferença entre 183 e 118 caracteres não é compressão. É a retirada de coisas que nunca foram texto.

O conteúdo de script e style não são etiquetas

O maior defeito de formatação dos limpadores ingénuos é retirarem as etiquetas de abertura e fecho de script e style e deixarem tudo o que está entre elas. Não é um problema cosmético menor: as regras CSS de uma página ou o código de um script em linha aterram no meio do que devia ser prosa legível, e serão indexados, contados, resumidos e mostrados a alguém. No fragmento de teste, o bloco de declarações CSS aparece como segunda linha da saída, mesmo acima do cabeçalho.

A norma HTML chama-lhes elementos de texto em bruto: dentro de script e style, o analisador deixa de procurar etiquetas e lê até à etiqueta de fecho correspondente. É por isso que a regex ingénua também estraga aquilo que deixa escapar. No script de teste, a expressão que contém um menor e um maior é lida pela regex como uma etiqueta e apagada, por isso o JavaScript que escapa nem sequer é o JavaScript original: é uma versão encurtada que por acaso parece uma frase. Dois modos de falha numa só construção.

A correção é uma regra colocada antes de todas as outras: reconhecer a etiqueta de abertura, o seu conteúdo e a sua etiqueta de fecho como uma só unidade para script, style, template e noscript, e apagar tudo. Coloque-a primeiro no pipeline, porque assim que as etiquetas desaparecem já não consegue saber que texto estava lá dentro.

Os blocos precisam de cortes, e br e p não são o mesmo corte

Apagar uma etiqueta apaga o espaço que ela implicava. Dois parágrafos seguidos sem espaço entre eles na origem tornam-se OneTwo. Dois itens de lista tornam-se AB. Um cabeçalho seguido de um parágrafo torna-se TB. É o defeito que torna o texto limpo ilegível e as contagens de palavras erradas, e é invisível em testes sempre que o HTML de origem esteja formatado com quebras de linha entre as etiquetas, que é precisamente por isso que chega a produção.

O mapeamento que produz uma saída legível é curto. A etiqueta de fecho de um elemento de bloco — p, div, li, tr, h1 a h6, blockquote, section, article e os restantes — torna-se uma linha em branco. Um br torna-se exatamente uma quebra de linha, porque um br é um corte dentro de um bloco, não um bloco novo. Um hr torna-se uma linha em branco. Qualquer outra etiqueta torna-se nada. Depois reduza as séries de três quebras de linha ou mais a uma só linha em branco, porque um bloco aninhado emite duas etiquetas de fecho e portanto duas linhas em branco.

Uma ressalva honesta: este mapeamento põe uma linha em branco entre itens de lista, porque li é um bloco. Lê-se mal numa lista compacta, e a versão mais cuidada trata li como uma simples quebra de linha e reserva a linha em branco só ao ul ou ol envolvente. Querer isso é uma preferência de formatação e não uma questão de correção, mas é uma preferência que tem de exprimir explicitamente: um limpador que nunca pensou em listas vai simplesmente colá-las, e isso não é uma preferência, é um erro.

Comentários, CDATA e entidades: três gramáticas especiais

Um comentário HTML não é uma etiqueta e não segue a gramática das etiquetas: vai de uma abertura de quatro caracteres a um fecho de três, e entre os dois pode aparecer absolutamente qualquer coisa, incluindo sinais de maior. Uma regex de etiquetas que parece retirar comentários safa-se apenas porque a maioria dos comentários não contém nenhum maior. Ponha um — uma nota do tipo manter se o stock for maior que zero — e a regex para aí, deixando o resto do texto do comentário na sua saída. No fragmento de teste, o resíduo visível é a cadeia 0 seguida do terminador de comentário.

As secções CDATA são um caso vizinho que sobrevive na prática dentro de SVG e de exportações ao estilo XHTML. O seu conteúdo não é por definição marcação, e o seu terminador também não é um sinal de maior. Passe pela regex ingénua uma secção CDATA que contenha texto com ar de marcação e obtém o texto interior com as suas etiquetas interiores retiradas e a sequência de parênteses de fecho pendurada: um resultado errado de três maneiras ao mesmo tempo. Reconheça e apague as secções CDATA como uma unidade, antes da passagem geral das etiquetas, exatamente como faz com os comentários.

As entidades vêm por último, e exatamente uma vez. Descodifique depois de as etiquetas desaparecerem, nunca antes: um sinal de menor escapado na origem é texto que o autor queria visível, e descodificá-lo primeiro promove-o a etiqueta que o limpador depois apaga. Num fragmento que diz usar um elemento em para a ênfase, com o nome do elemento escapado, retirar e depois descodificar preserva o nome visível do elemento, enquanto descodificar e depois retirar apaga-o e deixa um buraco. Descodifique duas vezes e fabrica marcação viva a partir de algo escapado de propósito, o que é um erro de formatação a caminho de se tornar um pior.

Onde está realmente a fronteira de segurança

Um removedor de etiquetas é uma ferramenta de formatação. Não é uma fronteira de segurança, e não se transforma numa acrescentando padrões. A razão é estrutural e já se vê na tabela acima: o padrão ingénuo e o tokenizador do navegador divergem sobre onde acaba uma etiqueta assim que um valor de atributo contém um sinal de maior, e a versão consciente da formatação diverge no mesmo sítio. Qualquer filtro construído com padrões é uma lista negra das formas em que o seu autor pensou, e uma lista negra vale o que vale a imaginação do autor, ao passo que o analisador é uma especificação fixa, publicada e independente do adversário. Não se conseguem enumerar as maneiras como duas gramáticas diferem.

As defesas corretas são duas, e nenhuma é uma remoção de etiquetas. A primeira e mais importante é o escape contextual de saída: escape o valor no ponto onde o insere, com a regra de escape desse contexto, porque as regras diferem para texto HTML, valores de atributo, URL, script e style. Um motor de modelos que escapa por omissão faz isso por si, e os erros acontecem onde alguém saiu dele. A segunda, necessária só quando os utilizadores estão de facto autorizados a submeter marcação que tem de ser renderizada como marcação, é um saneador baseado num analisador: analisa a entrada numa árvore e reconstrói-a a partir de uma lista branca de elementos e atributos — o modelo da especificação HTML Sanitizer e das bibliotecas de saneamento estabelecidas. Lista branca, analisador, não regex.

Há uma simplificação limpa que vale a pena enunciar. Se o texto limpo é texto simples e continua a sê-lo — mostrado num nó de texto, escrito numa coluna de texto, contado, enviado como email de texto simples — então a saída do limpador não tem qualquer papel de segurança, porque esse texto nunca mais é analisado como marcação. O perigo só aparece quando alguém pega no texto limpo e o volta a pôr em HTML. Nesse momento, o controlo relevante é o escape nesse ponto de inserção, e não nada do que o limpador tenha feito antes. Manter esses dois momentos separados na cabeça é quase tudo o que esta secção tenta ensinar.

Oito construções HTML através de uma regex de etiquetas ingénua e de um limpador consciente da formatação. Cada coluna de saída é a cadeia literal que o código devolveu no Node 22.
EntradaA regex ingénua devolveO limpador consciente da formatação devolve
<p>One</p><p>Two</p>OneTwoOne, linha em branco, Two
<p>One<br>Two</p>OneTwoOne, quebra de linha simples, Two
<li>A</li><li>B</li>ABA e B em linhas separadas
<h2>T</h2><p>B</p>TBT, linha em branco, B
Um elemento style que contém .lead{font-weight:700}o texto CSS .lead{font-weight:700} como saída visívelnada: o elemento e o seu conteúdo são eliminados juntos
Um elemento script que contém var ok = a < b && c > d;var ok = a d; — o meio engolido como se fosse uma etiquetanada
<p>A<!-- keep if stock > 0 -->B</p>A 0 -->BAB
<a title="fast > cheap" href="/x">our guide</a>cheap" href="/x">our guidetambém errado: nenhuma regex corrige isto, só um analisador
Remover tags HTMLRemova todas as tags HTML de um trecho e mantenha apenas o texto.Experimentar a ferramenta

Perguntas frequentes

Posso simplesmente usar o navegador e ler textContent?
Analisar com o analisador da própria plataforma é o instinto certo, e resolve de graça os problemas de atributos e comentários. Mas repare no que dão as duas propriedades. textContent devolve o texto concatenado de todos os descendentes sem formatação nenhuma, incluindo o conteúdo de script e style, e sem quebras de linha para os blocos, por isso reproduz dois dos defeitos deste artigo. innerText aproxima o texto renderizado, insere quebras de linha e ignora elementos ocultos, mas depende da disposição e portanto do CSS. Num navegador, analise a entrada num documento inerte, remova explicitamente os nós script, style e template, e depois percorra a árvore emitindo os seus próprios cortes. Num servidor sem DOM, uma verdadeira biblioteca de análise HTML dá-lhe a mesma árvore.
Devo truncar antes ou depois de retirar as etiquetas?
Depois, sempre. Truncar HTML corta uma etiqueta ao meio ou deixa um elemento por fechar, e o fragmento resultante não é marcação válida nem texto sensato: um extrato que acaba a meio de um valor de atributo é uma fonte clássica de disposições estragadas quando mais tarde é inserido algures. Retire primeiro, depois trunque o texto simples numa fronteira de palavra, e só então acrescente as reticências. O mesmo argumento vale para contar: uma contagem de caracteres ou palavras feita sobre a marcação conta os nomes de etiqueta e os valores de atributo como conteúdo, por isso está errada na proporção de marcação da origem, que numa exportação típica de CMS é uma fração grande.
Se o texto limpo volta para uma página, descodifico as entidades?
Descodifique-o uma vez para armazenamento, e depois deixe a camada de saída voltar a escapá-lo quando o inserir. Parece trabalho extra e é na verdade o único arranjo que se mantém correto: o seu valor guardado é o texto real, e cada consumidor escapa-o para o seu próprio contexto — texto HTML, um atributo, um URL, uma célula CSV, um email de texto simples. Se em vez disso guardar a forma codificada e saltar o escape na saída porque «já está escapada», o primeiro consumidor que não for HTML recebe sequências de e comercial literais, e o primeiro que escape à mesma codifica-a duas vezes. Um valor descodificado canónico no armazenamento, escape em cada fronteira.
É possível tornar seguro um limpador de regex acrescentando mais padrões?
Não, e a razão merece ser interiorizada porque se generaliza. Uma lista de padrões codifica as formas que o seu autor antecipou; o analisador aceita todas as formas que a especificação define, incluindo o comportamento de recuperação perante entradas malformadas, que ninguém escreve como regra. A segurança só decorre se o modelo da linguagem que o filtro tem for pelo menos tão completo como o do consumidor, e uma lista de padrões é por construção menos completa. É esse todo o argumento a favor das listas brancas contra as negras e dos analisadores contra os padrões, e é por isso que este artigo lhe dá um limpador para a formatação e o encaminha para um saneador para a segurança, em vez de vender uma só ferramenta para ambas as coisas.
O que deve o limpador fazer com imagens, ligações e tabelas?
Decida de propósito e documente a decisão, porque os três carregam informação que vive fora do texto. Uma imagem contribui com o seu texto alternativo, que é a descrição acessível e muitas vezes a única frase que vale a pena guardar; apagar o elemento em silêncio perde-a. Uma ligação contribui com o seu texto de âncora por omissão, e se o destino deve ser acrescentado entre parênteses depende de a sua saída vir alguma vez a ser lida sem forma de seguir ligações. Uma tabela precisa de um separador de células e de um separador de linhas ou cada linha vira uma tirada ilegível de valores concatenados: uma tabulação entre células e uma quebra de linha entre linhas é o mínimo habitual. Nada disto é decidido por si pelos nomes das etiquetas; é uma escolha editorial sobre o que o texto simples deve preservar.

Artigos que podem interessar-lhe

Todos os guias
GuiaRetirar o Markdown: o que o texto simples perde, e em que se engana uma regexUma ligação torna-se texto com o destino apagado, uma lista aninhada perde a hierarquia, uma tabela torna-se uma fila de palavras. Depois a metade técnica: o markdown não tem uma especificação única, e um limpador à base de regex estraga um nome de ficheiro, um sinal de multiplicação e o interior de um bloco de código — tudo confrontado com um analisador a sério.TutorialLimpar texto desarrumado: a ordem das operações que realmente importaRetirar etiquetas antes de descodificar entidades, aparar antes de desduplicar, colapsar os espaços no fim. Três ordens executadas no Node, um pipeline de nove passos na sequência certa e os caracteres invisíveis — U+00A0, U+200B, U+FEFF — que sobrevivem a qualquer limpeza ingénua.GuiaConstruir um URL com parâmetros que sobrevive a um copiar e colarTrês codificações, uma diferença visível: %20 ou +. O modo formulário do gerador reproduz URLSearchParams byte a byte em dezassete valores — mas dê-lhe um URL base com um fragmento e todos os parâmetros vão parar dentro do hash, onde nenhum servidor os vê.ExplicaçãoMaiúscula de frase e maiúscula de título: as regras mudam com a línguaAs maiúsculas de título inglesas têm três cortes diferentes consoante o manual de estilo. O português, o francês, o espanhol e o italiano não têm nenhum. O alemão capitaliza todos os substantivos. A ferramenta não sabe nada disto — eis exatamente o que faz.GuiaOs limites de caracteres que realmente mordem: unidades de código, pontos de código e grafemasUm caráter são três coisas ao mesmo tempo. Um emoji com tom de pele é 1 grafema, 2 pontos de código e 4 unidades UTF-16. Todas as contagens deste guia foram medidas em Node, mais porque é que um SMS cai de 160 para 70 e porque é que VARCHAR(255) não são 255 de coisa nenhuma em concreto.ExplicaçãoContar palavras é ambíguo, e cada ferramenta responde de forma diferenteUma contagem de palavras é uma definição, não uma medição. Contámos o mesmo parágrafo de quatro maneiras e obtivemos 25, 28, 33 e 38; depois contámos 50 000 caracteres de prosa vulgar e obtivemos acordo dentro de 4,5 %. A diferença deve-se inteiramente a compostos, algarismos e URL.

Ferramentas relacionadas

Fontes

Detetaste um erro neste artigo?