232 lines
8.4 KiB
JavaScript
232 lines
8.4 KiB
JavaScript
const http = require("http");
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const XLSX = require("xlsx");
|
||
const formidable = require("formidable");
|
||
|
||
const PORT = process.env.PORT || 3000;
|
||
const DATA_DIR = path.join(__dirname, "uploads");
|
||
const ANALYSIS_FILE = path.join(__dirname, "analysis.json");
|
||
|
||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);
|
||
|
||
function readAnalysis() {
|
||
try { return JSON.parse(fs.readFileSync(ANALYSIS_FILE, "utf8")); } catch (_) { return null; }
|
||
}
|
||
function writeAnalysis(data) {
|
||
fs.writeFileSync(ANALYSIS_FILE, JSON.stringify(data, null, 2));
|
||
}
|
||
|
||
function body(req) {
|
||
return new Promise((resolve) => {
|
||
let s = "";
|
||
req.on("data", (c) => (s += c));
|
||
req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } });
|
||
});
|
||
}
|
||
|
||
function parseFile(filepath) {
|
||
const ext = path.extname(filepath).toLowerCase();
|
||
if (ext === ".csv") {
|
||
const content = fs.readFileSync(filepath, "utf8");
|
||
const lines = content.split(/\r?\n/).filter(l => l.trim());
|
||
if (lines.length < 2) return { columns: [], rows: [] };
|
||
const parseLine = (line) => {
|
||
const result = [];
|
||
let current = '', inQuotes = false;
|
||
for (let i = 0; i < line.length; i++) {
|
||
const char = line[i];
|
||
if (char === '"') { inQuotes = !inQuotes; }
|
||
else if (char === ',' && !inQuotes) { result.push(current.trim()); current = ''; }
|
||
else { current += char; }
|
||
}
|
||
result.push(current.trim());
|
||
return result;
|
||
};
|
||
const columns = parseLine(lines[0]);
|
||
const rows = lines.slice(1).map(line => {
|
||
const values = parseLine(line);
|
||
const obj = {};
|
||
columns.forEach((col, i) => { obj[col] = values[i] || ''; });
|
||
return obj;
|
||
});
|
||
return { columns, rows };
|
||
} else {
|
||
const wb = XLSX.readFile(filepath);
|
||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||
const rows = XLSX.utils.sheet_to_json(sheet);
|
||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||
return { columns, rows };
|
||
}
|
||
}
|
||
|
||
function calculateStats(rows, columns) {
|
||
const stats = {};
|
||
const numericCols = columns.filter(col => {
|
||
const sample = rows.slice(0, 10).map(r => r[col]);
|
||
return sample.some(v => !isNaN(parseFloat(v)) && isFinite(v));
|
||
});
|
||
|
||
numericCols.forEach(col => {
|
||
const values = rows.map(r => parseFloat(r[col])).filter(v => !isNaN(v));
|
||
if (values.length > 0) {
|
||
const sum = values.reduce((a, b) => a + b, 0);
|
||
const avg = sum / values.length;
|
||
const min = Math.min(...values);
|
||
const max = Math.max(...values);
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
const median = sorted.length % 2 === 0
|
||
? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
|
||
: sorted[Math.floor(sorted.length / 2)];
|
||
stats[col] = { sum, avg, min, max, median, count: values.length };
|
||
}
|
||
});
|
||
return stats;
|
||
}
|
||
|
||
function generateInsights(rows, columns, stats) {
|
||
const insights = [];
|
||
const totalRows = rows.length;
|
||
|
||
if (totalRows > 0) {
|
||
insights.push({ type: "info", text: `Всего записей: ${totalRows}`, category: "Общие данные" });
|
||
}
|
||
|
||
Object.keys(stats).forEach(col => {
|
||
const s = stats[col];
|
||
if (s.count > totalRows * 0.5) {
|
||
insights.push({
|
||
type: "metric",
|
||
text: `Среднее по "${col}": ${s.avg.toFixed(2)}`,
|
||
category: "Статистика",
|
||
value: s.avg
|
||
});
|
||
if (s.max > s.avg * 2) {
|
||
insights.push({
|
||
type: "warning",
|
||
text: `Выявлен аномальный максимум в "${col}": ${s.max} (в ${((s.max/s.avg).toFixed(1))} раз выше среднего)`,
|
||
category: "Аномалии"
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
const stringCols = columns.filter(col => !stats[col]);
|
||
stringCols.forEach(col => {
|
||
const values = rows.map(r => r[col]).filter(v => v);
|
||
const unique = [...new Set(values)];
|
||
if (unique.length < totalRows * 0.3 && unique.length > 1) {
|
||
const counts = {};
|
||
values.forEach(v => { counts[v] = (counts[v] || 0) + 1; });
|
||
const top = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
||
if (top.length > 0) {
|
||
insights.push({
|
||
type: "info",
|
||
text: `Наиболее частые значения в "${col}": ${top.map(([k, v]) => `${k} (${v})`).join(', ')}`,
|
||
category: "Категории"
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
if (insights.length === 0) {
|
||
insights.push({ type: "info", text: "Загрузите данные для получения аналитических выводов", category: "Рекомендации" });
|
||
}
|
||
|
||
return insights;
|
||
}
|
||
|
||
http.createServer(async (req, res) => {
|
||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||
|
||
if (req.method === "OPTIONS") {
|
||
res.writeHead(204);
|
||
return res.end();
|
||
}
|
||
|
||
if (req.method === "GET" && req.url === "/api/analysis") {
|
||
const analysis = readAnalysis();
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify(analysis || { columns: [], rows: [], stats: {}, insights: [] }));
|
||
}
|
||
|
||
if (req.method === "POST" && req.url === "/api/upload") {
|
||
const form = formidable({ uploadDir: DATA_DIR, keepExtensions: true });
|
||
form.parse(req, async (err, fields, files) => {
|
||
if (err) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ error: err.message }));
|
||
}
|
||
const file = files.file && files.file[0];
|
||
if (!file) {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ error: "Файл не загружен" }));
|
||
}
|
||
try {
|
||
const { columns, rows } = parseFile(file.filepath);
|
||
const stats = calculateStats(rows, columns);
|
||
const insights = generateInsights(rows, columns, stats);
|
||
const analysis = { columns, rows, stats, insights, filename: file.originalFilename };
|
||
writeAnalysis(analysis);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true, columns, rows: rows.slice(0, 100), stats, insights }));
|
||
} catch (e) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ error: e.message }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && req.url.startsWith("/api/data")) {
|
||
const analysis = readAnalysis();
|
||
if (!analysis) {
|
||
res.writeHead(404, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ error: "Данные не загружены" }));
|
||
}
|
||
const url = new URL(req.url, "http://localhost");
|
||
const filterCol = url.searchParams.get("filterColumn");
|
||
const filterVal = url.searchParams.get("filterValue");
|
||
const sortCol = url.searchParams.get("sortColumn");
|
||
const sortOrder = url.searchParams.get("sortOrder") || "asc";
|
||
const limit = parseInt(url.searchParams.get("limit")) || 100;
|
||
|
||
let filtered = [...analysis.rows];
|
||
if (filterCol && filterVal) {
|
||
filtered = filtered.filter(r => String(r[filterCol] || "").toLowerCase().includes(filterVal.toLowerCase()));
|
||
}
|
||
if (sortCol) {
|
||
filtered.sort((a, b) => {
|
||
const aVal = parseFloat(a[sortCol]) || a[sortCol];
|
||
const bVal = parseFloat(b[sortCol]) || b[sortCol];
|
||
if (aVal < bVal) return sortOrder === "asc" ? -1 : 1;
|
||
if (aVal > bVal) return sortOrder === "asc" ? 1 : -1;
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({
|
||
columns: analysis.columns,
|
||
rows: filtered.slice(0, limit),
|
||
total: filtered.length,
|
||
stats: analysis.stats,
|
||
insights: analysis.insights
|
||
}));
|
||
}
|
||
|
||
let file = req.url === "/" ? "/index.html" : req.url.split("?")[0];
|
||
const full = path.join(__dirname, file);
|
||
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
|
||
const ext = path.extname(full);
|
||
const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html";
|
||
res.writeHead(200, { "Content-Type": type + "; charset=utf-8" });
|
||
return res.end(fs.readFileSync(full));
|
||
}
|
||
|
||
res.writeHead(404);
|
||
res.end("Not found");
|
||
}).listen(PORT, () => console.log("Аналитическое приложение на порту " + PORT));
|