modified: main_dc/docker-compose.yml
modified: main_dc/nginx/nginx-ssl.conf new file: main_dc/valitovgaziz/analytics/Dockerfile new file: main_dc/valitovgaziz/analytics/package.json new file: main_dc/valitovgaziz/analytics/server.js new file: main_dc/valitovgaziz/html/JavaScript/analytics.js modified: main_dc/valitovgaziz/html/index.html add nginx settings for api logs for valitovgaziz.ru site, add container for metrica container, add metrica scripts on site valitovgaziz.ru
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
services:
|
||||
|
||||
certbot:
|
||||
build:
|
||||
context: ./certbot
|
||||
@@ -33,6 +34,7 @@ services:
|
||||
- ./yalarba/serv_spa/spa/vue/dist:/usr/share/nginx/yalarba/html
|
||||
- ./valitovgaziz/html:/usr/share/nginx/valitovgaziz/html
|
||||
- ./BB/bbvue/dist:/usr/share/nginx/begushiybashkir/html
|
||||
- analytics_logs:/var/log/analytics:ro
|
||||
networks:
|
||||
- web-network
|
||||
- internal
|
||||
@@ -42,6 +44,33 @@ services:
|
||||
- certbot
|
||||
- api
|
||||
- api_bb
|
||||
- analytics
|
||||
|
||||
analytics:
|
||||
build:
|
||||
context: ./analytics
|
||||
dockerfile: Dockerfile
|
||||
container_name: analytics
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9999:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- LOG_LEVEL=info
|
||||
- LOG_RETENTION_DAYS=30
|
||||
volumes:
|
||||
- analytics_logs:/app/logs
|
||||
- analytics_data:/app/data
|
||||
networks:
|
||||
- web-network
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
api:
|
||||
build:
|
||||
@@ -197,7 +226,15 @@ services:
|
||||
- app-network
|
||||
- web-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8081/health"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:8081/health",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -208,6 +245,8 @@ volumes:
|
||||
db_tp_data:
|
||||
db_bb_data:
|
||||
api_bb_uploads:
|
||||
analytics_logs: # Volume для логов аналитики
|
||||
analytics_data: # Volume для данных аналитики
|
||||
|
||||
networks:
|
||||
web-network:
|
||||
|
||||
@@ -76,6 +76,23 @@ server {
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/analytics {
|
||||
proxy_pass http://analytics:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# CORS headers
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type" always;
|
||||
|
||||
if ($request_method = OPTIONS) {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем package.json и устанавливаем зависимости
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
|
||||
# Создаем директории для логов
|
||||
RUN mkdir -p /app/logs /app/data
|
||||
|
||||
# Создаем непривилегированного пользователя
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S analytics -u 1001
|
||||
|
||||
# Меняем владельца файлов
|
||||
RUN chown -R analytics:nodejs /app
|
||||
|
||||
USER analytics
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "analytics-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Custom analytics server for tracking website events",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.0.0",
|
||||
"morgan": "^1.10.0",
|
||||
"compression": "^1.7.4",
|
||||
"rate-limiter-flexible": "^3.0.8",
|
||||
"winston": "^3.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
},
|
||||
"keywords": [
|
||||
"analytics",
|
||||
"tracking",
|
||||
"express"
|
||||
],
|
||||
"author": "Valitov Gaziz",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
const morgan = require('morgan');
|
||||
const { RateLimiterMemory } = require('rate-limiter-flexible');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Настройка лимитера запросов
|
||||
const rateLimiter = new RateLimiterMemory({
|
||||
keyGenerator: (req) => req.ip,
|
||||
points: 100, // 100 запросов
|
||||
duration: 60, // за 60 секунд
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" }
|
||||
}));
|
||||
app.use(compression());
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Логирование запросов
|
||||
const accessLogStream = require('fs').createWriteStream(
|
||||
path.join(__dirname, 'logs', 'access.log'),
|
||||
{ flags: 'a' }
|
||||
);
|
||||
app.use(morgan('combined', { stream: accessLogStream }));
|
||||
|
||||
// Winston логгер для приложения
|
||||
const winston = require('winston');
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, 'logs', 'error.log'),
|
||||
level: 'error'
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, 'logs', 'combined.log')
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.simple()
|
||||
}));
|
||||
}
|
||||
|
||||
// Middleware для ограничения запросов
|
||||
app.use(async (req, res, next) => {
|
||||
try {
|
||||
await rateLimiter.consume(req.ip);
|
||||
next();
|
||||
} catch (rejRes) {
|
||||
res.status(429).json({
|
||||
error: 'Too Many Requests',
|
||||
retryAfter: Math.ceil(rejRes.msBeforeNext / 1000)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Функция для записи аналитики в файл
|
||||
async function writeAnalytics(data) {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const logFile = path.join(__dirname, 'data', `analytics-${timestamp}.json`);
|
||||
|
||||
const logEntry = {
|
||||
receivedAt: new Date().toISOString(),
|
||||
...data
|
||||
};
|
||||
|
||||
// Читаем существующие данные
|
||||
let existingData = [];
|
||||
try {
|
||||
const fileContent = await fs.readFile(logFile, 'utf8');
|
||||
existingData = JSON.parse(fileContent);
|
||||
} catch (error) {
|
||||
// Файл не существует или пустой - это нормально
|
||||
}
|
||||
|
||||
// Добавляем новую запись
|
||||
existingData.push(logEntry);
|
||||
|
||||
// Записываем обратно
|
||||
await fs.writeFile(logFile, JSON.stringify(existingData, null, 2));
|
||||
|
||||
logger.info('Analytics event saved', {
|
||||
eventsCount: data.events?.length || 1,
|
||||
sessionId: data.sessionId
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error writing analytics data', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Роуты
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'analytics-server'
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/analytics', async (req, res) => {
|
||||
try {
|
||||
const { events, sessionId } = req.body;
|
||||
|
||||
if (!events || !Array.isArray(events)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid data format: events array required'
|
||||
});
|
||||
}
|
||||
|
||||
await writeAnalytics({ events, sessionId });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
received: events.length,
|
||||
sessionId
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Analytics processing error', { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Статистика (только для разработки)
|
||||
app.get('/api/stats', async (req, res) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
try {
|
||||
const dataDir = path.join(__dirname, 'data');
|
||||
const files = await fs.readdir(dataDir);
|
||||
const analyticsFiles = files.filter(f => f.startsWith('analytics-'));
|
||||
|
||||
let totalEvents = 0;
|
||||
const sessions = new Set();
|
||||
|
||||
for (const file of analyticsFiles.slice(-7)) { // Последние 7 дней
|
||||
const content = await fs.readFile(path.join(dataDir, file), 'utf8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
data.forEach(entry => {
|
||||
totalEvents += entry.events?.length || 0;
|
||||
if (entry.sessionId) sessions.add(entry.sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
totalEvents,
|
||||
uniqueSessions: sessions.size,
|
||||
filesCount: analyticsFiles.length
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обработка 404
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Route not found' });
|
||||
});
|
||||
|
||||
// Обработка ошибок
|
||||
app.use((error, req, res, next) => {
|
||||
logger.error('Unhandled error', { error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
logger.info(`Analytics server running on port ${PORT}`);
|
||||
console.log(`🔍 Analytics server: http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
// analytics.js - собственный счетчик аналитики для браузера
|
||||
class CustomAnalytics {
|
||||
constructor() {
|
||||
this.endpoint = 'https://valitovgaziz.ru/api/analytics'; // Ваш endpoint для сбора данных
|
||||
this.queue = [];
|
||||
this.isOnline = navigator.onLine;
|
||||
this.sessionId = this.getSessionId();
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Загружаем сохраненные данные из localStorage
|
||||
this.loadFromStorage();
|
||||
|
||||
// Отслеживание событий
|
||||
this.trackPageView();
|
||||
this.setupEventListeners();
|
||||
|
||||
// Периодическая отправка данных
|
||||
setInterval(() => this.flushQueue(), 30000);
|
||||
|
||||
// Отслеживание онлайн/офлайн статуса
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true;
|
||||
this.flushQueue();
|
||||
});
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
});
|
||||
|
||||
// Отправка данных перед закрытием страницы
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.trackEvent('page', 'unload');
|
||||
this.flushQueueSync();
|
||||
});
|
||||
}
|
||||
|
||||
trackPageView() {
|
||||
const data = {
|
||||
type: 'pageview',
|
||||
url: window.location.href,
|
||||
referrer: document.referrer,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
screen: `${screen.width}x${screen.height}`,
|
||||
language: navigator.language,
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.addToQueue(data);
|
||||
}
|
||||
|
||||
trackEvent(category, action, label = null, value = null) {
|
||||
const data = {
|
||||
type: 'event',
|
||||
category,
|
||||
action,
|
||||
label,
|
||||
value,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.addToQueue(data);
|
||||
}
|
||||
|
||||
trackClick(element, context = 'unknown') {
|
||||
const data = {
|
||||
type: 'click',
|
||||
element: element.tagName,
|
||||
text: element.textContent?.substring(0, 100),
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.addToQueue(data);
|
||||
}
|
||||
|
||||
addToQueue(data) {
|
||||
this.queue.push(data);
|
||||
|
||||
// Сохраняем в localStorage
|
||||
this.saveToStorage();
|
||||
|
||||
// Отправляем сразу если онлайн и очередь большая
|
||||
if (this.isOnline && this.queue.length >= 3) {
|
||||
this.flushQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async flushQueue() {
|
||||
if (!this.isOnline || this.queue.length === 0) return;
|
||||
|
||||
const batch = [...this.queue];
|
||||
|
||||
try {
|
||||
const response = await fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
events: batch,
|
||||
sessionId: this.sessionId
|
||||
}),
|
||||
keepalive: true // Позволяет отправлять данные даже при закрытии страницы
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Удаляем отправленные данные из очереди
|
||||
this.queue = this.queue.filter(item => !batch.includes(item));
|
||||
this.saveToStorage();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Analytics offline, storing locally');
|
||||
}
|
||||
}
|
||||
|
||||
flushQueueSync() {
|
||||
if (this.queue.length === 0) return;
|
||||
|
||||
// Используем sendBeacon для надежной отправки при закрытии страницы
|
||||
const data = JSON.stringify({
|
||||
events: this.queue,
|
||||
sessionId: this.sessionId
|
||||
});
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(this.endpoint, data);
|
||||
}
|
||||
}
|
||||
|
||||
getSessionId() {
|
||||
let sessionId = localStorage.getItem('ga_session_id');
|
||||
const now = Date.now();
|
||||
|
||||
if (!sessionId) {
|
||||
sessionId = 'sess_' + now + '_' + Math.random().toString(36).substr(2, 9);
|
||||
localStorage.setItem('ga_session_id', sessionId);
|
||||
localStorage.setItem('ga_session_start', now);
|
||||
}
|
||||
|
||||
// Обновляем время последней активности
|
||||
localStorage.setItem('ga_last_activity', now);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem('ga_queue', JSON.stringify(this.queue));
|
||||
} catch (e) {
|
||||
console.warn('Cannot save analytics to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem('ga_queue');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
this.queue = parsed;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Cannot load analytics from localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Отслеживание кликов по кнопкам
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('button, .btn, a[href]')) {
|
||||
const context = e.target.closest('.section') ?
|
||||
e.target.closest('.section').querySelector('h2')?.textContent || 'unknown' :
|
||||
'global';
|
||||
this.trackClick(e.target, context);
|
||||
|
||||
// Специальные события для кнопок сотрудничества
|
||||
if (e.target.textContent.includes('сотрудничество') || e.target.textContent.includes('Написать')) {
|
||||
this.trackEvent('conversion', 'contact_click', e.target.textContent.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Отслеживание отправки форм
|
||||
document.addEventListener('submit', (e) => {
|
||||
this.trackEvent('form', 'submit', e.target.id || 'unknown');
|
||||
});
|
||||
|
||||
// Отслеживание видимости секций
|
||||
this.setupSectionTracking();
|
||||
|
||||
// Отслеживание внешних ссылок
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a[href]');
|
||||
if (link && link.hostname !== window.location.hostname) {
|
||||
this.trackEvent('outbound', 'click', link.href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupSectionTracking() {
|
||||
const sections = document.querySelectorAll('.section');
|
||||
const observedSections = new Set();
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
||||
const sectionId = entry.target.id ||
|
||||
entry.target.querySelector('h2')?.textContent?.substring(0, 50) ||
|
||||
'unknown_section';
|
||||
|
||||
if (!observedSections.has(sectionId)) {
|
||||
observedSections.add(sectionId);
|
||||
this.trackEvent('content', 'section_view', sectionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: [0.5],
|
||||
rootMargin: '0px 0px -10% 0px'
|
||||
});
|
||||
|
||||
sections.forEach(section => {
|
||||
observer.observe(section);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация при полной загрузке DOM
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.analytics = new CustomAnalytics();
|
||||
|
||||
// Глобальные функции для ручного отслеживания
|
||||
window.trackEvent = (category, action, label, value) => {
|
||||
if (window.analytics) {
|
||||
window.analytics.trackEvent(category, action, label, value);
|
||||
}
|
||||
};
|
||||
|
||||
// Отслеживание специальных событий для вашего сайта
|
||||
const specialButtons = document.querySelectorAll('[onclick*="sendMessageTelegram"]');
|
||||
specialButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
trackEvent('business', 'telegram_click', btn.textContent.trim());
|
||||
});
|
||||
});
|
||||
|
||||
// Отслеживание просмотра ключевых элементов
|
||||
const keyElements = document.querySelectorAll('.hero, .team-section, .yalarba-section');
|
||||
const elementObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const elementType = entry.target.className.split(' ')[0];
|
||||
trackEvent('engagement', `${elementType}_viewed`);
|
||||
elementObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
keyElements.forEach(el => elementObserver.observe(el));
|
||||
});
|
||||
|
||||
// Fallback для старых браузеров
|
||||
if (!window.Promise) {
|
||||
console.warn('Custom analytics requires Promise support');
|
||||
window.trackEvent = function () { };
|
||||
}
|
||||
@@ -15,53 +15,10 @@
|
||||
<script src="scripts.js"></script>
|
||||
<script src="darkThemeToggle.js"></script>
|
||||
<script src="digital_background.js"></script>
|
||||
<script src="JavaScript/analytics.js"></script>
|
||||
<title>ValitovGaziz - Предприниматель - Fullstack-разработчик</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Yandex.Metrika counter -->
|
||||
<script type="text/javascript">
|
||||
(function (m, e, t, r, i, k, a) {
|
||||
m[i] =
|
||||
m[i] ||
|
||||
function () {
|
||||
(m[i].a = m[i].a || []).push(arguments);
|
||||
};
|
||||
m[i].l = 1 * new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) {
|
||||
if (document.scripts[j].src === r) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
(k = e.createElement(t)),
|
||||
(a = e.getElementsByTagName(t)[0]),
|
||||
(k.async = 1),
|
||||
(k.src = r),
|
||||
a.parentNode.insertBefore(k, a);
|
||||
})(
|
||||
window,
|
||||
document,
|
||||
"script",
|
||||
"https://mc.yandex.ru/metrika/tag.js",
|
||||
"ym"
|
||||
);
|
||||
|
||||
ym(103321468, "init", {
|
||||
clickmap: true,
|
||||
trackLinks: true,
|
||||
accurateTrackBounce: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<noscript
|
||||
><div>
|
||||
<img
|
||||
src="https://mc.yandex.ru/watch/103321468"
|
||||
style="position: absolute; left: -9999px"
|
||||
alt=""
|
||||
/></div
|
||||
></noscript>
|
||||
<!-- /Yandex.Metrika counter -->
|
||||
|
||||
<header class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
|
||||
Reference in New Issue
Block a user