From 3f318c2691d951a287ffb057fdec441289eaa0a3 Mon Sep 17 00:00:00 2001 From: kalugin66 Date: Sun, 26 Jul 2026 20:50:19 +0500 Subject: [PATCH] =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/database/index.js | 20 ++++ modules/visitors/index.js | 145 ++++++++++++++++++++++++++ package.json | 1 + public/admin.html | 211 +++++++++++++++++++++++++++++++++++++- server.js | 64 ++++++++++++ 5 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 modules/visitors/index.js diff --git a/modules/database/index.js b/modules/database/index.js index 1749061..f1539b2 100644 --- a/modules/database/index.js +++ b/modules/database/index.js @@ -331,6 +331,26 @@ function initDatabase(db) { is_active INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, () => { + setupVisitorLogTable(); + }); + } + + function setupVisitorLogTable() { + db.run(`CREATE TABLE IF NOT EXISTS visitor_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + session_id TEXT, + user_agent TEXT, + accept_language TEXT, + country TEXT, + country_code TEXT, + page_path TEXT, + visit_date DATE DEFAULT (date('now')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, () => { + db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_session ON visitor_log(session_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_date ON visitor_log(visit_date)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_country ON visitor_log(country_code)`); setupIndices(); }); } diff --git a/modules/visitors/index.js b/modules/visitors/index.js new file mode 100644 index 0000000..5e6a813 --- /dev/null +++ b/modules/visitors/index.js @@ -0,0 +1,145 @@ +let db; + +function init(database) { + db = database; +} + +function getStats(req, res) { + db.get(`SELECT COUNT(DISTINCT session_id) as total FROM visitor_log`, [], (err, totalRow) => { + if (err) return res.status(500).json({ error: err.message }); + db.get(`SELECT COUNT(DISTINCT session_id) as today FROM visitor_log WHERE visit_date = date('now')`, [], (err, todayRow) => { + if (err) return res.status(500).json({ error: err.message }); + db.get(`SELECT COUNT(DISTINCT session_id) as week FROM visitor_log WHERE visit_date >= date('now', '-6 days')`, [], (err, weekRow) => { + if (err) return res.status(500).json({ error: err.message }); + db.get(`SELECT COUNT(DISTINCT session_id) as month FROM visitor_log WHERE visit_date >= date('now', 'start of month')`, [], (err, monthRow) => { + if (err) return res.status(500).json({ error: err.message }); + db.get(`SELECT COUNT(*) as page_views FROM visitor_log`, [], (err, pvRow) => { + if (err) return res.status(500).json({ error: err.message }); + db.get(`SELECT COUNT(DISTINCT ip) as unique_ips FROM visitor_log`, [], (err, ipRow) => { + if (err) return res.status(500).json({ error: err.message }); + res.json({ + total: totalRow ? totalRow.total : 0, + today: todayRow ? todayRow.today : 0, + week: weekRow ? weekRow.week : 0, + month: monthRow ? monthRow.month : 0, + pageViews: pvRow ? pvRow.page_views : 0, + uniqueIps: ipRow ? ipRow.unique_ips : 0 + }); + }); + }); + }); + }); + }); + }); +} + +function getCountries(req, res) { + db.all(`SELECT COALESCE(NULLIF(country, ''), 'Другое') as country, + COALESCE(NULLIF(country_code, ''), '--') as country_code, + COUNT(DISTINCT session_id) as count + FROM visitor_log + GROUP BY COALESCE(NULLIF(country_code, ''), '--') + ORDER BY count DESC LIMIT 30`, [], (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(rows || []); + }); +} + +function getLanguages(req, res) { + db.all(`SELECT accept_language, COUNT(DISTINCT session_id) as count + FROM visitor_log + WHERE accept_language IS NOT NULL AND accept_language != '' + GROUP BY accept_language + ORDER BY count DESC`, [], (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + const parsed = (rows || []).map(r => { + const primary = (r.accept_language || '').split(',')[0].split(';')[0].trim(); + const lang = primary.split('-')[0]; + return { lang, raw: primary, count: r.count }; + }); + res.json(parsed); + }); +} + +function getDaily(req, res) { + db.all(`SELECT visit_date, COUNT(DISTINCT session_id) as count, COUNT(*) as page_views + FROM visitor_log + WHERE visit_date >= date('now', '-29 days') + GROUP BY visit_date + ORDER BY visit_date ASC`, [], (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(rows || []); + }); +} + +function getIPs(req, res) { + const limit = Math.min(parseInt(req.query.limit) || 50, 200); + const offset = parseInt(req.query.offset) || 0; + const { id, ip, country, lang, date_from, date_to, page } = req.query; + + const whereClauses = []; + const params = []; + + if (id) { whereClauses.push('id = ?'); params.push(parseInt(id)); } + if (ip) { whereClauses.push('ip LIKE ?'); params.push('%' + ip + '%'); } + if (country) { whereClauses.push('(country LIKE ? OR country_code LIKE ?)'); params.push('%' + country + '%'); params.push('%' + country + '%'); } + if (lang) { whereClauses.push('accept_language LIKE ?'); params.push('%' + lang + '%'); } + if (date_from) { whereClauses.push('visit_date >= ?'); params.push(date_from); } + if (date_to) { whereClauses.push('visit_date <= ?'); params.push(date_to); } + if (page) { whereClauses.push('page_path LIKE ?'); params.push('%' + page + '%'); } + + const whereSQL = whereClauses.length > 0 ? 'WHERE ' + whereClauses.join(' AND ') : ''; + + db.get(`SELECT COUNT(*) as total FROM visitor_log ${whereSQL}`, params, (err, countRow) => { + if (err) return res.status(500).json({ error: err.message }); + + const allParams = [...params, limit, offset]; + db.all(`SELECT id, ip, COALESCE(NULLIF(country, ''), 'Другое') as country, + country_code, session_id, visit_date, page_path, + COALESCE(NULLIF(accept_language, ''), '-') as accept_language, + created_at + FROM visitor_log + ${whereSQL} + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, allParams, (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + res.json({ + entries: rows || [], + total: countRow ? countRow.total : 0, + limit, + offset + }); + }); + }); +} + +function getFilterOptions(req, res) { + db.all(`SELECT DISTINCT COALESCE(NULLIF(country, ''), 'Другое') as country + FROM visitor_log ORDER BY country`, [], (err, countries) => { + if (err) return res.status(500).json({ error: err.message }); + db.all(`SELECT DISTINCT accept_language FROM visitor_log + WHERE accept_language IS NOT NULL AND accept_language != '' + ORDER BY accept_language`, [], (err, langs) => { + if (err) return res.status(500).json({ error: err.message }); + db.all(`SELECT DISTINCT page_path FROM visitor_log ORDER BY page_path`, [], (err, pages) => { + if (err) return res.status(500).json({ error: err.message }); + res.json({ + countries: (countries || []).map(c => c.country), + languages: (langs || []).map(l => l.accept_language), + pages: (pages || []).map(p => p.page_path) + }); + }); + }); + }); +} + +function setupRoutes(app, authenticateToken) { + app.get('/api/admin/visitors/stats', authenticateToken, getStats); + app.get('/api/admin/visitors/countries', authenticateToken, getCountries); + app.get('/api/admin/visitors/languages', authenticateToken, getLanguages); + app.get('/api/admin/visitors/daily', authenticateToken, getDaily); + app.get('/api/admin/visitors/ips', authenticateToken, getIPs); + app.get('/api/admin/visitors/filters', authenticateToken, getFilterOptions); +} + +module.exports = { init, setupRoutes }; diff --git a/package.json b/package.json index 3ba4ecb..330f400 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", "express": "^5.2.1", + "geoip-lite": "^2.0.3", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", "node-cron": "^4.2.1", diff --git a/public/admin.html b/public/admin.html index bca3041..08e8416 100644 --- a/public/admin.html +++ b/public/admin.html @@ -208,6 +208,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid # Сезонные цены Развлечения Главный экран + Посетители Отзывы Настройки Профиль @@ -724,7 +725,6 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid # -

Главный экран

@@ -736,6 +736,85 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
+ +
+
+

Посетители

+
+
+
+
+
+
Всего посетителей
+
-
+
+
+
Сегодня
+
-
+
+
+
За 7 дней
+
-
+
+
+
За месяц
+
-
+
+
+
Просмотров страниц
+
-
+
+
+
Уникальных IP
+
-
+
+
+
+
+ +
+
+
+

По странам

+
+
+
+
+
+

Языки браузеров

+
+
+
+
+ +
+
+

Посещения за 30 дней

+
+ +
+
+
+ +
+
+

IP-адреса и даты

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @@ -911,6 +990,7 @@ function initTabs() { if (tab === 'seasonal') loadSeasonalPrices(); if (tab === 'activities') loadActivitiesAdmin(); if (tab === 'hero') loadHeroMediaAdmin(); + if (tab === 'visitors') loadVisitors(); if (tab === 'reviews') loadReviews(); if (tab === 'settings') loadSettings(); if (tab === 'profile') loadProfile(); @@ -2976,6 +3056,135 @@ function deleteHeroMedia(id) { .then(() => { loadHeroMediaAdmin(); showToast('Медиа удалено'); }) .catch(err => showToast(err.message, 'error')); } + +async function loadVisitors() { + try { + const stats = await api('/api/admin/visitors/stats'); + document.getElementById('visTotal').textContent = stats.total; + document.getElementById('visToday').textContent = stats.today; + document.getElementById('visWeek').textContent = stats.week; + document.getElementById('visMonth').textContent = stats.month; + document.getElementById('visPageViews').textContent = stats.pageViews; + document.getElementById('visUniqueIps').textContent = stats.uniqueIps; + + const countries = await api('/api/admin/visitors/countries'); + const ct = document.getElementById('visCountriesTable'); + if (countries.length === 0) { + ct.innerHTML = '
Нет данных
'; + } else { + ct.innerHTML = '' + + countries.map(c => ``).join('') + + '
��трана��осетителей
${c.country || '—'} ${c.country_code || ''}${c.count}
'; + } + + const langs = await api('/api/admin/visitors/languages'); + const lt = document.getElementById('visLanguagesTable'); + if (langs.length === 0) { + lt.innerHTML = '
Нет данных
'; + } else { + lt.innerHTML = '' + + langs.map(l => ``).join('') + + '
ЯзыкПосетителей
${l.raw || l.lang || '—'}${l.count}
'; + } + + const daily = await api('/api/admin/visitors/daily'); + if (daily.length > 0) { + let html = ''; + daily.reverse().forEach(d => { + html += ``; + }); + html += '
ДатаПосетителейПросмотров
${d.visit_date}${d.count}${d.page_views}
'; + document.getElementById('visDailyChart').innerHTML = html; + } + + await loadFilterOptions(); + loadIPs(0); + } catch (err) { + showToast(err.message, 'error'); + } +} + +function fillSelect(id, items) { + const sel = document.getElementById(id); + if (!sel) return; + const val = sel.value; + sel.innerHTML = ''; + items.forEach(item => { + const opt = document.createElement('option'); + opt.value = item; + opt.textContent = item; + sel.appendChild(opt); + }); + if (val) sel.value = val; +} + +async function loadFilterOptions() { + try { + const data = await api('/api/admin/visitors/filters'); + fillSelect('visFiltCountry', data.countries || []); + fillSelect('visFiltLang', data.languages || []); + fillSelect('visFiltPage', data.pages || []); + } catch (e) { + // silently fail, filters stay as "Все" + } +} + +async function loadIPs(offset) { + const limit = 50; + document.getElementById('visIPsTable').innerHTML = '
Загрузка...
'; + try { + const filters = { + id: document.getElementById('visFiltId').value.trim(), + ip: document.getElementById('visFiltIp').value.trim(), + country: document.getElementById('visFiltCountry').value.trim(), + lang: document.getElementById('visFiltLang').value.trim(), + date_from: document.getElementById('visFiltDateFrom').value, + date_to: document.getElementById('visFiltDateTo').value, + page: document.getElementById('visFiltPage').value.trim() + }; + let qs = `limit=${limit}&offset=${offset}`; + Object.keys(filters).forEach(k => { + if (filters[k]) qs += '&' + k + '=' + encodeURIComponent(filters[k]); + }); + const data = await api('/api/admin/visitors/ips?' + qs); + if (data.entries.length === 0) { + document.getElementById('visIPsTable').innerHTML = '
Нет данных
'; + document.getElementById('visIPsPagination').innerHTML = ''; + return; + } + let html = '' + + '' + + '' + + '' + + '' + + '' + + ''; + data.entries.forEach(e => { + html += `` + + `` + + `` + + `` + + `` + + `` + + ``; + }); + html += '
IPСтранаЯзыкДатаСтраница
${e.ip}${e.country || '-'} ${e.country_code || ''}${e.accept_language || '-'}${e.created_at ? e.created_at.substring(0, 16) : e.visit_date}${e.page_path}
'; + document.getElementById('visIPsTable').innerHTML = html; + + const totalPages = Math.ceil(data.total / limit); + const currentPage = Math.floor(offset / limit) + 1; + let paginationHtml = `Строк: ${data.total} · Стр. ${currentPage} из ${totalPages} `; + if (offset > 0) { + paginationHtml += ``; + } + if (offset + limit < data.total) { + paginationHtml += ``; + } + document.getElementById('visIPsPagination').innerHTML = paginationHtml; + } catch (err) { + document.getElementById('visIPsTable').innerHTML = '
Ошибка загрузки
'; + } +} diff --git a/server.js b/server.js index 30b9114..30b139c 100644 --- a/server.js +++ b/server.js @@ -188,6 +188,66 @@ app.use((req, res, next) => { if (req.method === 'OPTIONS') return res.sendStatus(200); next(); }); + +const geoip = require('geoip-lite'); +const ipCountryCache = {}; + +function getCountryByIp(ip) { + if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('::ffff:127.')) return null; + if (ipCountryCache[ip] !== undefined) return ipCountryCache[ip]; + try { + const geo = geoip.lookup(ip); + const result = geo ? { country: geo.country, code: geo.country } : null; + ipCountryCache[ip] = result; + return result; + } catch (e) { + ipCountryCache[ip] = null; + return null; + } +} + +function parseCookies(header) { + const cookies = {}; + if (!header) return cookies; + header.split(';').forEach(c => { + const idx = c.indexOf('='); + if (idx > 0) cookies[c.substring(0, idx).trim()] = c.substring(idx + 1).trim(); + }); + return cookies; +} + +app.use((req, res, next) => { + if (req.method !== 'GET' || req.path.startsWith('/api/') || + req.path.startsWith('/css/') || req.path.startsWith('/js/') || + req.path.startsWith('/img/') || req.path.startsWith('/data/') || + req.path.startsWith('/uploads/') || req.path.startsWith('/webfonts/') || + req.path === '/metrics' || req.path === '/favicon.ico') { + return next(); + } + + const ip = req.ip || req.connection.remoteAddress || ''; + const cookies = parseCookies(req.headers.cookie); + let sessionId = cookies.visitor_sid; + + if (!sessionId) { + sessionId = require('crypto').randomUUID(); + res.setHeader('Set-Cookie', `visitor_sid=${sessionId}; Path=/; Max-Age=86400; SameSite=Lax`); + } + + const geo = getCountryByIp(ip); + + db.run( + `INSERT INTO visitor_log (ip, session_id, user_agent, accept_language, country, country_code, page_path, visit_date) + VALUES (?, ?, ?, ?, ?, ?, ?, date('now'))`, + [ip, sessionId, (req.headers['user-agent'] || '').substring(0, 500), + (req.headers['accept-language'] || '').substring(0, 200), + geo ? geo.country : null, geo ? geo.code : null, req.path], + (err) => { if (err) console.error('Visitor log error:', err.message); } + ); + + next(); +}); + app.use(express.static(path.join(__dirname, 'public'))); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use('/data/room_images', express.static(roomsUploadsDir)); @@ -272,6 +332,7 @@ const syncModule = require('./modules/sync'); const reportsModule = require('./modules/reports'); const activitiesModule = require('./modules/activities'); const heroModule = require('./modules/hero'); +const visitorsModule = require('./modules/visitors'); const { runStartupTests } = require('./tests/runStartupTests'); modules.auth = authModule; @@ -291,6 +352,7 @@ modules.sync = syncModule; modules.reports = reportsModule; modules.activities = activitiesModule; modules.hero = heroModule; +modules.visitors = visitorsModule; authModule.init(db, JWT_SECRET); bookingsModule.init(db); @@ -308,6 +370,7 @@ syncModule.init(db, settingsModule, emailModule, telegramModule); reportsModule.init(db); activitiesModule.init(db); heroModule.init(db); +visitorsModule.init(db); function initDefaultRooms() { db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => { @@ -382,6 +445,7 @@ telegramModule.setupRoutes(app, authModule.authenticateToken, authModule.require reportsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); activitiesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadActivity); heroModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadHeroImage, uploadHeroVideo); +visitorsModule.setupRoutes(app, authModule.authenticateToken); app.get('/api/translations/:lang', (req, res) => { const lang = req.params.lang;