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