SisuFinderNode/index.js
Murilo Scolari 4ab340fb5a feat: enhance UI and add help modal functionality
- Added a floating help button and modal for user guidance.
- Improved styling for result cards and added match score badges.
- Refactored CSS for better readability and organization.
- Updated HTML structure to accommodate new features and improve accessibility.
- Implemented JavaScript functions for modal management and collapsible sections.
- Marked 'pg' package as a peer dependency in package-lock files.
2025-10-14 04:08:12 -03:00

527 lines
17 KiB
JavaScript

// Carrega as variáveis de ambiente do arquivo .env
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const TABLENAME = 'candidatos_sisu_enem';
const app = express();
app.use(cors());
app.use(express.static('public'));
// Pool de conexões OTIMIZADO com configurações robustas
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Máximo de conexões
min: 2, // Mínimo de conexões sempre ativas
idleTimeoutMillis: 30000, // 30 segundos
connectionTimeoutMillis: 5000, // 5 segundos para obter conexão
keepAlive: true, // Mantém conexões vivas
keepAliveInitialDelayMillis: 10000, // Delay inicial do keepalive
});
// Event listeners para debug
pool.on('error', (err, client) => {
console.error('❌ Erro inesperado no pool de conexões:', err);
});
pool.on('connect', () => {
console.log('✅ Nova conexão estabelecida com o banco');
});
// Função auxiliar para executar queries com RETRY automático
async function executeQueryWithRetry(queryText, params, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await pool.query(queryText, params);
return result;
} catch (error) {
console.error(`❌ Tentativa ${attempt} falhou:`, error.message);
// Se for o último retry, lança o erro
if (attempt === maxRetries) {
throw error;
}
// Aguarda antes de tentar novamente (backoff exponencial)
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
console.log(`⏳ Aguardando ${delay}ms antes de tentar novamente...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Função para normalizar texto (remove acentos)
function normalizeText(text) {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
// Validação e sanitização de inputs
function validateSearchParams(nomes, page) {
const trimmedNomes = nomes.trim();
if (!trimmedNomes || trimmedNomes.length < 2) {
return { valid: false, error: 'Nome deve ter pelo menos 2 caracteres' };
}
const pageNumber = parseInt(page, 10);
if (isNaN(pageNumber) || pageNumber < 1) {
return { valid: false, error: 'Página inválida' };
}
return { valid: true, nomes: trimmedNomes, pageNumber };
}
// Função para gerar condições de busca por palavras
function buildWordSearchConditions(searchTerm) {
const words = searchTerm
.trim()
.split(/\s+/)
.filter(word => word.length >= 2);
if (words.length === 0) return null;
const conditions = words.map(() => `sisu_no_inscrito ILIKE ?`).join(' AND ');
const params = words.map(word => `%${word}%`);
return { conditions, params, words };
}
// ============= BUSCA POR NOME =============
app.get('/buscar', async (req, res) => {
const { nomes = '', page = 1 } = req.query;
const validation = validateSearchParams(nomes, page);
if (!validation.valid) {
return res.status(400).json({ message: validation.error });
}
const { nomes: sanitizedNomes, pageNumber } = validation;
const limit = 50;
const offset = (pageNumber - 1) * limit;
try {
let queryText;
let countQueryText;
let queryParams;
if (sanitizedNomes.includes(';')) {
const searchTerms = sanitizedNomes
.split(';')
.map(name => name.trim())
.filter(name => name.length >= 2);
if (searchTerms.length === 0) {
return res.status(400).json({ message: 'Nenhum termo de busca válido' });
}
const allConditions = [];
const allParams = [];
searchTerms.forEach(term => {
const wordSearch = buildWordSearchConditions(term);
if (wordSearch) {
allConditions.push(`(${wordSearch.conditions})`);
allParams.push(...wordSearch.params);
}
});
if (allConditions.length === 0) {
return res.status(400).json({ message: 'Nenhum termo de busca válido' });
}
const finalCondition = allConditions.join(' OR ');
let paramIndex = 1;
const pgCondition = finalCondition.replace(/\?/g, () => `$${paramIndex++}`);
queryText = `
SELECT
sisu_no_inscrito, sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies, sisu_no_campus,
sisu_ds_turno, sisu_ds_formacao, sisu_nu_nota_candidato, sisu_nu_notacorte_concorrida,
sisu_nu_classificacao, sisu_qt_vagas_concorrencia, sisu_no_modalidade_concorrencia,
enem_nu_nota_cn, enem_nu_nota_ch, enem_nu_nota_lc, enem_nu_nota_mt, enem_nu_nota_redacao,
enem_nu_nota_comp1, enem_nu_nota_comp2, enem_nu_nota_comp3, enem_nu_nota_comp4, enem_nu_nota_comp5,
enem_media_enem, match_score, enem_no_municipio_esc, enem_sg_uf_esc,
sisu_ensino_medio, sisu_quilombola, sisu_deficiente, sisu_tipo_concorrencia,
sisu_st_bonus_perc, sisu_qt_bonus_perc
FROM ${TABLENAME}
WHERE ${pgCondition}
ORDER BY sisu_no_inscrito
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
countQueryText = `
SELECT COUNT(*) FROM ${TABLENAME}
WHERE ${pgCondition}`;
queryParams = [...allParams, limit, offset];
} else {
const wordSearch = buildWordSearchConditions(sanitizedNomes);
if (!wordSearch) {
return res.status(400).json({ message: 'Termo de busca inválido' });
}
const { conditions, params, words } = wordSearch;
let paramIndex = 1;
const pgConditions = conditions.replace(/\?/g, () => `$${paramIndex++}`);
const firstWord = words[0];
const searchTermStart = `${firstWord}%`;
const searchTermWordStart = `% ${firstWord}%`;
queryText = `
SELECT
sisu_no_inscrito, sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies, sisu_no_campus,
sisu_ds_turno, sisu_ds_formacao, sisu_nu_nota_candidato, sisu_nu_notacorte_concorrida,
sisu_nu_classificacao, sisu_qt_vagas_concorrencia, sisu_no_modalidade_concorrencia,
enem_nu_nota_cn, enem_nu_nota_ch, enem_nu_nota_lc, enem_nu_nota_mt, enem_nu_nota_redacao,
enem_nu_nota_comp1, enem_nu_nota_comp2, enem_nu_nota_comp3, enem_nu_nota_comp4, enem_nu_nota_comp5,
enem_media_enem, match_score, enem_no_municipio_esc, enem_sg_uf_esc,
sisu_ensino_medio, sisu_quilombola, sisu_deficiente, sisu_tipo_concorrencia,
sisu_st_bonus_perc, sisu_qt_bonus_perc
FROM ${TABLENAME}
WHERE ${pgConditions}
ORDER BY
CASE
WHEN LOWER(sisu_no_inscrito) LIKE LOWER($${paramIndex}) THEN 1
WHEN LOWER(sisu_no_inscrito) LIKE LOWER($${paramIndex + 1}) THEN 2
ELSE 3
END,
LENGTH(sisu_no_inscrito),
sisu_no_inscrito
LIMIT $${paramIndex + 2} OFFSET $${paramIndex + 3}`;
countQueryText = `
SELECT COUNT(*) FROM ${TABLENAME}
WHERE ${pgConditions}`;
queryParams = [...params, searchTermStart, searchTermWordStart, limit, offset];
}
const resultsPromise = executeQueryWithRetry(queryText, queryParams);
const countParams = sanitizedNomes.includes(';')
? queryParams.slice(0, -2)
: queryParams.slice(0, -4);
const countPromise = executeQueryWithRetry(countQueryText, countParams);
const [results, totalCountResult] = await Promise.all([
resultsPromise,
countPromise
]);
const totalResults = parseInt(totalCountResult.rows[0].count, 10);
const totalPages = Math.ceil(totalResults / limit);
res.json({
data: results.rows,
pagination: {
totalResults,
totalPages,
currentPage: pageNumber
}
});
} catch (error) {
console.error('❌ Erro na busca no banco:', error);
res.status(500).json({ message: 'Erro ao buscar os dados. Tente novamente.' });
}
});
// ============= FILTROS AVANÇADOS =============
app.get('/filtros', async (req, res) => {
const {
curso = '',
uf = '',
notaMin = '',
turno = '',
page = 1
} = req.query;
const limit = 50;
const pageNumber = parseInt(page, 10) || 1;
const offset = (pageNumber - 1) * limit;
try {
let conditions = [];
let params = [];
let paramIndex = 1;
if (curso && curso.trim()) {
const normalizedCurso = normalizeText(curso.trim());
conditions.push(`
LOWER(TRANSLATE(sisu_no_curso,
'áàâãäéèêëíìîïóòôõöúùûüçñÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇÑ',
'aaaaaeeeeiiiiooooouuuucnAAAAAEEEEIIIIOOOOOUUUUCN'
)) LIKE $${paramIndex}
`);
params.push(`%${normalizedCurso}%`);
paramIndex++;
}
if (uf && uf.trim()) {
conditions.push(`sisu_sg_uf_ies = $${paramIndex}`);
params.push(uf.trim().toUpperCase());
paramIndex++;
}
if (notaMin && notaMin.trim() && parseFloat(notaMin) > 0) {
conditions.push(`sisu_nu_nota_candidato >= $${paramIndex}`);
params.push(parseFloat(notaMin));
paramIndex++;
}
if (turno && turno.trim()) {
conditions.push(`sisu_ds_turno ILIKE $${paramIndex}`);
params.push(`%${turno.trim()}%`);
paramIndex++;
}
if (conditions.length === 0) {
return res.status(400).json({
message: 'Selecione pelo menos um filtro para buscar.'
});
}
const whereClause = conditions.join(' AND ');
const queryText = `
SELECT
sisu_no_inscrito, sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies, sisu_no_campus,
sisu_ds_turno, sisu_ds_formacao, sisu_nu_nota_candidato, sisu_nu_notacorte_concorrida,
sisu_nu_classificacao, sisu_qt_vagas_concorrencia, sisu_no_modalidade_concorrencia,
enem_nu_nota_cn, enem_nu_nota_ch, enem_nu_nota_lc, enem_nu_nota_mt, enem_nu_nota_redacao,
enem_nu_nota_comp1, enem_nu_nota_comp2, enem_nu_nota_comp3, enem_nu_nota_comp4, enem_nu_nota_comp5,
enem_media_enem, match_score, enem_no_municipio_esc, enem_sg_uf_esc,
sisu_ensino_medio, sisu_quilombola, sisu_deficiente, sisu_tipo_concorrencia,
sisu_st_bonus_perc, sisu_qt_bonus_perc
FROM ${TABLENAME}
WHERE ${whereClause}
ORDER BY sisu_nu_nota_candidato DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
const countQuery = `
SELECT COUNT(*) FROM ${TABLENAME}
WHERE ${whereClause}`;
params.push(limit, offset);
const [results, countResult] = await Promise.all([
executeQueryWithRetry(queryText, params),
executeQueryWithRetry(countQuery, params.slice(0, -2))
]);
const totalResults = parseInt(countResult.rows[0].count, 10);
const totalPages = Math.ceil(totalResults / limit);
res.json({
data: results.rows,
pagination: {
totalResults,
totalPages,
currentPage: pageNumber
}
});
} catch (error) {
console.error('❌ Erro nos filtros:', error);
res.status(500).json({ message: 'Erro ao filtrar dados. Tente novamente.' });
}
});
// ============= TOP 10 CURSOS MAIS CONCORRIDOS =============
app.get('/stats/cursos-concorridos', async (req, res) => {
try {
const query = `
SELECT
sisu_no_curso,
sisu_no_ies,
sisu_sg_uf_ies,
MAX(sisu_nu_notacorte_concorrida) as nota_corte,
COUNT(*) as total_aprovados,
ROUND(AVG(sisu_nu_nota_candidato)::numeric, 2) as media_aprovados
FROM ${TABLENAME}
WHERE sisu_nu_notacorte_concorrida > 0
GROUP BY sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies
ORDER BY nota_corte DESC
LIMIT 10`;
const result = await executeQueryWithRetry(query, []);
res.json({ data: result.rows });
} catch (error) {
console.error('❌ Erro ao buscar cursos concorridos:', error);
res.status(500).json({ message: 'Erro ao buscar estatísticas. Tente novamente.' });
}
});
// ============= TOP 10 UNIVERSIDADES =============
app.get('/stats/universidades', async (req, res) => {
try {
const query = `
SELECT
sisu_no_ies,
sisu_sg_uf_ies,
ROUND(AVG(sisu_nu_nota_candidato)::numeric, 2) as media_notas,
COUNT(*) as total_aprovados,
MAX(sisu_nu_nota_candidato) as maior_nota,
MIN(sisu_nu_nota_candidato) as menor_nota,
COUNT(DISTINCT sisu_no_curso) as total_cursos
FROM ${TABLENAME}
WHERE sisu_nu_nota_candidato IS NOT NULL
GROUP BY sisu_no_ies, sisu_sg_uf_ies
HAVING COUNT(*) >= 50
ORDER BY media_notas DESC
LIMIT 10`;
const result = await executeQueryWithRetry(query, []);
res.json({ data: result.rows });
} catch (error) {
console.error('❌ Erro ao buscar universidades:', error);
res.status(500).json({ message: 'Erro ao buscar estatísticas. Tente novamente.' });
}
});
// ============= TOP 10 MAIORES NOTAS =============
app.get('/stats/maiores-notas', async (req, res) => {
try {
const query = `
SELECT
sisu_no_inscrito, sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies, sisu_no_campus,
sisu_ds_turno, sisu_ds_formacao, sisu_nu_nota_candidato, sisu_nu_notacorte_concorrida,
sisu_nu_classificacao, sisu_qt_vagas_concorrencia, sisu_no_modalidade_concorrencia,
enem_nu_nota_cn, enem_nu_nota_ch, enem_nu_nota_lc, enem_nu_nota_mt, enem_nu_nota_redacao,
enem_nu_nota_comp1, enem_nu_nota_comp2, enem_nu_nota_comp3, enem_nu_nota_comp4, enem_nu_nota_comp5,
enem_media_enem, match_score, enem_no_municipio_esc, enem_sg_uf_esc,
sisu_ensino_medio, sisu_quilombola, sisu_deficiente, sisu_tipo_concorrencia,
sisu_st_bonus_perc, sisu_qt_bonus_perc
FROM ${TABLENAME}
WHERE sisu_nu_nota_candidato IS NOT NULL
ORDER BY sisu_nu_nota_candidato DESC
LIMIT 10`;
const result = await executeQueryWithRetry(query, []);
res.json({ data: result.rows });
} catch (error) {
console.error('❌ Erro ao buscar maiores notas:', error);
res.status(500).json({ message: 'Erro ao buscar estatísticas. Tente novamente.' });
}
});
// ============= ESTATÍSTICAS POR ESTADO =============
app.get('/stats/estado/:uf', async (req, res) => {
const { uf } = req.params;
try {
// Query 1: Dados gerais do estado
const queryGeral = `
SELECT
COUNT(*) as total_aprovados,
ROUND(AVG(sisu_nu_nota_candidato)::numeric, 2) as media_notas,
MAX(sisu_nu_nota_candidato) as maior_nota,
MIN(sisu_nu_nota_candidato) as menor_nota,
COUNT(DISTINCT sisu_no_ies) as total_universidades,
COUNT(DISTINCT sisu_no_curso) as total_cursos
FROM ${TABLENAME}
WHERE sisu_sg_uf_ies = $1 AND sisu_nu_nota_candidato IS NOT NULL`;
// Query 2: Top 3 cursos mais concorridos do estado
const queryTopCursos = `
SELECT
sisu_no_curso,
sisu_no_ies,
MAX(sisu_nu_notacorte_concorrida) as nota_corte,
COUNT(*) as total_aprovados
FROM ${TABLENAME}
WHERE sisu_sg_uf_ies = $1 AND sisu_nu_notacorte_concorrida > 0
GROUP BY sisu_no_curso, sisu_no_ies
ORDER BY nota_corte DESC
LIMIT 3`;
// Query 3: Distribuição por modalidade
const queryModalidades = `
SELECT
sisu_no_modalidade_concorrencia,
COUNT(*) as total
FROM ${TABLENAME}
WHERE sisu_sg_uf_ies = $1
GROUP BY sisu_no_modalidade_concorrencia
ORDER BY total DESC
LIMIT 5`;
// Query 4: Top 3 universidades do estado
const queryTopUniversidades = `
SELECT
sisu_no_ies,
COUNT(*) as total_aprovados,
ROUND(AVG(sisu_nu_nota_candidato)::numeric, 2) as media_notas
FROM ${TABLENAME}
WHERE sisu_sg_uf_ies = $1 AND sisu_nu_nota_candidato IS NOT NULL
GROUP BY sisu_no_ies
ORDER BY total_aprovados DESC
LIMIT 3`;
const [geral, topCursos, modalidades, topUniversidades] = await Promise.all([
executeQueryWithRetry(queryGeral, [uf.toUpperCase()]),
executeQueryWithRetry(queryTopCursos, [uf.toUpperCase()]),
executeQueryWithRetry(queryModalidades, [uf.toUpperCase()]),
executeQueryWithRetry(queryTopUniversidades, [uf.toUpperCase()])
]);
res.json({
estado: uf.toUpperCase(),
geral: geral.rows[0],
topCursos: topCursos.rows,
modalidades: modalidades.rows,
topUniversidades: topUniversidades.rows
});
} catch (error) {
console.error('❌ Erro ao buscar dados do estado:', error);
res.status(500).json({ message: 'Erro ao buscar dados do estado.' });
}
});
// ============= HEALTH CHECK =============
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
poolSize: pool.totalCount,
idleConnections: pool.idleCount,
waitingClients: pool.waitingCount
});
} catch (error) {
res.status(503).json({
status: 'error',
message: 'Database unavailable',
error: error.message
});
}
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('⏳ SIGTERM recebido, fechando servidor...');
await pool.end();
console.log('✅ Pool de conexões fechado');
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('⏳ SIGINT recebido, fechando servidor...');
await pool.end();
console.log('✅ Pool de conexões fechado');
process.exit(0);
});
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`🚀 SisuData rodando na porta ${PORT}!`);
console.log(`📊 Pool configurado: max=${pool.options.max}, min=${pool.options.min || 0}`);
});