Adiciona arquivos de configuração e scripts para importação de dados do Sisu para o banco de dados

This commit is contained in:
Murilo Scolari 2025-09-12 08:10:37 -03:00
parent e6194438a7
commit 9f57ffa204
11 changed files with 722 additions and 229 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
# Credenciais do seu banco de dados NeonDB
DATABASE_URL="postgresql://usuario:senha@endereco_do_banco:porta/nome_do_banco"

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
csv/*
!csv/.gitkeep
.env
node_modules/
__pycache__/
venv/

75
README.md Normal file
View file

@ -0,0 +1,75 @@
# SisuFinderNode
SisuFinderNode é uma aplicação que permite buscar candidatos do Sisu por nome, utilizando dados públicos do MEC. O sistema faz scraping dos arquivos CSV, importa para um banco PostgreSQL e oferece uma interface web moderna para consulta.
## Funcionalidades
- **Scraper:** Baixa arquivos CSV do Sisu a partir de um arquivo JSON com os IDs das instituições.
- **Importação:** Junta e trata todos os CSVs, importando para uma tabela no PostgreSQL.
- **API Express:** Endpoint `/buscar` para consultar candidatos por nome (busca simples ou múltipla).
- **Frontend:** Interface web responsiva para busca e navegação paginada dos resultados.
## Estrutura dos arquivos
- `scraper.py`: Baixa os arquivos CSV do Sisu usando os IDs do arquivo `arquivo.json`.
- `importar_csv.py`: Lê todos os CSVs, trata os dados e importa para o banco PostgreSQL.
- `index.js`: Backend Express que serve a API e o frontend.
- `public/index.html`: Interface web para busca dos candidatos.
- `arquivo.json`: Lista de objetos com o campo `co_ies` (IDs das instituições).
- `csv/`: Pasta onde ficam os arquivos CSV baixados.
## Pré-requisitos
- Node.js >= 16
- Python >= 3.8
- PostgreSQL (recomendado NeonDB)
## Instalação
1. Clone o repositório:
```bash
git clone https://github.com/Revocable/SisuFinderNode.git
cd SisuFinderNode
```
2. Instale as dependências Node.js:
```bash
npm install
```
3. Instale as dependências Python:
```bash
pip install -r requirements.txt
```
4. Configure o arquivo `.env`:
```env
DATABASE_URL=postgres://usuario:senha@host:porta/database
```
## Como executar
### 1. Baixar os arquivos CSV
Coloque o arquivo `arquivo.json` na raiz do projeto, contendo os IDs das instituições no campo `co_ies`.
Execute o scraper:
```bash
python scraper.py
```
### 2. Importar os dados para o banco
```bash
python importar_csv.py
```
### 3. Iniciar o servidor Express
```bash
node index.js
```
O servidor estará disponível em `http://localhost:8080`.
## Como utilizar
- Acesse `http://localhost:8080` no navegador.
- Digite o nome do candidato ou vários nomes separados por ponto e vírgula (`;`).
- Clique em "Buscar" para ver os resultados paginados.
- Use os botões de navegação para avançar entre as páginas.
## Observações
- Os arquivos CSV não são versionados pelo Git (veja `.gitignore`).
- O banco de dados deve estar acessível via URL definida no `.env`.
- O scraper depende do formato da API pública do Sisu/MEC.
## Autor
Desenvolvido por Murilo Scolari
[GitHub](https://github.com/Revocable)

0
csv/.gitkeep Normal file
View file

73
importar_csv.py Normal file
View file

@ -0,0 +1,73 @@
import os
import glob
import pandas as pd
from sqlalchemy import create_engine
from dotenv import load_dotenv
# Carrega as variáveis do arquivo .env
load_dotenv()
# Pega a URL de conexão do banco de dados do .env
DATABASE_URL = os.getenv('DATABASE_URL')
# Define o nome da tabela que vamos criar no banco
NOME_TABELA = 'candidatos_sisu'
# Path para a pasta com os arquivos CSV
CAMINHO_CSVS = 'csv/*.csv'
def importar_dados():
"""
Lê todos os arquivos CSV de uma pasta, trata os dados e os envia
para uma tabela no PostgreSQL.
"""
print("Iniciando a importação dos dados...")
try:
# Cria a "engine" de conexão com o banco de dados
engine = create_engine(DATABASE_URL)
print("✅ Conexão com o NeonDB estabelecida!")
# Busca todos os arquivos que terminam com .csv na pasta especificada
todos_os_arquivos = glob.glob(CAMINHO_CSVS)
if not todos_os_arquivos:
print("❌ Opa, não encontrei nenhum arquivo .csv na pasta 'csv/'.")
return
print(f"Encontrei {len(todos_os_arquivos)} arquivos CSV para importar.")
# Junta todos os CSVs em um único DataFrame do Pandas
lista_dfs = []
for arquivo in todos_os_arquivos:
print(f"Lendo o arquivo: {arquivo}...")
# Lê o CSV usando ';' como separador
df_temp = pd.read_csv(arquivo, sep=';')
lista_dfs.append(df_temp)
df = pd.concat(lista_dfs, ignore_index=True)
# --- Tratamento dos dados ---
# Deixa os nomes das colunas em minúsculo pra facilitar no SQL
df.columns = df.columns.str.lower()
# Converte as notas (que estão com vírgula) para número
df['nu_nota_candidato'] = pd.to_numeric(df['nu_nota_candidato'].str.replace(',', '.', regex=False), errors='coerce')
df['nu_notacorte_concorrida'] = pd.to_numeric(df['nu_notacorte_concorrida'].str.replace(',', '.', regex=False), errors='coerce')
# Converte a coluna de bônus para número, tratando valores vazios
df['qt_bonus_perc'] = pd.to_numeric(df['qt_bonus_perc'], errors='coerce')
print("Dados tratados. Enviando para o banco de dados...")
# Envia o DataFrame para a tabela no banco de dados
# 'if_exists='replace'' apaga a tabela se ela já existir e cria de novo.
# Se preferir, pode usar 'append' para só adicionar os novos dados.
df.to_sql(NOME_TABELA, engine, if_exists='replace', index=False)
print(f"🚀 Sucesso! Todos os dados foram importados para a tabela '{NOME_TABELA}'.")
except Exception as e:
print(f"❌ Eita, deu um erro: {e}")
if __name__ == '__main__':
importar_dados()

168
index.js
View file

@ -1,115 +1,81 @@
// Carrega as variáveis de ambiente do arquivo .env
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const csv = require('csv-parse');
const { Pool } = require('pg');
const app = express();
// Enable CORS for all routes
app.use(cors());
// Serve static files
app.use(express.static('public'));
// Global variable to store CSV data
let globalCsvData = [];
// Function to load CSV data into memory - now with streaming for better performance
async function loadCSV(csvFolder) {
const dados = [];
const files = fs.readdirSync(csvFolder).filter(file => file.endsWith('.csv'));
for (const file of files) {
const filePath = path.join(csvFolder, file);
await new Promise((resolve, reject) => {
const parser = csv.parse({
delimiter: ';',
skip_empty_lines: true,
from_line: 2 // Skip header line
});
fs.createReadStream(filePath, { encoding: 'utf-8' })
.pipe(parser)
.on('data', (line) => {
if (line.length > 17) {
dados.push({
nota: line[16].trim(), // Atualizando para acessar "NU_NOTA_CANDIDATO" que é a coluna 17 (índice 16)
nome_aluno: line[11].trim().toLowerCase(),
instituicao: line[1].trim(),
curso: line[6].trim(),
no_campus: line[4].trim()
});
}
})
.on('error', reject)
.on('end', resolve);
});
}
return dados;
}
// Optimized search function using Set for faster lookups
function searchNamesInCSV(names, data) {
const nameSet = new Set(names.map(name => name.trim().toLowerCase()));
return data.filter(student =>
Array.from(nameSet).some(name => student.nome_aluno.includes(name))
);
}
// Initialize data at server startup
async function initializeData() {
try {
console.log('Loading CSV data...');
const startTime = Date.now();
globalCsvData = await loadCSV("csv");
const endTime = Date.now();
console.log(`CSV data loaded successfully in ${endTime - startTime}ms. Total records: ${globalCsvData.length}`);
} catch (error) {
console.error('Error loading CSV data:', error);
process.exit(1);
}
}
// Root endpoint - serve index.html
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: {
rejectUnauthorized: false
}
});
// Search endpoint - now using pre-loaded data
app.get('/buscar', (req, res) => {
const names = req.query.nomes;
if (!names) {
return res.status(400).json({
error: "Por favor, forneça os nomes na URL, por exemplo: ?nomes=João;Maria"
});
app.get('/buscar', async (req, res) => {
const { nomes = '', page = 1 } = req.query;
const limit = 50;
const pageNumber = parseInt(page, 10);
const offset = (pageNumber - 1) * limit;
try {
let queryText;
let countQueryText;
let queryParams;
// Lógica inteligente: se tiver ';', faz busca múltipla. Senão, busca simples.
if (nomes.includes(';')) {
// Mapeia os nomes para o formato do ILIKE e cria um array
const searchTerms = nomes.split(';').map(name => `%${name.trim()}%`);
queryText = `
SELECT no_inscrito, no_curso, no_ies, nu_nota_candidato
FROM candidatos_sisu
WHERE no_inscrito ILIKE ANY($1)
ORDER BY no_inscrito
LIMIT $2 OFFSET $3`;
countQueryText = 'SELECT COUNT(*) FROM candidatos_sisu WHERE no_inscrito ILIKE ANY($1)';
queryParams = [searchTerms, limit, offset];
} else {
const searchTerm = `%${nomes}%`;
queryText = `
SELECT no_inscrito, no_curso, no_ies, nu_nota_candidato
FROM candidatos_sisu
WHERE no_inscrito ILIKE $1
ORDER BY no_inscrito
LIMIT $2 OFFSET $3`;
countQueryText = 'SELECT COUNT(*) FROM candidatos_sisu WHERE no_inscrito ILIKE $1';
queryParams = [searchTerm, limit, offset];
}
const resultsPromise = pool.query(queryText, queryParams);
const countPromise = pool.query(countQueryText, [queryParams[0]]); // Usa só o primeiro param (termo de busca)
try {
const nameList = names.split(';');
const results = searchNamesInCSV(nameList, globalCsvData);
return res.json({ resultados: results });
} catch (error) {
console.error('Error:', error);
return res.status(500).json({
error: "Erro interno do servidor"
});
}
});
const [results, totalCountResult] = await Promise.all([resultsPromise, countPromise]);
// Start server after loading data
const PORT = 8080;
const totalResults = parseInt(totalCountResult.rows[0].count, 10);
const totalPages = Math.ceil(totalResults / limit);
// Create public directory if it doesn't exist
if (!fs.existsSync('public')) {
fs.mkdirSync('public');
}
initializeData().then(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
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.' });
}
});
const PORT = 8080;
app.listen(PORT, () => {
console.log(`Servidor rodando na porta ${PORT}!`);
});

145
node_modules/.package-lock.json generated vendored
View file

@ -124,7 +124,6 @@
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
@ -179,6 +178,17 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "17.2.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -257,7 +267,6 @@
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@ -592,6 +601,122 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -802,6 +927,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@ -859,6 +992,14 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"engines": {
"node": ">=0.4"
}
}
}
}

149
package-lock.json generated
View file

@ -8,7 +8,9 @@
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"csv-parser": "^3.1.0",
"express": "^4.21.2"
"dotenv": "^17.2.2",
"express": "^4.21.2",
"pg": "^8.16.3"
}
},
"node_modules/accepts": {
@ -132,7 +134,6 @@
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
@ -187,6 +188,17 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "17.2.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -265,7 +277,6 @@
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@ -600,6 +611,122 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -810,6 +937,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@ -867,6 +1002,14 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"engines": {
"node": ">=0.4"
}
}
}
}

View file

@ -3,6 +3,8 @@
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"csv-parser": "^3.1.0",
"express": "^4.21.2"
"dotenv": "^17.2.2",
"express": "^4.21.2",
"pg": "^8.16.3"
}
}

View file

@ -3,114 +3,132 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Busca de Alunos</title>
<title>SisuFinder</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<style>
body {
font-family: Arial, sans-serif;
background-color: #121212;
color: #e0e0e0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
:root {
--bg-color: #121212;
--surface-color: rgba(30, 30, 30, 0.7);
--primary-color: #00e676;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--border-color: rgba(255, 255, 255, 0.1);
}
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(45deg, #1a237e, #0d47a1, #01579b, #006064);
background-size: 400% 400%;
animation: gradientBG 15s ease infinite;
color: var(--text-primary);
margin: 0;
padding: 20px;
min-height: 100vh;
box-sizing: border-box;
}
@keyframes gradientBG {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.container {
max-width: 800px;
margin: 50px auto;
padding: 20px;
background-color: #1f1f1f;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
flex-grow: 1;
}
h1 {
text-align: center;
color: #ffffff;
}
.input-group {
margin-bottom: 20px;
display: flex;
justify-content: center;
margin: 20px auto;
padding: 30px;
background: var(--surface-color);
border-radius: 15px;
border: 1px solid var(--border-color);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
h1 { text-align: center; color: #ffffff; font-weight: 600; }
.input-group { margin-bottom: 20px; display: flex; gap: 10px; }
input[type="text"] {
padding: 10px;
width: 70%;
border: 1px solid #555;
border-radius: 5px;
flex-grow: 1;
padding: 12px 15px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 16px;
background-color: #333;
color: #e0e0e0;
background-color: rgba(0, 0, 0, 0.3);
color: var(--text-primary);
transition: all 0.3s ease;
}
button {
padding: 10px 15px;
margin-left: 10px;
background-color: #4CAF50;
color: white;
input[type="text"]:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 10px rgba(0, 230, 118, 0.3);
}
#searchButton {
background-color: var(--primary-color);
color: #121212;
font-weight: 600;
border: none;
border-radius: 5px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
padding: 10px 20px;
transition: all 0.3s ease;
}
button:hover {
background-color: #45a049;
}
.result {
margin-top: 20px;
#searchButton:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 230, 118, 0.4);
}
.result-card {
background-color: #333;
padding: 15px;
margin-bottom: 10px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
background-color: rgba(0, 0, 0, 0.2);
padding: 20px;
margin-bottom: 15px;
border-radius: 10px;
border-left: 4px solid var(--primary-color);
transition: all 0.3s ease;
}
.result-card h3 {
margin: 0;
font-size: 18px;
color: #fff;
}
.result-card p {
margin: 5px 0;
color: #ddd;
}
.error {
color: red;
text-align: center;
}
.info {
font-size: 14px;
color: #bbb;
text-align: center;
}
.footer {
font-size: 12px;
color: #bbb;
text-align: center;
margin-top: auto;
padding: 10px;
}
.footer a {
color: #4CAF50;
text-decoration: none;
.result-card:hover {
transform: scale(1.02);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.result-card h3 { margin: 0 0 10px 0; color: #fff; font-weight: 600; }
.result-card p { margin: 5px 0; color: var(--text-secondary); }
.result-card strong { color: var(--text-primary); }
.message { text-align: center; color: var(--text-secondary); padding: 20px; }
.pagination-controls { display: flex; justify-content: center; align-items: center; margin-top: 30px; gap: 15px; }
.pagination-controls button { background: none; border: 1px solid var(--primary-color); color: var(--primary-color); border-radius: 8px; cursor: pointer; font-size: 16px; padding: 10px 20px; transition: all 0.3s ease; }
.pagination-controls button:hover:not(:disabled) { background-color: var(--primary-color); color: var(--bg-color); }
.pagination-controls button:disabled { border-color: #555; color: #555; cursor: not-allowed; }
.pagination-info { font-size: 16px; color: var(--text-secondary); }
.footer { font-size: 14px; color: var(--text-secondary); text-align: center; margin-top: 40px; }
.footer a { color: var(--primary-color); text-decoration: none; }
.footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<h1>Busca de Alunos</h1>
<h1>SisuFinder</h1>
<div class="input-group">
<input type="text" id="nome" placeholder="Digite o nome(s) para buscar" />
<button onclick="buscarAluno()">Buscar</button>
<input type="text" id="nomeInput" placeholder="Digite um nome ou vários separados por ;" />
<button id="searchButton">Buscar</button>
</div>
<div class="info">
<p><strong>Para buscar vários nomes, separe-os com ";".</strong></p>
</div>
<div id="message" class="message" style="display: none;"></div>
<div id="result" class="result"></div>
<div class="result" id="result"></div>
<div class="error" id="error"></div>
<div id="pagination-controls" class="pagination-controls" style="display: none;">
<button id="prevButton">Anterior</button>
<span id="pagination-info" class="pagination-info"></span>
<button id="nextButton">Próxima</button>
</div>
</div>
<div class="footer">
@ -119,49 +137,112 @@
</div>
<script>
function buscarAluno() {
const nome = document.getElementById('nome').value.trim();
const errorDiv = document.getElementById('error');
const resultDiv = document.getElementById('result');
// Limpar resultados anteriores
const nomeInput = document.getElementById('nomeInput');
const searchButton = document.getElementById('searchButton');
const resultDiv = document.getElementById('result');
const messageDiv = document.getElementById('message');
const paginationControls = document.getElementById('pagination-controls');
const paginationInfo = document.getElementById('pagination-info');
const prevButton = document.getElementById('prevButton');
const nextButton = document.getElementById('nextButton');
let currentPage = 1;
let totalPages = 0;
let currentSearchTerm = '';
let debounceTimeout;
async function fetchResults(searchTerm, page) {
messageDiv.textContent = 'Buscando...';
messageDiv.style.display = 'block';
resultDiv.innerHTML = '';
errorDiv.innerHTML = '';
paginationControls.style.display = 'none';
try {
const response = await fetch(`/buscar?nomes=${encodeURIComponent(searchTerm)}&page=${page}`);
if (!response.ok) throw new Error('Erro na resposta do servidor.');
const data = await response.json();
messageDiv.style.display = 'none';
if (!nome) {
errorDiv.textContent = 'Por favor, digite um nome.';
return;
if (data.data && data.data.length > 0) {
displayResults(data.data);
updatePagination(data.pagination);
} else {
messageDiv.textContent = 'Nenhum candidato encontrado.';
messageDiv.style.display = 'block';
}
} catch (error) {
messageDiv.textContent = 'Ocorreu um erro ao buscar os dados.';
console.error(error);
}
// Realiza a requisição GET
fetch(`/buscar?nomes=${encodeURIComponent(nome)}`)
.then(response => response.json())
.then(data => {
if (data.error) {
errorDiv.textContent = data.error;
} else if (data.resultados.length === 0) {
errorDiv.textContent = 'Nenhum aluno encontrado com esse nome.';
} else {
// Exibe os resultados
data.resultados.forEach(item => {
const card = document.createElement('div');
card.className = 'result-card';
card.innerHTML = `
<h3>${item.nome_aluno}</h3>
<p><strong>Nota:</strong> ${item.nota}</p>
<p><strong>Instituição:</strong> ${item.instituicao}</p>
<p><strong>Curso:</strong> ${item.curso}</p>
<p><strong>Campus:</strong> ${item.no_campus}</p> <!-- Alterado para exibir "Campus" -->
`;
resultDiv.appendChild(card);
});
}
})
.catch(error => {
errorDiv.textContent = 'Ocorreu um erro ao buscar os dados.';
});
}
function displayResults(results) {
results.forEach(item => {
const card = document.createElement('div');
card.className = 'result-card';
card.innerHTML = `
<h3>${item.no_inscrito}</h3>
<p><strong>Nota:</strong> ${item.nu_nota_candidato}</p>
<p><strong>Instituição:</strong> ${item.no_ies}</p>
<p><strong>Curso:</strong> ${item.no_curso}</p>
`;
resultDiv.appendChild(card);
});
}
function updatePagination(pagination) {
currentPage = pagination.currentPage;
totalPages = pagination.totalPages;
currentSearchTerm = nomeInput.value;
if (totalPages > 0) { // Mostra mesmo se for 1 página
paginationControls.style.display = 'flex';
paginationInfo.textContent = `Página ${currentPage} de ${totalPages}`;
prevButton.disabled = currentPage <= 1;
nextButton.disabled = currentPage >= totalPages;
}
}
nomeInput.addEventListener('input', () => {
clearTimeout(debounceTimeout);
// Só faz a busca em tempo real se NÃO tiver ponto e vírgula
if (!nomeInput.value.includes(';')) {
debounceTimeout = setTimeout(() => {
const searchTerm = nomeInput.value.trim();
if (searchTerm) {
fetchResults(searchTerm, 1);
} else {
resultDiv.innerHTML = '';
messageDiv.style.display = 'none';
paginationControls.style.display = 'none';
}
}, 500); // Aumentei um pouco o tempo para melhor performance
}
});
// O botão de busca funciona para qualquer tipo de busca
searchButton.addEventListener('click', () => {
const searchTerm = nomeInput.value.trim();
if (searchTerm) {
fetchResults(searchTerm, 1);
}
});
prevButton.addEventListener('click', () => {
if (currentPage > 1) {
fetchResults(currentSearchTerm, currentPage - 1);
}
});
nextButton.addEventListener('click', () => {
if (currentPage < totalPages) {
fetchResults(currentSearchTerm, currentPage + 1);
}
});
</script>
</body>
</html>
</html>

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
pandas
SQLAlchemy
psycopg2-binary
python-dotenv