feat(v2): add robust GLPI text normalization (HTML/entities, multi-level escaping)
Handles: numeric entities (< >), named entities (& etc), HTML tags, multi-level escaping (up to 5 decode iterations, capped to avoid infinite loop), whitespace/newline collapsing. Preserves dates, names, numbers, error codes, hostnames, versions -- only strips markup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6bbdaa5fe8
commit
d7650308a9
@@ -0,0 +1,58 @@
|
||||
const NAMED_ENTITIES = {
|
||||
' ': ' ',
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
};
|
||||
|
||||
const MAX_DECODE_ITERATIONS = 5;
|
||||
|
||||
function decodeEntitiesOnce(text) {
|
||||
let result = text.replace(/&#(\d+);/g, (match, code) => {
|
||||
const num = Number(code);
|
||||
return Number.isFinite(num) ? String.fromCodePoint(num) : match;
|
||||
});
|
||||
result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {
|
||||
const num = parseInt(hex, 16);
|
||||
return Number.isFinite(num) ? String.fromCodePoint(num) : match;
|
||||
});
|
||||
for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {
|
||||
result = result.split(entity).join(char);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripHtmlTags(text) {
|
||||
return text.replace(/<[^>]*>/g, ' ');
|
||||
}
|
||||
|
||||
// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS
|
||||
// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,
|
||||
// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto
|
||||
// que nao seja literalmente uma entidade HTML ou uma tag).
|
||||
function normalizeGlpiText(rawText) {
|
||||
if (rawText === null || rawText === undefined) return '';
|
||||
let text = String(rawText);
|
||||
|
||||
for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {
|
||||
const decoded = decodeEntitiesOnce(text);
|
||||
if (decoded === text) break;
|
||||
text = decoded;
|
||||
}
|
||||
|
||||
text = stripHtmlTags(text);
|
||||
|
||||
text = text
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/[ \t]+/g, ' ').trim())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
module.exports = { normalizeGlpiText, decodeEntitiesOnce, stripHtmlTags };
|
||||
@@ -0,0 +1,63 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { normalizeGlpiText } = require('./normalize');
|
||||
|
||||
test('HTML normal e removido, texto preservado', () => {
|
||||
assert.equal(normalizeGlpiText('<p>Aguardando cliente</p>'), 'Aguardando cliente');
|
||||
});
|
||||
|
||||
test('entidades HTML escapadas uma vez sao decodificadas e as tags removidas', () => {
|
||||
const entrada = '<p>Aguardando retorno de Patricia sobre troca da carcaça</p>';
|
||||
assert.equal(normalizeGlpiText(entrada), 'Aguardando retorno de Patricia sobre troca da carcaça');
|
||||
});
|
||||
|
||||
test('entidades HTML escapadas duas vezes sao decodificadas iterativamente', () => {
|
||||
const entrada = '&#60;p&#62;texto&#60;/p&#62;';
|
||||
assert.equal(normalizeGlpiText(entrada), 'texto');
|
||||
});
|
||||
|
||||
test('entidades nomeadas comuns (amp, nbsp) sao decodificadas', () => {
|
||||
assert.equal(normalizeGlpiText('Suporte & Manutenção'), 'Suporte & Manutenção');
|
||||
});
|
||||
|
||||
test('texto sem HTML permanece intacto (so trim de espacos)', () => {
|
||||
assert.equal(normalizeGlpiText(' Servidor não responde '), 'Servidor não responde');
|
||||
});
|
||||
|
||||
test('caracteres acentuados sao preservados', () => {
|
||||
assert.equal(normalizeGlpiText('<p>Impressão não está funcionando corretamente na área de atendimento</p>'), 'Impressão não está funcionando corretamente na área de atendimento');
|
||||
});
|
||||
|
||||
test('codigo tecnico (numeros, versao, hostname) e preservado', () => {
|
||||
const entrada = '<p>Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50</p>';
|
||||
assert.equal(normalizeGlpiText(entrada), 'Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50');
|
||||
});
|
||||
|
||||
test('string vazia retorna string vazia', () => {
|
||||
assert.equal(normalizeGlpiText(''), '');
|
||||
});
|
||||
|
||||
test('null e undefined retornam string vazia sem lancar excecao', () => {
|
||||
assert.doesNotThrow(() => normalizeGlpiText(null));
|
||||
assert.doesNotThrow(() => normalizeGlpiText(undefined));
|
||||
assert.equal(normalizeGlpiText(null), '');
|
||||
assert.equal(normalizeGlpiText(undefined), '');
|
||||
});
|
||||
|
||||
test('quebras de linha excessivas e espacos duplicados sao colapsados', () => {
|
||||
const entrada = '<p>Linha 1</p>\n\n\n\n<p>Linha 2 com espacos</p>';
|
||||
const resultado = normalizeGlpiText(entrada);
|
||||
assert.ok(!resultado.includes('\n\n\n'));
|
||||
assert.ok(!/ {2,}/.test(resultado));
|
||||
});
|
||||
|
||||
test('decodificacao para no limite de iteracoes sem lancar excecao (protecao contra loop)', () => {
|
||||
const entradaMaliciosa = '&'.repeat(20) + '#60;p>';
|
||||
assert.doesNotThrow(() => normalizeGlpiText(entradaMaliciosa));
|
||||
});
|
||||
|
||||
test('conteudo normalizado de duas codificacoes diferentes do mesmo texto e identico', () => {
|
||||
const versaoHtml = '<p>Aguardando cliente</p>';
|
||||
const versaoEscapada = '<p>Aguardando cliente</p>';
|
||||
assert.equal(normalizeGlpiText(versaoHtml), normalizeGlpiText(versaoEscapada));
|
||||
});
|
||||
Reference in New Issue
Block a user