From ad4d215f04fd664a3dde3c20ea929cf7b2383480 Mon Sep 17 00:00:00 2001 From: MrAkells Date: Wed, 18 Jun 2025 21:22:55 +0300 Subject: [PATCH] first commit --- README.md | 211 +++++++++++++++++++ cli_tools.py | 159 ++++++++++++++ config.py | 130 ++++++++++++ deploy.sh | 406 ++++++++++++++++++++++++++++++++++++ main.py | 273 ++++++++++++++++++++++++ modules/__init__.py | 4 + modules/admin.py | 362 ++++++++++++++++++++++++++++++++ modules/feed_generator.py | 210 +++++++++++++++++++ modules/image_downloader.py | 126 +++++++++++ modules/parser.py | 402 +++++++++++++++++++++++++++++++++++ modules/storage.py | 272 ++++++++++++++++++++++++ modules/translator.py | 154 ++++++++++++++ monitor_health.py | 333 +++++++++++++++++++++++++++++ nginx.conf | 101 +++++++++ requirements.txt | 23 ++ run_tests.py | 27 +++ setup.py | 59 ++++++ test_parser.py | 160 ++++++++++++++ utils/db_manager.py | 93 +++++++++ utils/feed_validator.py | 131 ++++++++++++ utils/image_optimizer.py | 66 ++++++ utils/monitor.py | 60 ++++++ 22 files changed, 3762 insertions(+) create mode 100644 README.md create mode 100644 cli_tools.py create mode 100644 config.py create mode 100644 deploy.sh create mode 100644 main.py create mode 100644 modules/__init__.py create mode 100644 modules/admin.py create mode 100644 modules/feed_generator.py create mode 100644 modules/image_downloader.py create mode 100644 modules/parser.py create mode 100644 modules/storage.py create mode 100644 modules/translator.py create mode 100644 monitor_health.py create mode 100644 nginx.conf create mode 100644 requirements.txt create mode 100644 run_tests.py create mode 100644 setup.py create mode 100644 test_parser.py create mode 100644 utils/db_manager.py create mode 100644 utils/feed_validator.py create mode 100644 utils/image_optimizer.py create mode 100644 utils/monitor.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8d2602 --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# 🔍 Morele.net Parser + +Профессиональный парсер для автоматического сбора товаров с сайта morele.net с переводом на украинский язык и генерацией фида для Prom.ua. + +## 🚀 Возможности + +- **Автоматический парсинг товаров** из указанных категорий +- **Перевод на украинский язык** с кешированием +- **Загрузка и оптимизация изображений** +- **Генерация YML фида** для Prom.ua +- **Простая веб-админка** для управления +- **Модульная архитектура** для лёгкого расширения +- **Поддержка нескольких переводчиков** (Google Translate, DeepL, LibreTranslate) +- **Уведомления в Telegram** + +## 📋 Требования + +- Python 3.8+ +- SQLite (по умолчанию) или MySQL +- Доступ к интернету +- API ключи для переводчиков (опционально) + +## 🛠 Установка + +1. **Клонируйте проект или создайте структуру папок:** + +``` +morele_parser/ +├── main.py +├── config.py +├── requirements.txt +├── config.yaml (создается автоматически) +├── modules/ +│ ├── __init__.py +│ ├── parser.py +│ ├── translator.py +│ ├── image_downloader.py +│ ├── feed_generator.py +│ ├── storage.py +│ └── admin.py +├── data/ +├── images/ +├── feeds/ +└── logs/ +``` + +2. **Установите зависимости:** + +```bash +pip install -r requirements.txt +``` + +3. **Создайте файл конфигурации:** + +При первом запуске файл `config.yaml` создастся автоматически с настройками по умолчанию. + +## ⚙️ Настройка + +Отредактируйте файл `config.yaml`: + +```yaml +# Основные настройки перевода +translation: + service: 'google' # google, deepl, libretranslate + google: + api_key: '' # Можно оставить пустым для бесплатной версии + +# Настройки фида +feed: + shop_name: 'Ваш магазин' + company: 'Ваша компания' + images_base_url: 'https://yoursite.com/' # URL для изображений + margin_percent: 10 # Наценка в процентах + +# Уведомления в Telegram (опционально) +telegram: + enabled: true + bot_token: 'YOUR_BOT_TOKEN' + chat_id: 'YOUR_CHAT_ID' +``` + +## 🎯 Использование + +### Запуск веб-админки + +```bash +python main.py --admin +``` + +Откройте http://127.0.0.1:5000 в браузере. + +### Добавление категорий + +1. Откройте админ-панель +2. Перейдите в раздел "Категории" +3. Добавьте URL категорий с morele.net + +Пример URL категории: +``` +https://www.morele.net/kategoria/laptopy-421/ +``` + +### Запуск парсинга + +```bash +# Разовый запуск +python main.py --parse + +# Генерация только фида +python main.py --generate-feed +``` + +### Настройка автоматического запуска (cron) + +Добавьте в crontab для ежедневного запуска в 02:00: + +```bash +0 2 * * * /usr/bin/python3 /path/to/morele_parser/main.py --parse +``` + +## 📊 Структура данных + +Парсер собирает для каждого товара: + +- Название (оригинал + перевод) +- Цену в PLN +- Описание (оригинал + перевод) +- Характеристики (оригинал + перевод) +- Категорию +- Наличие +- Изображения (скачивает локально) +- Бренд, модель, артикул +- Ссылку на товар + +## 🔄 YML фид для Prom.ua + +Парсер автоматически генерирует YML фид с: + +- Переведёнными названиями и описаниями +- Ценами в UAH (с актуальным курсом + наценкой) +- Локальными изображениями +- Всеми характеристиками +- Правильными категориями + +Файл фида: `feeds/prom_feed.yml` + +## 🔧 Расширение функциональности + +### Добавление нового переводчика + +1. Создайте класс, наследующий от `TranslationProvider` +2. Реализуйте метод `translate()` +3. Добавьте в `TranslationService._init_provider()` + +### Добавление новых полей товара + +1. Обновите `MoreleParser._parse_product_page()` +2. Добавьте поля в схему БД в `StorageManager._init_sqlite()` +3. Обновите `FeedGenerator._create_offer()` при необходимости + +## 📝 Логирование + +Логи сохраняются в: +- `logs/parser.log` - основные логи +- База данных - статистика парсинга +- Telegram - уведомления о результатах + +## ⚠️ Важные моменты + +1. **Соблюдайте robots.txt** и не перегружайте сервер запросами +2. **Используйте VPN** если необходимо для доступа к API переводчиков +3. **Настройте паузы** между запросами в конфигурации +4. **Регулярно обновляйте** курсы валют +5. **Проверяйте качество** переводов и корректируйте при необходимости + +## 🐛 Решение проблем + +### Ошибки перевода +- Проверьте API ключи в config.yaml +- Убедитесь в наличии интернет-соединения +- Попробуйте другой сервис перевода + +### Ошибки парсинга +- Проверьте доступность morele.net +- Обновите User-Agent в конфигурации +- Увеличьте паузы между запросами + +### Проблемы с изображениями +- Проверьте свободное место на диске +- Убедитесь в правильности базового URL для изображений +- Проверьте права доступа к папке images/ + +## 📞 Поддержка + +При возникновении проблем: + +1. Проверьте логи в `logs/parser.log` +2. Убедитесь в правильности конфигурации +3. Проверьте доступность внешних сервисов +4. Обновите зависимости: `pip install -r requirements.txt --upgrade` + +## 📄 Лицензия + +Проект предназначен для образовательных целей. При использовании соблюдайте: +- Условия использования morele.net +- Лимиты API переводчиков +- Законодательство о парсинге данных + +--- + +**Удачного парсинга! 🚀** diff --git a/cli_tools.py b/cli_tools.py new file mode 100644 index 0000000..a0ad032 --- /dev/null +++ b/cli_tools.py @@ -0,0 +1,159 @@ +# cli_tools.py +""" +CLI инструменты для управления парсером +""" + +import argparse +import sys +from pathlib import Path + +# Добавляем путь к модулям +sys.path.append(str(Path(__file__).parent)) + +from utils.db_manager import DatabaseManager +from utils.monitor import SystemMonitor +from utils.image_optimizer import ImageOptimizer +from utils.feed_validator import FeedValidator +from config import Config + + +def backup_database(args): + """Создаёт резервную копию БД""" + config = Config() + db_path = config.get('database.sqlite_path', 'data/morele_parser.db') + + manager = DatabaseManager(db_path) + backup_file = manager.backup_database(args.backup_dir) + print(f"Backup created: {backup_file}") + + +def cleanup_data(args): + """Очищает старые данные""" + config = Config() + db_path = config.get('database.sqlite_path', 'data/morele_parser.db') + + manager = DatabaseManager(db_path) + manager.cleanup_old_data(args.days) + + if args.optimize: + manager.optimize_database() + + +def show_stats(args): + """Показывает статистику""" + config = Config() + db_path = config.get('database.sqlite_path', 'data/morele_parser.db') + + # Статистика БД + manager = DatabaseManager(db_path) + db_stats = manager.get_database_stats() + + print("=== DATABASE STATISTICS ===") + for key, value in db_stats.items(): + print(f"{key}: {value}") + + # Системная статистика + if args.system: + monitor = SystemMonitor() + sys_stats = monitor.get_system_stats() + + print("\n=== SYSTEM STATISTICS ===") + print(f"CPU Usage: {sys_stats['cpu_percent']}%") + print(f"Memory Usage: {sys_stats['memory_percent']}%") + print(f"Disk Usage: {sys_stats['disk_usage']}%") + + +def optimize_images(args): + """Оптимизирует изображения""" + optimizer = ImageOptimizer(quality=args.quality, max_size=(args.max_width, args.max_height)) + + optimized, errors = optimizer.optimize_directory( + args.directory, + args.extensions.split(',') if args.extensions else None + ) + + print(f"Optimized: {optimized}, Errors: {errors}") + + +def validate_feed(args): + """Валидирует YML фид""" + validator = FeedValidator() + + if validator.validate_feed(args.feed_path): + print("✅ Feed is valid!") + else: + print("❌ Feed has errors:") + print(validator.get_report()) + + +def monitor_system(args): + """Мониторинг системы""" + monitor = SystemMonitor() + + # Проверяем ресурсы + disk_status, disk_msg = monitor.check_disk_space() + memory_status, memory_msg = monitor.check_memory_usage() + + print(f"Disk: {disk_msg}") + print(f"Memory: {memory_msg}") + + if disk_status == 'critical' or memory_status == 'critical': + sys.exit(1) + elif disk_status == 'warning' or memory_status == 'warning': + sys.exit(2) + + +def main(): + """Главная функция CLI""" + parser = argparse.ArgumentParser(description='Morele Parser CLI Tools') + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Backup command + backup_parser = subparsers.add_parser('backup', help='Create database backup') + backup_parser.add_argument('--backup-dir', default='backups', help='Backup directory') + backup_parser.set_defaults(func=backup_database) + + # Cleanup command + cleanup_parser = subparsers.add_parser('cleanup', help='Cleanup old data') + cleanup_parser.add_argument('--days', type=int, default=30, help='Days to keep') + cleanup_parser.add_argument('--optimize', action='store_true', help='Optimize database') + cleanup_parser.set_defaults(func=cleanup_data) + + # Stats command + stats_parser = subparsers.add_parser('stats', help='Show statistics') + stats_parser.add_argument('--system', action='store_true', help='Include system stats') + stats_parser.set_defaults(func=show_stats) + + # Optimize images command + images_parser = subparsers.add_parser('optimize-images', help='Optimize images') + images_parser.add_argument('directory', help='Directory with images') + images_parser.add_argument('--quality', type=int, default=85, help='JPEG quality') + images_parser.add_argument('--max-width', type=int, default=1200, help='Max width') + images_parser.add_argument('--max-height', type=int, default=1200, help='Max height') + images_parser.add_argument('--extensions', help='File extensions (comma-separated)') + images_parser.set_defaults(func=optimize_images) + + # Validate feed command + validate_parser = subparsers.add_parser('validate-feed', help='Validate YML feed') + validate_parser.add_argument('feed_path', help='Path to YML feed file') + validate_parser.set_defaults(func=validate_feed) + + # Monitor command + monitor_parser = subparsers.add_parser('monitor', help='Monitor system resources') + monitor_parser.set_defaults(func=monitor_system) + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + try: + args.func(args) + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/config.py b/config.py new file mode 100644 index 0000000..e18105a --- /dev/null +++ b/config.py @@ -0,0 +1,130 @@ +# config.py +""" +Класс для работы с конфигурацией +""" + +import yaml +import os +from pathlib import Path + + +class Config: + """Класс для управления конфигурацией""" + + def __init__(self, config_path='config.yaml'): + self.config_path = config_path + self.config = self._load_config() + + def _load_config(self): + """Загружает конфигурацию из YAML файла""" + if not os.path.exists(self.config_path): + self._create_default_config() + + with open(self.config_path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + + def _create_default_config(self): + """Создаёт конфигурационный файл по умолчанию""" + default_config = { + 'database': { + 'type': 'sqlite', # sqlite или mysql + 'sqlite_path': 'data/morele_parser.db', + 'mysql': { + 'host': 'localhost', + 'port': 3306, + 'database': 'morele_parser', + 'username': 'user', + 'password': 'password' + } + }, + 'parsing': { + 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'delay_between_requests': 1, # секунды + 'max_retries': 3, + 'timeout': 30, + 'concurrent_requests': 5 + }, + 'images': { + 'download_path': 'images', + 'max_size_mb': 10, + 'allowed_formats': ['jpg', 'jpeg', 'png', 'webp'], + 'quality': 85 + }, + 'translation': { + 'service': 'google', # google, deepl, libretranslate + 'cache_enabled': True, + 'cache_path': 'data/translation_cache.db', + 'google': { + 'api_key': '', # Заполнить + 'source_lang': 'pl', + 'target_lang': 'uk' + }, + 'deepl': { + 'api_key': '', + 'source_lang': 'PL', + 'target_lang': 'UK' + }, + 'libretranslate': { + 'url': 'https://libretranslate.de', + 'api_key': '' + } + }, + 'feed': { + 'output_path': 'feeds/prom_feed.yml', + 'shop_name': 'Ваш магазин', + 'company': 'Ваша компания', + 'currency': 'UAH', + 'pln_to_uah_rate': 'auto', # auto или число + 'margin_percent': 10, # наценка в процентах + 'categories_mapping': {} # соответствие категорий morele и prom + }, + 'telegram': { + 'enabled': False, + 'bot_token': '', + 'chat_id': '' + }, + 'logging': { + 'level': 'INFO', + 'file': 'logs/parser.log', + 'max_size_mb': 50, + 'backup_count': 5 + } + } + + # Создаём директорию для конфига если её нет + Path(self.config_path).parent.mkdir(parents=True, exist_ok=True) + + with open(self.config_path, 'w', encoding='utf-8') as f: + yaml.dump(default_config, f, default_flow_style=False, allow_unicode=True) + + return default_config + + def get(self, key, default=None): + """Получает значение из конфигурации по ключу (поддерживает точечную нотацию)""" + keys = key.split('.') + value = self.config + + try: + for k in keys: + value = value[k] + return value + except (KeyError, TypeError): + return default + + def set(self, key, value): + """Устанавливает значение в конфигурации""" + keys = key.split('.') + config = self.config + + for k in keys[:-1]: + if k not in config: + config[k] = {} + config = config[k] + + config[keys[-1]] = value + self.save() + + def save(self): + """Сохраняет конфигурацию в файл""" + with open(self.config_path, 'w', encoding='utf-8') as f: + yaml.dump(self.config, f, default_flow_style=False, allow_unicode=True) diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..d3f2351 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,406 @@ +#!/bin/bash +# Скрипт для развертывания парсера + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="/opt/morele_parser" +SERVICE_USER="morele" +PYTHON_VERSION="3.11" + +echo "🚀 Deploying Morele Parser..." + +# Функция для вывода сообщений +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +# Проверка прав root +if [[ $EUID -eq 0 ]]; then + echo "❌ Don't run this script as root" + exit 1 +fi + +# Установка системных зависимостей +install_system_deps() { + log "Installing system dependencies..." + + sudo apt update + sudo apt install -y \ + python${PYTHON_VERSION} \ + python${PYTHON_VERSION}-venv \ + python${PYTHON_VERSION}-dev \ + nginx \ + sqlite3 \ + supervisor \ + curl \ + wget \ + unzip \ + git +} + +# Создание пользователя +create_user() { + if ! id "$SERVICE_USER" &>/dev/null; then + log "Creating user $SERVICE_USER..." + sudo useradd -r -s /bin/bash -d $PROJECT_DIR $SERVICE_USER + fi +} + +# Создание директорий +create_directories() { + log "Creating directories..." + + sudo mkdir -p $PROJECT_DIR + sudo mkdir -p $PROJECT_DIR/{data,images,feeds,logs,backups} + sudo mkdir -p /var/log/morele_parser + + sudo chown -R $SERVICE_USER:$SERVICE_USER $PROJECT_DIR + sudo chown -R $SERVICE_USER:$SERVICE_USER /var/log/morele_parser +} + +# Копирование файлов +copy_files() { + log "Copying files..." + + sudo cp -r $SCRIPT_DIR/* $PROJECT_DIR/ + sudo chown -R $SERVICE_USER:$SERVICE_USER $PROJECT_DIR +} + +# Создание виртуального окружения +setup_venv() { + log "Setting up Python virtual environment..." + + sudo -u $SERVICE_USER python${PYTHON_VERSION} -m venv $PROJECT_DIR/venv + sudo -u $SERVICE_USER $PROJECT_DIR/venv/bin/pip install --upgrade pip + sudo -u $SERVICE_USER $PROJECT_DIR/venv/bin/pip install -r $PROJECT_DIR/requirements.txt +} + +# Настройка конфигурации +setup_config() { + log "Setting up configuration..." + + if [ ! -f "$PROJECT_DIR/config.yaml" ]; then + sudo -u $SERVICE_USER $PROJECT_DIR/venv/bin/python $PROJECT_DIR/main.py --help > /dev/null + log "Default config created. Please edit $PROJECT_DIR/config.yaml" + fi +} + +# Настройка Supervisor +setup_supervisor() { + log "Setting up Supervisor..." + + sudo tee /etc/supervisor/conf.d/morele_parser.conf > /dev/null < /dev/null < /tmp/morele_cron 2>/dev/null || true + + cat >> /tmp/morele_cron <> logs/cron.log 2>&1 +0 3 * * * cd $PROJECT_DIR && $PROJECT_DIR/venv/bin/python main.py --generate-feed >> logs/cron.log 2>&1 +0 4 * * 0 cd $PROJECT_DIR && $PROJECT_DIR/venv/bin/python cli_tools.py backup --backup-dir backups >> logs/cron.log 2>&1 +0 5 * * 0 cd $PROJECT_DIR && $PROJECT_DIR/venv/bin/python cli_tools.py cleanup --days 30 --optimize >> logs/cron.log 2>&1 +EOF + + sudo -u $SERVICE_USER crontab /tmp/morele_cron + rm /tmp/morele_cron +} + +# Настройка логротации +setup_logrotate() { + log "Setting up log rotation..." + + sudo tee /etc/logrotate.d/morele_parser > /dev/null < /dev/null; then + log "✅ Admin panel is running" + else + log "❌ Admin panel is not responding" + fi + + # Проверка nginx + if curl -s http://localhost/health | grep -q "healthy"; then + log "✅ Nginx is working" + else + log "❌ Nginx is not working" + fi + + # Проверка supervisor + if sudo supervisorctl status morele_parser_admin | grep -q "RUNNING"; then + log "✅ Supervisor service is running" + else + log "❌ Supervisor service is not running" + fi +} + +# Основная функция +main() { + log "Starting deployment process..." + + install_system_deps + create_user + create_directories + copy_files + setup_venv + setup_config + setup_supervisor + setup_nginx + setup_cron + setup_logrotate + start_services + verify_installation + + log "🎉 Deployment completed!" + log "Admin panel: http://your-server-ip/" + log "Please edit configuration: $PROJECT_DIR/config.yaml" + log "Check logs: tail -f /var/log/morele_parser/admin.log" +} + +# Проверка аргументов +case "${1:-}" in + "install") + main + ;; + "update") + copy_files + sudo supervisorctl restart morele_parser_admin + log "Updated successfully" + ;; + "status") + sudo supervisorctl status + sudo systemctl status nginx + ;; + "logs") + tail -f /var/log/morele_parser/admin.log + ;; + *) + echo "Usage: $0 {install|update|status|logs}" + echo " install - Full installation" + echo " update - Update code only" + echo " status - Show service status" + echo " logs - Show logs" + exit 1 + ;; +esac + +# ===== update.sh ===== +#!/bin/bash +# Скрипт для обновления парсера + +set -e + +PROJECT_DIR="/opt/morele_parser" +SERVICE_USER="morele" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +# Создание резервной копии +backup_current() { + log "Creating backup..." + + BACKUP_DIR="$PROJECT_DIR/backups/update_$(date +%Y%m%d_%H%M%S)" + sudo -u $SERVICE_USER mkdir -p "$BACKUP_DIR" + + sudo -u $SERVICE_USER cp -r $PROJECT_DIR/{*.py,modules,config.yaml} "$BACKUP_DIR/" 2>/dev/null || true + sudo -u $SERVICE_USER $PROJECT_DIR/venv/bin/python $PROJECT_DIR/cli_tools.py backup --backup-dir "$BACKUP_DIR" + + log "Backup created: $BACKUP_DIR" +} + +# Обновление кода +update_code() { + log "Updating code..." + + # Здесь можно добавить git pull или копирование новых файлов + # git -C $PROJECT_DIR pull origin main + + # Или копирование из source директории + if [ -d "./source" ]; then + sudo cp -r ./source/* $PROJECT_DIR/ + sudo chown -R $SERVICE_USER:$SERVICE_USER $PROJECT_DIR + fi +} + +# Обновление зависимостей +update_dependencies() { + log "Updating dependencies..." + + sudo -u $SERVICE_USER $PROJECT_DIR/venv/bin/pip install --upgrade pip + sudo -u $SERVICE_USER $PROJECT_DIR/venv/bin/pip install -r $PROJECT_DIR/requirements.txt --upgrade +} + +# Миграция базы данных (если нужно) +migrate_database() { + log "Checking database migrations..." + + # Здесь можно добавить скрипты миграции БД + # sudo -u $SERVICE_USER $PROJECT_DIR/venv/bin/python $PROJECT_DIR/migrate.py +} + +# Перезапуск сервисов +restart_services() { + log "Restarting services..." + + sudo supervisorctl restart morele_parser_admin + sudo systemctl reload nginx +} + +# Проверка обновления +verify_update() { + log "Verifying update..." + + sleep 5 + + if curl -s http://localhost:5000 > /dev/null; then + log "✅ Update successful" + else + log "❌ Update failed, check logs" + sudo supervisorctl status + exit 1 + fi +} + +main() { + log "Starting update process..." + + backup_current + update_code + update_dependencies + migrate_database + restart_services + verify_update + + log "🎉 Update completed successfully!" +} + +main diff --git a/main.py b/main.py new file mode 100644 index 0000000..45589d6 --- /dev/null +++ b/main.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +""" +Morele.net Parser - главный файл +Профессиональный парсер для ежедневного сбора товаров +""" + +import os +import sys +import logging +import argparse +from datetime import datetime +from pathlib import Path + +# Добавляем текущую директорию в путь для импортов +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from config import Config +from modules.parser import MoreleParser +from modules.translator import TranslationService +from modules.image_downloader import ImageDownloader +from modules.feed_generator import FeedGenerator +from modules.storage import StorageManager +from modules.admin import AdminPanel + + +class MoreleParserMain: + """Главный класс для управления парсером""" + + def __init__(self, config_path='config.yaml'): + self.config = Config(config_path) + self.setup_logging() + + # Инициализация компонентов + self.storage = StorageManager(self.config) + self.translator = TranslationService(self.config, self.storage) + self.image_downloader = ImageDownloader(self.config) + self.parser = MoreleParser(self.config) + self.feed_generator = FeedGenerator(self.config, self.storage) + + self.logger = logging.getLogger(__name__) + + def setup_logging(self): + """Настройка логирования""" + log_level = getattr(logging, self.config.get('logging.level', 'INFO').upper()) + + logging.basicConfig( + level=log_level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(self.config.get('logging.file', 'logs/parser.log')), + logging.StreamHandler(sys.stdout) + ] + ) + + def run_parsing(self): + """Основной процесс парсинга""" + self.logger.info("Запуск парсинга товаров...") + start_time = datetime.now() + + try: + # Получаем список категорий для парсинга + categories = self.storage.get_active_categories() + + if not categories: + self.logger.warning("Нет активных категорий для парсинга") + return + + total_new = 0 + total_updated = 0 + total_errors = 0 + + for category in categories: + self.logger.info(f"Парсинг категории: {category['url']}") + + try: + # Парсим товары из категории + products = self.parser.parse_category(category['url']) + + for product in products: + try: + # Проверяем, есть ли товар в БД + existing_product = self.storage.get_product_by_url(product['url']) + + if existing_product: + # Проверяем, изменился ли товар + if self._product_changed(existing_product, product): + self._update_product(product, existing_product['id']) + total_updated += 1 + else: + # Новый товар + self._process_new_product(product) + total_new += 1 + + except Exception as e: + self.logger.error(f"Ошибка при обработке товара {product.get('url', 'unknown')}: {e}") + total_errors += 1 + + except Exception as e: + self.logger.error(f"Ошибка при парсинге категории {category['url']}: {e}") + total_errors += 1 + + # Генерируем фид + if total_new > 0 or total_updated > 0: + self.logger.info("Генерация YML фида...") + self.feed_generator.generate_yml_feed() + + # Статистика + duration = datetime.now() - start_time + self.logger.info(f"Парсинг завершён за {duration}") + self.logger.info(f"Новых товаров: {total_new}, обновлённых: {total_updated}, ошибок: {total_errors}") + + # Отправляем уведомление в Telegram (если настроено) + self._send_telegram_notification(total_new, total_updated, total_errors, duration) + + except Exception as e: + self.logger.error(f"Критическая ошибка при парсинге: {e}") + raise + + def _product_changed(self, existing_product, new_product): + """Проверяем, изменился ли товар""" + # Сравниваем ключевые поля + fields_to_compare = ['title', 'price', 'availability', 'description'] + + for field in fields_to_compare: + if existing_product.get(field) != new_product.get(field): + return True + + return False + + def _process_new_product(self, product): + """Обработка нового товара""" + self.logger.debug(f"Обработка нового товара: {product['title']}") + + # Загружаем изображения + if product.get('images'): + local_images = self.image_downloader.download_product_images( + product['images'], + product['id'] + ) + product['local_images'] = local_images + + # Переводим текст + product['title_ua'] = self.translator.translate(product['title']) + product['description_ua'] = self.translator.translate(product['description']) + + # Переводим атрибуты + if product.get('attributes'): + product['attributes_ua'] = {} + for key, value in product['attributes'].items(): + key_ua = self.translator.translate(key) + value_ua = self.translator.translate(value) if isinstance(value, str) else value + product['attributes_ua'][key_ua] = value_ua + + # Сохраняем в БД + product['is_translated'] = True + product['updated_at'] = datetime.now().isoformat() + + self.storage.save_product(product) + + def _update_product(self, product, product_id): + """Обновление существующего товара""" + self.logger.debug(f"Обновление товара: {product['title']}") + + # Проверяем, нужно ли обновить перевод + existing_product = self.storage.get_product_by_id(product_id) + + if existing_product['title'] != product['title']: + product['title_ua'] = self.translator.translate(product['title']) + else: + product['title_ua'] = existing_product['title_ua'] + + if existing_product['description'] != product['description']: + product['description_ua'] = self.translator.translate(product['description']) + else: + product['description_ua'] = existing_product['description_ua'] + + # Обновляем изображения при необходимости + if product.get('images') and product['images'] != existing_product.get('original_images'): + local_images = self.image_downloader.download_product_images( + product['images'], + product['id'] + ) + product['local_images'] = local_images + else: + product['local_images'] = existing_product.get('local_images', []) + + product['updated_at'] = datetime.now().isoformat() + self.storage.update_product(product_id, product) + + def _send_telegram_notification(self, new_count, updated_count, errors_count, duration): + """Отправка уведомления в Telegram""" + telegram_config = self.config.get('telegram') + if not telegram_config or not telegram_config.get('enabled'): + return + + try: + import requests + + message = f"""🔄 Парсинг morele.net завершён + +📊 Статистика: +• Новых товаров: {new_count} +• Обновлённых: {updated_count} +• Ошибок: {errors_count} +• Время выполнения: {duration} + +📅 Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}""" + + url = f"https://api.telegram.org/bot{telegram_config['bot_token']}/sendMessage" + data = { + 'chat_id': telegram_config['chat_id'], + 'text': message, + 'parse_mode': 'HTML' + } + + response = requests.post(url, data=data, timeout=10) + if response.status_code == 200: + self.logger.info("Telegram уведомление отправлено") + else: + self.logger.error(f"Ошибка отправки Telegram уведомления: {response.text}") + + except Exception as e: + self.logger.error(f"Ошибка при отправке Telegram уведомления: {e}") + + def run_admin_panel(self, host='127.0.0.1', port=5000): + """Запуск веб-админки""" + admin = AdminPanel(self.config, self.storage) + admin.run(host=host, port=port) + + +def main(): + """Главная функция""" + parser = argparse.ArgumentParser(description='Morele.net Parser') + parser.add_argument('--config', default='config.yaml', help='Путь к файлу конфигурации') + parser.add_argument('--admin', action='store_true', help='Запустить веб-админку') + parser.add_argument('--host', default='127.0.0.1', help='Host для админки') + parser.add_argument('--port', type=int, default=5000, help='Порт для админки') + parser.add_argument('--parse', action='store_true', help='Запустить парсинг') + parser.add_argument('--generate-feed', action='store_true', help='Сгенерировать YML фид') + + args = parser.parse_args() + + # Создаём необходимые директории + Path('logs').mkdir(exist_ok=True) + Path('data').mkdir(exist_ok=True) + Path('images').mkdir(exist_ok=True) + Path('feeds').mkdir(exist_ok=True) + + try: + app = MoreleParserMain(args.config) + + if args.admin: + print(f"Запуск админ-панели на http://{args.host}:{args.port}") + app.run_admin_panel(args.host, args.port) + elif args.parse: + app.run_parsing() + elif args.generate_feed: + app.feed_generator.generate_yml_feed() + print("YML фид сгенерирован") + else: + print("Используйте --parse для парсинга или --admin для запуска админки") + print("Для помощи: python main.py --help") + + except KeyboardInterrupt: + print("\nПарсинг прерван пользователем") + except Exception as e: + print(f"Критическая ошибка: {e}") + logging.error(f"Критическая ошибка: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..a8d6708 --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,4 @@ +# modules/__init__.py +""" +Модули парсера morele.net +""" diff --git a/modules/admin.py b/modules/admin.py new file mode 100644 index 0000000..1a501ed --- /dev/null +++ b/modules/admin.py @@ -0,0 +1,362 @@ +# modules/admin.py +""" +Простая веб-админка для управления парсером +""" + +from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify +import logging +from datetime import datetime, timedelta + + +class AdminPanel: + """Простая веб-админка""" + + def __init__(self, config, storage): + self.config = config + self.storage = storage + self.app = Flask(__name__) + self.app.secret_key = 'morele-parser-secret-key' + + self._setup_routes() + + def _setup_routes(self): + """Настройка маршрутов""" + + @self.app.route('/') + def index(): + """Главная страница""" + categories = self.storage.get_active_categories() + stats = self.storage.get_parsing_stats(7) # За неделю + + return render_template_string(self.INDEX_TEMPLATE, + categories=categories, + stats=stats) + + @self.app.route('/categories') + def categories(): + """Страница управления категориями""" + categories = self.storage.get_active_categories() + return render_template_string(self.CATEGORIES_TEMPLATE, categories=categories) + + @self.app.route('/add_category', methods=['POST']) + def add_category(): + """Добавление категории""" + name = request.form.get('name') + url = request.form.get('url') + + if name and url: + try: + self.storage.add_category(name, url) + flash('Категория добавлена успешно', 'success') + except Exception as e: + flash(f'Ошибка при добавлении категории: {e}', 'error') + else: + flash('Заполните все поля', 'error') + + return redirect(url_for('categories')) + + @self.app.route('/deactivate_category/') + def deactivate_category(category_id): + """Деактивация категории""" + try: + self.storage.deactivate_category(category_id) + flash('Категория деактивирована', 'success') + except Exception as e: + flash(f'Ошибка: {e}', 'error') + + return redirect(url_for('categories')) + + @self.app.route('/products') + def products(): + """Страница товаров""" + page = int(request.args.get('page', 1)) + per_page = 50 + + # Здесь можно добавить пагинацию + products = self.storage.get_products_for_feed()[:per_page] + + return render_template_string(self.PRODUCTS_TEMPLATE, products=products) + + @self.app.route('/api/stats') + def api_stats(): + """API для получения статистики""" + stats = self.storage.get_parsing_stats(30) + return jsonify(stats) + + def run(self, host='127.0.0.1', port=5000): + """Запуск админки""" + self.app.run(host=host, port=port, debug=False) + + # HTML шаблоны + INDEX_TEMPLATE = ''' + + + + + + Morele.net Parser - Админка + + + +
+
+

🔍 Morele.net Parser - Админка

+ +
+ +
+

📊 Статистика

+
+
+
{{ categories|length }}
+
Активных категорий
+
+
+
{{ stats|length }}
+
Сессий парсинга за неделю
+
+
+
+ +
+

📋 Последние сессии парсинга

+ {% if stats %} + + + + + + + + + + + + + {% for stat in stats[:10] %} + + + + + + + + + {% endfor %} + +
ДатаКатегорияНайденоНовыхОбновленоОшибок
{{ stat.completed_at }}{{ stat.category_url }}{{ stat.products_found }}{{ stat.products_new }}{{ stat.products_updated }}{{ stat.errors_count }}
+ {% else %} +

Пока нет данных о парсинге

+ {% endif %} +
+
+ + + ''' + + CATEGORIES_TEMPLATE = ''' + + + + + + Категории - Morele.net Parser + + + +
+
+

📁 Управление категориями

+ +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+

➕ Добавить категорию

+
+
+ + +
+
+ + +
+ +
+
+ +
+

📋 Список категорий

+ {% if categories %} + + + + + + + + + + + + {% for category in categories %} + + + + + + + + {% endfor %} + +
IDНазваниеURLДата добавленияДействия
{{ category.id }}{{ category.name }}{{ category.url[:50] }}...{{ category.created_at }} + + Деактивировать + +
+ {% else %} +

Пока нет добавленных категорий. Добавьте первую категорию выше.

+ {% endif %} +
+
+ + + ''' + + PRODUCTS_TEMPLATE = ''' + + + + + + Товары - Morele.net Parser + + + +
+
+

📦 Товары

+ +
+ +
+

📋 Список товаров

+ {% if products %} +

Показано товаров: {{ products|length }}

+ + + + + + + + + + + + + + {% for product in products %} + + + + + + + + + + {% endfor %} + +
ИзображениеНазвание (UA)Цена (PLN)НаличиеКатегорияОбновленоСсылка
+ {% if product.local_images %} + Product image + {% else %} +
+ {% endif %} +
{{ product.title_ua }}{{ "%.2f"|format(product.price) }} PLN + {{ product.availability }} + {{ product.category }}{{ product.updated_at[:16] }}Открыть
+ {% else %} +

Пока нет товаров. Запустите парсинг для получения товаров.

+ {% endif %} +
+
+ + + ''' diff --git a/modules/feed_generator.py b/modules/feed_generator.py new file mode 100644 index 0000000..cdad009 --- /dev/null +++ b/modules/feed_generator.py @@ -0,0 +1,210 @@ +# modules/feed_generator.py +""" +Модуль для генерации YML фида для Prom.ua +""" + +import xml.etree.ElementTree as ET +from xml.dom import minidom +import requests +import logging +from datetime import datetime +from pathlib import Path + + +class FeedGenerator: + """Генератор YML фида для Prom.ua""" + + def __init__(self, config, storage): + self.config = config + self.storage = storage + self.logger = logging.getLogger(__name__) + + self.output_path = Path(config.get('feed.output_path', 'feeds/prom_feed.yml')) + self.shop_name = config.get('feed.shop_name', 'Ваш магазин') + self.company = config.get('feed.company', 'Ваша компания') + self.currency = config.get('feed.currency', 'UAH') + self.margin_percent = config.get('feed.margin_percent', 10) + + # Создаём директорию для фидов + self.output_path.parent.mkdir(parents=True, exist_ok=True) + + def generate_yml_feed(self): + """Генерирует YML фид""" + self.logger.info("Начинаем генерацию YML фида...") + + # Получаем товары + products = self.storage.get_products_for_feed() + + if not products: + self.logger.warning("Нет товаров для генерации фида") + return + + # Получаем курс валют + pln_to_uah_rate = self._get_exchange_rate() + + # Создаём XML структуру + yml_catalog = ET.Element('yml_catalog', date=datetime.now().strftime('%Y-%m-%d %H:%M')) + shop = ET.SubElement(yml_catalog, 'shop') + + # Информация о магазине + ET.SubElement(shop, 'name').text = self.shop_name + ET.SubElement(shop, 'company').text = self.company + ET.SubElement(shop, 'url').text = self.config.get('feed.shop_url', 'https://example.com') + + # Валюты + currencies = ET.SubElement(shop, 'currencies') + currency = ET.SubElement(currencies, 'currency', id=self.currency, rate='1') + + # Категории + categories_elem = ET.SubElement(shop, 'categories') + categories_map = self._build_categories(products, categories_elem) + + # Товары + offers = ET.SubElement(shop, 'offers') + + for product in products: + try: + offer = self._create_offer(product, pln_to_uah_rate, categories_map) + if offer is not None: + offers.append(offer) + except Exception as e: + self.logger.error(f"Ошибка при создании оффера для товара {product['id']}: {e}") + + # Сохраняем файл + self._save_xml(yml_catalog) + + self.logger.info(f"YML фид сгенерирован: {self.output_path}") + self.logger.info(f"Товаров в фиде: {len(offers)}") + + def _get_exchange_rate(self): + """Получает курс PLN к UAH""" + rate_setting = self.config.get('feed.pln_to_uah_rate', 'auto') + + if isinstance(rate_setting, (int, float)): + return float(rate_setting) + + try: + # Пытаемся получить актуальный курс + response = requests.get( + 'https://api.exchangerate-api.com/v4/latest/PLN', + timeout=10 + ) + response.raise_for_status() + data = response.json() + rate = data['rates'].get('UAH', 10.0) # Fallback курс + + self.logger.info(f"Получен курс PLN/UAH: {rate}") + return rate + + except Exception as e: + self.logger.error(f"Ошибка получения курса валют: {e}") + return 10.0 # Fallback курс + + def _build_categories(self, products, categories_elem): + """Строит список категорий""" + categories = set() + + for product in products: + if product.get('category'): + categories.add(product['category']) + + categories_map = {} + category_id = 1 + + for category_name in sorted(categories): + category_elem = ET.SubElement(categories_elem, 'category', id=str(category_id)) + category_elem.text = category_name + categories_map[category_name] = category_id + category_id += 1 + + return categories_map + + def _create_offer(self, product, pln_to_uah_rate, categories_map): + """Создаёт элемент offer для товара""" + # Проверяем обязательные поля + if not product.get('title_ua') or not product.get('price'): + return None + + # Рассчитываем цену в UAH с наценкой + price_pln = float(product['price']) + price_uah = price_pln * pln_to_uah_rate + price_uah_with_margin = price_uah * (1 + self.margin_percent / 100) + price_uah_final = round(price_uah_with_margin, 2) + + # Создаём элемент offer + offer = ET.Element('offer', id=str(product['external_id']), available='true') + + # Основные поля + ET.SubElement(offer, 'name').text = product['title_ua'] + ET.SubElement(offer, 'price').text = str(price_uah_final) + ET.SubElement(offer, 'currencyId').text = self.currency + + # Категория + if product.get('category') and product['category'] in categories_map: + ET.SubElement(offer, 'categoryId').text = str(categories_map[product['category']]) + + # Изображения + if product.get('local_images'): + for img_path in product['local_images'][:10]: # Ограничиваем количество + # Преобразуем локальный путь в URL + img_url = self._local_path_to_url(img_path) + if img_url: + ET.SubElement(offer, 'picture').text = img_url + + # Описание + description = product.get('description_ua', '').strip() + if description: + description_elem = ET.SubElement(offer, 'description') + description_elem.text = description[:3000] # Ограничиваем длину + + # Атрибуты товара + if product.get('brand'): + ET.SubElement(offer, 'vendor').text = product['brand'] + + if product.get('model'): + ET.SubElement(offer, 'model').text = product['model'] + + if product.get('sku'): + ET.SubElement(offer, 'vendorCode').text = product['sku'] + + # Характеристики + if product.get('attributes_ua'): + for attr_name, attr_value in product['attributes_ua'].items(): + if attr_name and attr_value and len(str(attr_value)) < 100: + param = ET.SubElement(offer, 'param', name=str(attr_name)) + param.text = str(attr_value) + + # Наличие + stock_status = product.get('availability', '').lower() + if 'нет' in stock_status or 'недоступ' in stock_status: + offer.set('available', 'false') + + # URL товара + if product.get('url'): + ET.SubElement(offer, 'url').text = product['url'] + + return offer + + def _local_path_to_url(self, local_path): + """Преобразует локальный путь в URL""" + # Здесь нужно настроить базовый URL вашего сервера + base_url = self.config.get('feed.images_base_url', 'https://yoursite.com/') + + if base_url.endswith('/'): + base_url = base_url[:-1] + + # Убираем начальную часть пути + relative_path = str(local_path).replace('\\', '/') + if relative_path.startswith('./'): + relative_path = relative_path[2:] + + return f"{base_url}/{relative_path}" + + def _save_xml(self, root): + """Сохраняет XML в файл с красивым форматированием""" + rough_string = ET.tostring(root, encoding='unicode') + reparsed = minidom.parseString(rough_string) + pretty_xml = reparsed.toprettyxml(indent=" ", encoding='utf-8') + + with open(self.output_path, 'wb') as f: + f.write(pretty_xml) diff --git a/modules/image_downloader.py b/modules/image_downloader.py new file mode 100644 index 0000000..96ebd1d --- /dev/null +++ b/modules/image_downloader.py @@ -0,0 +1,126 @@ +# modules/image_downloader.py +""" +Модуль для загрузки изображений +""" + +import os +import requests +import hashlib +import logging +from pathlib import Path +from urllib.parse import urlparse +from PIL import Image +import mimetypes + + +class ImageDownloader: + """Загрузчик изображений""" + + def __init__(self, config): + self.config = config + self.logger = logging.getLogger(__name__) + + self.download_path = Path(config.get('images.download_path', 'images')) + self.max_size_mb = config.get('images.max_size_mb', 10) + self.allowed_formats = config.get('images.allowed_formats', ['jpg', 'jpeg', 'png', 'webp']) + self.quality = config.get('images.quality', 85) + + # Создаём директорию для изображений + self.download_path.mkdir(parents=True, exist_ok=True) + + def download_product_images(self, image_urls, product_id): + """Загружает все изображения товара""" + local_images = [] + + # Создаём папку для товара + product_dir = self.download_path / str(product_id) + product_dir.mkdir(exist_ok=True) + + for i, url in enumerate(image_urls): + try: + local_path = self._download_image(url, product_dir, f"img_{i}") + if local_path: + local_images.append(str(local_path)) + except Exception as e: + self.logger.error(f"Failed to download image {url}: {e}") + + return local_images + + def _download_image(self, url, save_dir, filename_prefix): + """Загружает одно изображение""" + try: + # Получаем изображение + response = requests.get(url, timeout=30, stream=True) + response.raise_for_status() + + # Проверяем размер + content_length = response.headers.get('content-length') + if content_length and int(content_length) > self.max_size_mb * 1024 * 1024: + self.logger.warning(f"Image too large: {url}") + return None + + # Определяем формат + content_type = response.headers.get('content-type', '') + extension = mimetypes.guess_extension(content_type) + + if not extension: + # Пытаемся определить по URL + parsed_url = urlparse(url) + path_ext = Path(parsed_url.path).suffix.lower() + if path_ext in ['.jpg', '.jpeg', '.png', '.webp']: + extension = path_ext + else: + extension = '.jpg' # По умолчанию + + # Проверяем разрешённые форматы + format_name = extension[1:].lower() + if format_name not in self.allowed_formats: + self.logger.warning(f"Unsupported format {format_name}: {url}") + return None + + # Генерируем имя файла + url_hash = hashlib.md5(url.encode()).hexdigest()[:8] + filename = f"{filename_prefix}_{url_hash}{extension}" + filepath = save_dir / filename + + # Проверяем, не скачан ли уже файл + if filepath.exists(): + return filepath + + # Сохраняем изображение + with open(filepath, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + # Оптимизируем изображение + self._optimize_image(filepath) + + self.logger.debug(f"Downloaded image: {filepath}") + return filepath + + except Exception as e: + self.logger.error(f"Error downloading image {url}: {e}") + return None + + def _optimize_image(self, filepath): + """Оптимизирует изображение""" + try: + with Image.open(filepath) as img: + # Конвертируем в RGB если необходимо + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + + # Ограничиваем размер + max_size = (1200, 1200) + if img.size[0] > max_size[0] or img.size[1] > max_size[1]: + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # Сохраняем с оптимизацией + img.save(filepath, 'JPEG', quality=self.quality, optimize=True) + + except Exception as e: + self.logger.error(f"Error optimizing image {filepath}: {e}") diff --git a/modules/parser.py b/modules/parser.py new file mode 100644 index 0000000..232346c --- /dev/null +++ b/modules/parser.py @@ -0,0 +1,402 @@ +# modules/parser.py +""" +Модуль для парсинга товаров с morele.net +""" + +import requests +import time +import re +from urllib.parse import urljoin, urlparse +from bs4 import BeautifulSoup +import logging +import hashlib +from concurrent.futures import ThreadPoolExecutor, as_completed + + +class MoreleParser: + """Парсер для morele.net""" + + def __init__(self, config): + self.config = config + self.session = requests.Session() + self.logger = logging.getLogger(__name__) + + # Настройка сессии + self.session.headers.update({ + 'User-Agent': config.get('parsing.user_agent'), + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'pl,en-US;q=0.7,en;q=0.3', + 'Accept-Encoding': 'gzip, deflate', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + }) + + def parse_category(self, category_url): + """Парсит все товары из категории""" + self.logger.info(f"Начинаем парсинг категории: {category_url}") + + products = [] + page = 1 + max_pages = self.config.get('parsing.max_pages', 100) + + while page <= max_pages: + self.logger.debug(f"Парсинг страницы {page}") + + page_url = self._get_page_url(category_url, page) + page_products = self._parse_category_page(page_url) + + if not page_products: + self.logger.info(f"Страница {page} пуста, завершаем парсинг категории") + break + + products.extend(page_products) + page += 1 + + # Пауза между запросами + time.sleep(self.config.get('parsing.delay_between_requests', 1)) + + self.logger.info(f"Найдено {len(products)} товаров в категории") + return products + + def _get_page_url(self, base_url, page): + """Формирует URL для конкретной страницы""" + if page == 1: + return base_url + + # Проверяем, есть ли уже параметры в URL + separator = '&' if '?' in base_url else '?' + return f"{base_url}{separator}page={page}" + + def _parse_category_page(self, page_url): + """Парсит товары с одной страницы категории""" + try: + response = self._make_request(page_url) + if not response: + return [] + + soup = BeautifulSoup(response.content, 'html.parser') + + # Ищем карточки товаров + product_cards = soup.find_all('div', class_='cat-product') + products = [] + + # Используем многопоточность для парсинга товаров + with ThreadPoolExecutor(max_workers=self.config.get('parsing.concurrent_requests', 5)) as executor: + futures = [] + + for card in product_cards: + product_url = self._extract_product_url(card) + if product_url: + future = executor.submit(self._parse_product_page, product_url) + futures.append(future) + + for future in as_completed(futures): + try: + product = future.result() + if product: + products.append(product) + except Exception as e: + self.logger.error(f"Ошибка при парсинге товара: {e}") + + return products + + except Exception as e: + self.logger.error(f"Ошибка при парсинге страницы {page_url}: {e}") + return [] + + def _extract_product_url(self, card): + """Извлекает URL товара из карточки""" + try: + link = card.find('a', href=True) + if link: + href = link['href'] + if not href.startswith('http'): + href = urljoin('https://www.morele.net', href) + return href + except Exception as e: + self.logger.error(f"Ошибка при извлечении URL товара: {e}") + + return None + + def _parse_product_page(self, product_url): + """Парсит детальную страницу товара""" + try: + response = self._make_request(product_url) + if not response: + return None + + soup = BeautifulSoup(response.content, 'html.parser') + + # Извлекаем данные товара + product = { + 'url': product_url, + 'id': self._extract_product_id(product_url), + 'title': self._extract_title(soup), + 'price': self._extract_price(soup), + 'availability': self._extract_availability(soup), + 'description': self._extract_description(soup), + 'attributes': self._extract_attributes(soup), + 'category': self._extract_category(soup), + 'images': self._extract_images(soup), + 'brand': self._extract_brand(soup), + 'model': self._extract_model(soup), + 'sku': self._extract_sku(soup), + 'parsed_at': time.time() + } + + # Генерируем хеш для определения изменений + product['content_hash'] = self._generate_content_hash(product) + + return product + + except Exception as e: + self.logger.error(f"Ошибка при парсинге товара {product_url}: {e}") + return None + + def _extract_product_id(self, url): + """Извлекает ID товара из URL""" + # Ищем числовой ID в URL + match = re.search(r'/(\d+)-', url) + if match: + return match.group(1) + + # Если не найден, используем хеш URL + return hashlib.md5(url.encode()).hexdigest()[:10] + + def _extract_title(self, soup): + """Извлекает название товара""" + selectors = [ + 'h1.prod-name', + 'h1[data-test="product-name"]', + 'h1.product-title', + '.product-name h1', + 'h1' + ] + + for selector in selectors: + element = soup.select_one(selector) + if element: + return element.get_text(strip=True) + + return "Без названия" + + def _extract_price(self, soup): + """Извлекает цену товара""" + selectors = [ + '.price-new', + '.price-main', + '[data-test="product-price"]', + '.product-price .price', + '.price' + ] + + for selector in selectors: + element = soup.select_one(selector) + if element: + price_text = element.get_text(strip=True) + # Извлекаем числовое значение + price_match = re.search(r'([\d\s]+[,.]?\d*)', price_text.replace(' ', '')) + if price_match: + price_str = price_match.group(1).replace(' ', '').replace(',', '.') + try: + return float(price_str) + except ValueError: + continue + + return 0.0 + + def _extract_availability(self, soup): + """Извлекает информацию о наличии""" + selectors = [ + '.availability', + '[data-test="product-availability"]', + '.product-availability', + '.stock-info' + ] + + for selector in selectors: + element = soup.select_one(selector) + if element: + availability_text = element.get_text(strip=True).lower() + + if any(word in availability_text for word in ['dostępny', 'w magazynie', 'dostępne']): + return 'в наличии' + elif any(word in availability_text for word in ['brak', 'niedostępny']): + return 'нет в наличии' + else: + return availability_text + + return 'неизвестно' + + def _extract_description(self, soup): + """Извлекает описание товара""" + selectors = [ + '.product-description', + '[data-test="product-description"]', + '.prod-description', + '.description', + '.product-details .description' + ] + + for selector in selectors: + element = soup.select_one(selector) + if element: + # Удаляем HTML теги и лишние пробелы + description = element.get_text(separator=' ', strip=True) + return re.sub(r'\s+', ' ', description) + + return "" + + def _extract_attributes(self, soup): + """Извлекает характеристики товара""" + attributes = {} + + # Различные селекторы для характеристик + specs_sections = soup.find_all(['div', 'section'], class_=re.compile(r'spec|param|attribute|feature')) + + for section in specs_sections: + # Ищем пары ключ-значение + rows = section.find_all(['tr', 'div'], class_=re.compile(r'spec-row|param-row|attribute-row')) + + for row in rows: + # Пытаемся найти название и значение + name_elem = row.find(['td', 'div', 'span'], class_=re.compile(r'name|key|label')) + value_elem = row.find(['td', 'div', 'span'], class_=re.compile(r'value|val')) + + if name_elem and value_elem: + name = name_elem.get_text(strip=True) + value = value_elem.get_text(strip=True) + + if name and value and len(name) < 100: + attributes[name] = value + + return attributes + + def _extract_category(self, soup): + """Извлекает категорию товара""" + # Ищем хлебные крошки + breadcrumb_selectors = [ + '.breadcrumb', + '.breadcrumbs', + '[data-test="breadcrumb"]', + '.navigation-path' + ] + + for selector in breadcrumb_selectors: + breadcrumb = soup.select_one(selector) + if breadcrumb: + links = breadcrumb.find_all('a') + if len(links) > 1: # Пропускаем "Главная" + return links[-1].get_text(strip=True) + + return "Без категории" + + def _extract_images(self, soup): + """Извлекает изображения товара""" + images = [] + + # Селекторы для изображений + img_selectors = [ + '.product-gallery img', + '.product-images img', + '[data-test="product-image"]', + '.gallery img', + '.product-photo img' + ] + + for selector in img_selectors: + imgs = soup.select(selector) + for img in imgs: + src = img.get('src') or img.get('data-src') or img.get('data-lazy') + if src: + if not src.startswith('http'): + src = urljoin('https://www.morele.net', src) + + # Фильтруем маленькие изображения + if not any(size in src for size in ['icon', 'thumb', 'small']) and src not in images: + images.append(src) + + return images[:10] # Ограничиваем количество изображений + + def _extract_brand(self, soup): + """Извлекает бренд товара""" + selectors = [ + '[data-test="product-brand"]', + '.product-brand', + '.brand', + '.manufacturer' + ] + + for selector in selectors: + element = soup.select_one(selector) + if element: + return element.get_text(strip=True) + + return "" + + def _extract_model(self, soup): + """Извлекает модель товара""" + selectors = [ + '[data-test="product-model"]', + '.product-model', + '.model' + ] + + for selector in selectors: + element = soup.select_one(selector) + if element: + return element.get_text(strip=True) + + return "" + + def _extract_sku(self, soup): + """Извлекает артикул товара""" + selectors = [ + '[data-test="product-sku"]', + '.product-sku', + '.sku', + '.article-number', + '.product-code' + ] + + for selector in selectors: + element = soup.select_one(selector) + if element: + return element.get_text(strip=True) + + return "" + + def _generate_content_hash(self, product): + """Генерирует хеш содержимого товара для определения изменений""" + content = f"{product['title']}{product['price']}{product['availability']}{product['description']}" + return hashlib.md5(content.encode('utf-8')).hexdigest() + + def _make_request(self, url, retries=None): + """Выполняет HTTP запрос с повторными попытками""" + if retries is None: + retries = self.config.get('parsing.max_retries', 3) + + for attempt in range(retries + 1): + try: + response = self.session.get( + url, + timeout=self.config.get('parsing.timeout', 30) + ) + + if response.status_code == 200: + return response + elif response.status_code == 429: # Too Many Requests + wait_time = (attempt + 1) * 5 + self.logger.warning(f"Rate limit hit, waiting {wait_time} seconds...") + time.sleep(wait_time) + else: + self.logger.warning(f"HTTP {response.status_code} for {url}") + + except requests.RequestException as e: + self.logger.error(f"Request error on attempt {attempt + 1} for {url}: {e}") + + if attempt < retries: + time.sleep((attempt + 1) * 2) + + self.logger.error(f"Failed to fetch {url} after {retries + 1} attempts") + return None diff --git a/modules/storage.py b/modules/storage.py new file mode 100644 index 0000000..8141f5f --- /dev/null +++ b/modules/storage.py @@ -0,0 +1,272 @@ +# modules/storage.py +""" +Модуль для работы с хранилищем данных +""" + +import sqlite3 +import json +import logging +from datetime import datetime +from pathlib import Path + + +class StorageManager: + """Менеджер для работы с базой данных""" + + def __init__(self, config): + self.config = config + self.logger = logging.getLogger(__name__) + + # Инициализация БД + self.db_type = config.get('database.type', 'sqlite') + + if self.db_type == 'sqlite': + self.db_path = config.get('database.sqlite_path', 'data/morele_parser.db') + Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) + self._init_sqlite() + else: + raise NotImplementedError("Пока поддерживается только SQLite") + + def _init_sqlite(self): + """Инициализирует SQLite базу данных""" + with sqlite3.connect(self.db_path) as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT UNIQUE NOT NULL, + url TEXT NOT NULL, + title TEXT NOT NULL, + title_ua TEXT, + price REAL NOT NULL, + availability TEXT, + description TEXT, + description_ua TEXT, + attributes TEXT, + attributes_ua TEXT, + category TEXT, + brand TEXT, + model TEXT, + sku TEXT, + images TEXT, + local_images TEXT, + content_hash TEXT, + is_translated BOOLEAN DEFAULT 0, + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT UNIQUE NOT NULL, + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS translation_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + original_text TEXT UNIQUE NOT NULL, + translated_text TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS parsing_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_url TEXT, + products_found INTEGER, + products_new INTEGER, + products_updated INTEGER, + errors_count INTEGER, + started_at TIMESTAMP, + completed_at TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_products_external_id ON products(external_id); + CREATE INDEX IF NOT EXISTS idx_products_url ON products(url); + CREATE INDEX IF NOT EXISTS idx_translation_cache_original ON translation_cache(original_text); + """) + + def save_product(self, product): + """Сохраняет товар в базу данных""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT OR REPLACE INTO products ( + external_id, url, title, title_ua, price, availability, + description, description_ua, attributes, attributes_ua, + category, brand, model, sku, images, local_images, + content_hash, is_translated, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + product['id'], + product['url'], + product['title'], + product.get('title_ua', ''), + product['price'], + product['availability'], + product['description'], + product.get('description_ua', ''), + json.dumps(product.get('attributes', {}), ensure_ascii=False), + json.dumps(product.get('attributes_ua', {}), ensure_ascii=False), + product.get('category', ''), + product.get('brand', ''), + product.get('model', ''), + product.get('sku', ''), + json.dumps(product.get('images', [])), + json.dumps(product.get('local_images', [])), + product.get('content_hash', ''), + product.get('is_translated', False), + datetime.now().isoformat() + )) + + def get_product_by_url(self, url): + """Получает товар по URL""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM products WHERE url = ?", (url,)) + row = cursor.fetchone() + + if row: + product = dict(row) + product['attributes'] = json.loads(product['attributes'] or '{}') + product['attributes_ua'] = json.loads(product['attributes_ua'] or '{}') + product['images'] = json.loads(product['images'] or '[]') + product['local_images'] = json.loads(product['local_images'] or '[]') + return product + + return None + + def get_product_by_id(self, product_id): + """Получает товар по ID""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM products WHERE id = ?", (product_id,)) + row = cursor.fetchone() + + if row: + product = dict(row) + product['attributes'] = json.loads(product['attributes'] or '{}') + product['attributes_ua'] = json.loads(product['attributes_ua'] or '{}') + product['images'] = json.loads(product['images'] or '[]') + product['local_images'] = json.loads(product['local_images'] or '[]') + return product + + return None + + def update_product(self, product_id, product_data): + """Обновляет товар""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + UPDATE products SET + title = ?, title_ua = ?, price = ?, availability = ?, + description = ?, description_ua = ?, attributes = ?, attributes_ua = ?, + category = ?, brand = ?, model = ?, sku = ?, images = ?, local_images = ?, + content_hash = ?, is_translated = ?, updated_at = ? + WHERE id = ? + """, ( + product_data['title'], + product_data.get('title_ua', ''), + product_data['price'], + product_data['availability'], + product_data['description'], + product_data.get('description_ua', ''), + json.dumps(product_data.get('attributes', {}), ensure_ascii=False), + json.dumps(product_data.get('attributes_ua', {}), ensure_ascii=False), + product_data.get('category', ''), + product_data.get('brand', ''), + product_data.get('model', ''), + product_data.get('sku', ''), + json.dumps(product_data.get('images', [])), + json.dumps(product_data.get('local_images', [])), + product_data.get('content_hash', ''), + product_data.get('is_translated', False), + datetime.now().isoformat(), + product_id + )) + + def get_active_categories(self): + """Получает список активных категорий для парсинга""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM categories WHERE is_active = 1") + return [dict(row) for row in cursor.fetchall()] + + def add_category(self, name, url): + """Добавляет категорию""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT OR REPLACE INTO categories (name, url) VALUES (?, ?) + """, (name, url)) + + def deactivate_category(self, category_id): + """Деактивирует категорию""" + with sqlite3.connect(self.db_path) as conn: + conn.execute("UPDATE categories SET is_active = 0 WHERE id = ?", (category_id,)) + + def get_translation_from_cache(self, original_text): + """Получает перевод из кеша""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute( + "SELECT translated_text FROM translation_cache WHERE original_text = ?", + (original_text,) + ) + row = cursor.fetchone() + return row[0] if row else None + + def save_translation_to_cache(self, original_text, translated_text): + """Сохраняет перевод в кеш""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT OR REPLACE INTO translation_cache (original_text, translated_text) + VALUES (?, ?) + """, (original_text, translated_text)) + + def get_products_for_feed(self): + """Получает товары для генерации фида""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT * FROM products + WHERE is_active = 1 AND is_translated = 1 AND price > 0 + ORDER BY updated_at DESC + """) + + products = [] + for row in cursor.fetchall(): + product = dict(row) + product['attributes'] = json.loads(product['attributes'] or '{}') + product['attributes_ua'] = json.loads(product['attributes_ua'] or '{}') + product['images'] = json.loads(product['images'] or '[]') + product['local_images'] = json.loads(product['local_images'] or '[]') + products.append(product) + + return products + + def log_parsing_session(self, category_url, stats): + """Логирует сессию парсинга""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT INTO parsing_logs + (category_url, products_found, products_new, products_updated, errors_count, started_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + category_url, + stats.get('found', 0), + stats.get('new', 0), + stats.get('updated', 0), + stats.get('errors', 0), + stats.get('started_at'), + stats.get('completed_at') + )) + + def get_parsing_stats(self, days=30): + """Получает статистику парсинга за последние дни""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT * FROM parsing_logs + WHERE completed_at > datetime('now', '-{} days') + ORDER BY completed_at DESC + """.format(days)) + + return [dict(row) for row in cursor.fetchall()] diff --git a/modules/translator.py b/modules/translator.py new file mode 100644 index 0000000..1ac014d --- /dev/null +++ b/modules/translator.py @@ -0,0 +1,154 @@ +# modules/translator.py +""" +Модуль для перевода текста с кешем +""" + +import hashlib +import logging +import time +from abc import ABC, abstractmethod + + +class TranslationProvider(ABC): + """Абстрактный класс для провайдеров перевода""" + + @abstractmethod + def translate(self, text, source_lang, target_lang): + pass + + +class GoogleTranslateProvider(TranslationProvider): + """Провайдер Google Translate""" + + def __init__(self, api_key): + self.api_key = api_key + + def translate(self, text, source_lang='pl', target_lang='uk'): + try: + from googletrans import Translator + translator = Translator() + result = translator.translate(text, src=source_lang, dest=target_lang) + return result.text + except Exception as e: + logging.error(f"Google Translate error: {e}") + raise + + +class DeepLProvider(TranslationProvider): + """Провайдер DeepL""" + + def __init__(self, api_key): + self.api_key = api_key + + def translate(self, text, source_lang='PL', target_lang='UK'): + try: + import deepl + translator = deepl.Translator(self.api_key) + result = translator.translate_text(text, source_lang=source_lang, target_lang=target_lang) + return result.text + except Exception as e: + logging.error(f"DeepL error: {e}") + raise + + +class LibreTranslateProvider(TranslationProvider): + """Провайдер LibreTranslate""" + + def __init__(self, url, api_key=None): + self.url = url + self.api_key = api_key + + def translate(self, text, source_lang='pl', target_lang='uk'): + try: + import requests + + data = { + 'q': text, + 'source': source_lang, + 'target': target_lang, + 'format': 'text' + } + + if self.api_key: + data['api_key'] = self.api_key + + response = requests.post(f"{self.url}/translate", data=data) + response.raise_for_status() + + return response.json()['translatedText'] + except Exception as e: + logging.error(f"LibreTranslate error: {e}") + raise + + +class TranslationService: + """Сервис для перевода с кешем""" + + def __init__(self, config, storage): + self.config = config + self.storage = storage + self.logger = logging.getLogger(__name__) + + # Инициализация провайдера + self.provider = self._init_provider() + + # Настройки + self.cache_enabled = config.get('translation.cache_enabled', True) + self.source_lang = config.get('translation.google.source_lang', 'pl') + self.target_lang = config.get('translation.google.target_lang', 'uk') + + def _init_provider(self): + """Инициализирует провайдера перевода""" + service = self.config.get('translation.service', 'google') + + if service == 'google': + api_key = self.config.get('translation.google.api_key') + if not api_key: + self.logger.warning("Google Translate API key not found, using googletrans library") + return GoogleTranslateProvider(api_key) + + elif service == 'deepl': + api_key = self.config.get('translation.deepl.api_key') + if not api_key: + raise ValueError("DeepL API key is required") + return DeepLProvider(api_key) + + elif service == 'libretranslate': + url = self.config.get('translation.libretranslate.url') + api_key = self.config.get('translation.libretranslate.api_key') + return LibreTranslateProvider(url, api_key) + + else: + raise ValueError(f"Unsupported translation service: {service}") + + def translate(self, text): + """Переводит текст с использованием кеша""" + if not text or not text.strip(): + return text + + text = text.strip() + + # Проверяем кеш + if self.cache_enabled: + cached = self.storage.get_translation_from_cache(text) + if cached: + return cached + + try: + # Переводим + translated = self.provider.translate(text, self.source_lang, self.target_lang) + + # Сохраняем в кеш + if self.cache_enabled and translated: + self.storage.save_translation_to_cache(text, translated) + + # Небольшая пауза чтобы не превысить лимиты API + time.sleep(0.1) + + return translated + + except Exception as e: + self.logger.error(f"Translation failed for text '{text[:50]}...': {e}") + return text # Возвращаем оригинальный текст при ошибке + + diff --git a/monitor_health.py b/monitor_health.py new file mode 100644 index 0000000..19b0c33 --- /dev/null +++ b/monitor_health.py @@ -0,0 +1,333 @@ +# monitor_health.py +""" +Скрипт мониторинга здоровья парсера +""" + +import requests +import sqlite3 +import logging +import json +import smtplib +from email.mime.text import MIMEText +from datetime import datetime, timedelta +from pathlib import Path +import sys + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +from config import Config + + +class HealthMonitor: + """Мониторинг здоровья парсера""" + + def __init__(self, config_path="config.yaml"): + self.config = Config(config_path) + self.logger = self._setup_logger() + self.alerts = [] + + def _setup_logger(self): + """Настройка логирования""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler("logs/health_monitor.log"), + logging.StreamHandler(), + ], + ) + return logging.getLogger(__name__) + + def check_admin_panel(self): + """Проверка работы админ-панели""" + try: + response = requests.get("http://localhost:5000", timeout=10) + if response.status_code == 200: + self.logger.info("✅ Admin panel is healthy") + return True + else: + self.alerts.append( + f"Admin panel returned status {response.status_code}" + ) + return False + except Exception as e: + self.alerts.append(f"Admin panel is not responding: {e}") + return False + + def check_database(self): + """Проверка состояния базы данных""" + try: + db_path = self.config.get("database.sqlite_path") + + if not Path(db_path).exists(): + self.alerts.append("Database file does not exist") + return False + + with sqlite3.connect(db_path) as conn: + cursor = conn.execute("SELECT COUNT(*) FROM products") + product_count = cursor.fetchone()[0] + + cursor = conn.execute( + "SELECT COUNT(*) FROM categories WHERE is_active = 1" + ) + active_categories = cursor.fetchone()[0] + + self.logger.info( + f"✅ Database healthy: {product_count} products, {active_categories} active categories" + ) + + if active_categories == 0: + self.alerts.append("No active categories found") + + return True + + except Exception as e: + self.alerts.append(f"Database check failed: {e}") + return False + + def check_recent_parsing(self, hours=25): + """Проверка недавнего парсинга""" + try: + db_path = self.config.get("database.sqlite_path") + cutoff_time = datetime.now() - timedelta(hours=hours) + + with sqlite3.connect(db_path) as conn: + cursor = conn.execute( + "SELECT COUNT(*) FROM parsing_logs WHERE completed_at > ?", + (cutoff_time.isoformat(),), + ) + recent_sessions = cursor.fetchone()[0] + + if recent_sessions > 0: + self.logger.info( + f"✅ Recent parsing activity: {recent_sessions} sessions in last {hours}h" + ) + return True + else: + self.alerts.append(f"No parsing activity in last {hours} hours") + return False + + except Exception as e: + self.alerts.append(f"Parsing check failed: {e}") + return False + + def check_disk_space(self, warning_threshold=80, critical_threshold=90): + """Проверка свободного места на диске""" + try: + import shutil + + total, used, free = shutil.disk_usage("/") + used_percent = (used / total) * 100 + + if used_percent >= critical_threshold: + self.alerts.append(f"CRITICAL: Disk usage {used_percent:.1f}%") + return False + elif used_percent >= warning_threshold: + self.alerts.append(f"WARNING: Disk usage {used_percent:.1f}%") + return True + else: + self.logger.info(f"✅ Disk usage: {used_percent:.1f}%") + return True + + except Exception as e: + self.alerts.append(f"Disk space check failed: {e}") + return False + + def check_feed_freshness(self, hours=26): + """Проверка актуальности фида""" + try: + feed_path = Path(self.config.get("feed.output_path", "feeds/prom_feed.yml")) + + if not feed_path.exists(): + self.alerts.append("Feed file does not exist") + return False + + file_age = datetime.now() - datetime.fromtimestamp( + feed_path.stat().st_mtime + ) + + if file_age.total_seconds() > hours * 3600: + self.alerts.append(f"Feed is {file_age.days} days old") + return False + else: + self.logger.info( + f"✅ Feed is fresh ({file_age.total_seconds() / 3600:.1f}h old)" + ) + return True + + except Exception as e: + self.alerts.append(f"Feed freshness check failed: {e}") + return False + + def check_log_errors(self, hours=24): + """Проверка ошибок в логах""" + try: + log_path = Path("logs/parser.log") + + if not log_path.exists(): + return True + + cutoff_time = datetime.now() - timedelta(hours=hours) + error_count = 0 + + with open(log_path, "r", encoding="utf-8") as f: + for line in f: + if "ERROR" in line: + # Простая проверка времени в логе + try: + # Предполагаем формат: YYYY-MM-DD HH:MM:SS + time_str = line[:19] + log_time = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S") + if log_time > cutoff_time: + error_count += 1 + except: + continue + + if error_count > 10: + self.alerts.append( + f"High error count in logs: {error_count} errors in last {hours}h" + ) + return False + elif error_count > 0: + self.logger.info(f"⚠️ {error_count} errors in logs (last {hours}h)") + else: + self.logger.info(f"✅ No errors in logs (last {hours}h)") + + return True + + except Exception as e: + self.alerts.append(f"Log check failed: {e}") + return False + + def send_alerts(self): + """Отправка уведомлений о проблемах""" + if not self.alerts: + return + + # Telegram уведомления + if self.config.get("telegram.enabled"): + self._send_telegram_alert() + + # Email уведомления (если настроены) + email_config = self.config.get("email") + if email_config and email_config.get("enabled"): + self._send_email_alert() + + # Логирование всех алертов + for alert in self.alerts: + self.logger.error(f"ALERT: {alert}") + + def _send_telegram_alert(self): + """Отправка в Telegram""" + try: + bot_token = self.config.get("telegram.bot_token") + chat_id = self.config.get("telegram.chat_id") + + if not bot_token or not chat_id: + return + + message = "🚨 Morele Parser Health Alert\n\n" + message += "\n".join([f"• {alert}" for alert in self.alerts]) + message += f"\n\n📅 {datetime.now().strftime('%d.%m.%Y %H:%M')}" + + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + data = {"chat_id": chat_id, "text": message, "parse_mode": "HTML"} + + response = requests.post(url, data=data, timeout=10) + if response.status_code == 200: + self.logger.info("Alert sent to Telegram") + + except Exception as e: + self.logger.error(f"Failed to send Telegram alert: {e}") + + def _send_email_alert(self): + """Отправка email уведомления""" + try: + email_config = self.config.get("email") + + msg = MIMEText("\n".join(self.alerts)) + msg["Subject"] = "Morele Parser Health Alert" + msg["From"] = email_config["from"] + msg["To"] = email_config["to"] + + with smtplib.SMTP( + email_config["smtp_host"], email_config["smtp_port"] + ) as server: + if email_config.get("use_tls"): + server.starttls() + + if email_config.get("username"): + server.login(email_config["username"], email_config["password"]) + + server.send_message(msg) + + self.logger.info("Alert sent via email") + + except Exception as e: + self.logger.error(f"Failed to send email alert: {e}") + + def run_health_check(self): + """Запуск полной проверки здоровья""" + self.logger.info("Starting health check...") + + checks = [ + self.check_admin_panel, + self.check_database, + self.check_recent_parsing, + self.check_disk_space, + self.check_feed_freshness, + self.check_log_errors, + ] + + results = [] + for check in checks: + try: + result = check() + results.append(result) + except Exception as e: + self.logger.error(f"Health check failed: {e}") + results.append(False) + + # Отправляем алерты если есть проблемы + if self.alerts: + self.send_alerts() + + # Возвращаем общий статус + overall_health = all(results) + + if overall_health: + self.logger.info("✅ All health checks passed") + else: + self.logger.error("❌ Some health checks failed") + + return overall_health + + +if __name__ == "__main__": + # Основная функция для health monitor + def main(): + monitor = HealthMonitor() + + import argparse + + parser = argparse.ArgumentParser(description="Morele Parser Health Monitor") + parser.add_argument("--config", default="config.yaml", help="Config file path") + parser.add_argument( + "--check-only", action="store_true", help="Only check, do not send alerts" + ) + + args = parser.parse_args() + + monitor = HealthMonitor(args.config) + + if args.check_only: + # Отключаем отправку алертов + original_send = monitor.send_alerts + monitor.send_alerts = lambda: None + + health_status = monitor.run_health_check() + + # Код выхода для использования в мониторинге + sys.exit(0 if health_status else 1) + + if len(sys.argv) > 1 and sys.argv[1] == "health": + main() diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..c7ad570 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,101 @@ +# nginx.conf +# Конфигурация Nginx для раздачи изображений и проксирования админки + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Логирование + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log; + + # Настройки + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 50M; + + # Gzip сжатие + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript + application/javascript application/xml+rss + application/json image/svg+xml; + + # Upstream для админки + upstream morele_admin { + server 127.0.0.1:5000; + } + + server { + listen 80; + server_name your-domain.com www.your-domain.com; + + # Редирект на HTTPS (если используется) + # return 301 https://$server_name$request_uri; + + # Раздача изображений + location /images/ { + alias /path/to/morele_parser/images/; + + # Кеширование + expires 7d; + add_header Cache-Control "public, immutable"; + + # Оптимизация + location ~* \.(jpg|jpeg|png|webp)$ { + add_header Vary Accept; + try_files $uri $uri/ =404; + } + } + + # Раздача фидов + location /feeds/ { + alias /path/to/morele_parser/feeds/; + + # Только для авторизованных пользователей + # auth_basic "Restricted Area"; + # auth_basic_user_file /etc/nginx/.htpasswd; + + # CORS для фидов + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + } + + # Админка + location /admin/ { + proxy_pass http://morele_admin/; + 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; + + # Ограничение доступа по IP + # allow 192.168.1.0/24; + # deny all; + } + + # Здоровье сервиса + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Основная страница (перенаправление на админку) + location / { + return 302 /admin/; + } + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8d9ef74 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +# requirements.txt +# Основные зависимости для парсера morele.net + +# Веб-запросы и парсинг +requests>=2.31.0 +beautifulsoup4>=4.12.0 +lxml>=4.9.0 + +# Работа с изображениями +Pillow>=10.0.0 + +# Работа с конфигурацией +PyYAML>=6.0 + +# Веб-админка +Flask>=2.3.0 + +# Переводчики (опционально, один на выбор) +googletrans==4.0.0rc1 # Бесплатный Google Translate +# deepl>=1.15.0 # Для DeepL (требует API ключ) + +# Для работы с Excel (если понадобится) +openpyxl>=3.1.0 diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 0000000..6c2051b --- /dev/null +++ b/run_tests.py @@ -0,0 +1,27 @@ +# run_tests.py +""" +Запуск тестов +""" + +import unittest +import sys +import os + + +def run_tests(): + """Запуск всех тестов""" + # Поиск всех тестов + loader = unittest.TestLoader() + start_dir = os.path.dirname(os.path.abspath(__file__)) + suite = loader.discover(start_dir, pattern="test_*.py") + + # Запуск тестов + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Возврат кода выхода + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + sys.exit(run_tests()) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5185371 --- /dev/null +++ b/setup.py @@ -0,0 +1,59 @@ +# setup.py +""" +Установочный скрипт для парсера +""" + +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +with open("requirements.txt", "r", encoding="utf-8") as fh: + requirements = [ + line.strip() for line in fh if line.strip() and not line.startswith("#") + ] + +setup( + name="morele-parser", + version="1.0.0", + author="Your Name", + author_email="your.email@example.com", + description="Professional parser for morele.net with Ukrainian translation", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/yourusername/morele-parser", + packages=find_packages(), + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Internet :: WWW/HTTP :: Indexing/Search", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + python_requires=">=3.8", + install_requires=requirements, + extras_require={ + "dev": [ + "pytest>=7.0.0", + "black>=22.0.0", + "flake8>=4.0.0", + "mypy>=0.900", + ], + "deepl": ["deepl>=1.15.0"], + "mysql": ["PyMySQL>=1.0.0"], + }, + entry_points={ + "console_scripts": [ + "morele-parser=main:main", + "morele-tools=cli_tools:main", + "morele-health=monitor_health:main", + ], + }, + include_package_data=True, + package_data={ + "": ["*.yaml", "*.yml", "*.json", "*.txt"], + }, + zip_safe=False, +) diff --git a/test_parser.py b/test_parser.py new file mode 100644 index 0000000..b6730e2 --- /dev/null +++ b/test_parser.py @@ -0,0 +1,160 @@ +# test_parser.py +""" +Тестирование функциональности парсера +""" + +import unittest +import tempfile +import os +import json +from pathlib import Path +import sys + +# Добавляем путь к модулям +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from config import Config +from modules.storage import StorageManager +from modules.translator import TranslationService +from modules.feed_generator import FeedGenerator + + +class TestConfig(unittest.TestCase): + """Тестирование конфигурации""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.config_path = os.path.join(self.temp_dir, 'test_config.yaml') + + def test_config_creation(self): + """Тест создания конфигурации""" + config = Config(self.config_path) + + # Проверяем, что файл создался + self.assertTrue(os.path.exists(self.config_path)) + + # Проверяем базовые настройки + self.assertIsNotNone(config.get('database.type')) + self.assertIsNotNone(config.get('parsing.user_agent')) + + def test_config_get_set(self): + """Тест получения и установки значений""" + config = Config(self.config_path) + + # Установка значения + config.set('test.value', 'test_data') + self.assertEqual(config.get('test.value'), 'test_data') + + # Получение несуществующего значения + self.assertIsNone(config.get('nonexistent.key')) + self.assertEqual(config.get('nonexistent.key', 'default'), 'default') + + +class TestStorage(unittest.TestCase): + """Тестирование хранилища""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.config = Config() + self.config.config['database']['sqlite_path'] = os.path.join(self.temp_dir, 'test.db') + self.storage = StorageManager(self.config) + + def test_product_operations(self): + """Тест операций с товарами""" + # Тестовый товар + product = { + 'id': 'test_123', + 'url': 'https://test.com/product/123', + 'title': 'Test Product', + 'price': 99.99, + 'availability': 'в наличии', + 'description': 'Test description', + 'category': 'Test Category', + 'images': ['https://test.com/img1.jpg'], + 'content_hash': 'test_hash' + } + + # Сохранение товара + self.storage.save_product(product) + + # Получение товара + saved_product = self.storage.get_product_by_url(product['url']) + self.assertIsNotNone(saved_product) + self.assertEqual(saved_product['title'], product['title']) + self.assertEqual(saved_product['price'], product['price']) + + def test_category_operations(self): + """Тест операций с категориями""" + # Добавление категории + self.storage.add_category('Test Category', 'https://test.com/category') + + # Получение активных категорий + categories = self.storage.get_active_categories() + self.assertEqual(len(categories), 1) + self.assertEqual(categories[0]['name'], 'Test Category') + + def test_translation_cache(self): + """Тест кеша переводов""" + original = "Test text" + translated = "Тестовый текст" + + # Сохранение в кеш + self.storage.save_translation_to_cache(original, translated) + + # Получение из кеша + cached = self.storage.get_translation_from_cache(original) + self.assertEqual(cached, translated) + + # Несуществующий перевод + not_found = self.storage.get_translation_from_cache("Not found") + self.assertIsNone(not_found) + + +class TestFeedGenerator(unittest.TestCase): + """Тестирование генератора фида""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.config = Config() + self.config.config['database']['sqlite_path'] = os.path.join(self.temp_dir, 'test.db') + self.config.config['feed']['output_path'] = os.path.join(self.temp_dir, 'test_feed.yml') + + self.storage = StorageManager(self.config) + self.feed_generator = FeedGenerator(self.config, self.storage) + + # Добавляем тестовый товар + product = { + 'external_id': 'test_123', + 'url': 'https://test.com/product/123', + 'title': 'Test Product', + 'title_ua': 'Тестовий товар', + 'price': 100.0, + 'availability': 'в наличии', + 'description': 'Test description', + 'description_ua': 'Тестовий опис', + 'category': 'Test Category', + 'brand': 'Test Brand', + 'images': ['https://test.com/img1.jpg'], + 'local_images': ['images/test_123/img1.jpg'], + 'is_translated': True, + 'is_active': True + } + + self.storage.save_product(product) + + def test_feed_generation(self): + """Тест генерации фида""" + self.feed_generator.generate_yml_feed() + + # Проверяем, что файл создался + feed_path = self.config.get('feed.output_path') + self.assertTrue(os.path.exists(feed_path)) + + # Проверяем содержимое + with open(feed_path, 'r', encoding='utf-8') as f: + content = f.read() + + self.assertIn('yml_catalog', content) + self.assertIn('shop', content) + self.assertIn('offers', content) + self.assertIn('Тестовий товар', content) diff --git a/utils/db_manager.py b/utils/db_manager.py new file mode 100644 index 0000000..07bdb9b --- /dev/null +++ b/utils/db_manager.py @@ -0,0 +1,93 @@ +# utils/db_manager.py +""" +Утилиты для управления базой данных +""" + +import sqlite3 +import json +from pathlib import Path +from datetime import datetime, timedelta + + +class DatabaseManager: + """Управление базой данных парсера""" + + def __init__(self, db_path): + self.db_path = db_path + + def backup_database(self, backup_dir='backups'): + """Создаёт резервную копию базы данных""" + backup_path = Path(backup_dir) + backup_path.mkdir(exist_ok=True) + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_file = backup_path / f"morele_parser_backup_{timestamp}.db" + + # Копируем базу данных + import shutil + shutil.copy2(self.db_path, backup_file) + + print(f"Backup created: {backup_file}") + return backup_file + + def cleanup_old_data(self, days=30): + """Очищает старые данные""" + cutoff_date = datetime.now() - timedelta(days=days) + + with sqlite3.connect(self.db_path) as conn: + # Удаляем старые логи парсинга + cursor = conn.execute(""" + DELETE FROM parsing_logs + WHERE completed_at < ? + """, (cutoff_date.isoformat(),)) + + deleted_logs = cursor.rowcount + + # Очищаем кеш переводов старше определённого времени + cursor = conn.execute(""" + DELETE FROM translation_cache + WHERE created_at < ? + """, (cutoff_date.isoformat(),)) + + deleted_cache = cursor.rowcount + + print(f"Deleted {deleted_logs} old parsing logs") + print(f"Deleted {deleted_cache} old translation cache entries") + + def optimize_database(self): + """Оптимизирует базу данных""" + with sqlite3.connect(self.db_path) as conn: + conn.execute("VACUUM") + conn.execute("ANALYZE") + + print("Database optimized") + + def get_database_stats(self): + """Получает статистику базы данных""" + with sqlite3.connect(self.db_path) as conn: + stats = {} + + # Количество товаров + cursor = conn.execute("SELECT COUNT(*) FROM products") + stats['total_products'] = cursor.fetchone()[0] + + cursor = conn.execute("SELECT COUNT(*) FROM products WHERE is_active = 1") + stats['active_products'] = cursor.fetchone()[0] + + cursor = conn.execute("SELECT COUNT(*) FROM products WHERE is_translated = 1") + stats['translated_products'] = cursor.fetchone()[0] + + # Количество категорий + cursor = conn.execute("SELECT COUNT(*) FROM categories WHERE is_active = 1") + stats['active_categories'] = cursor.fetchone()[0] + + # Размер кеша переводов + cursor = conn.execute("SELECT COUNT(*) FROM translation_cache") + stats['translation_cache_size'] = cursor.fetchone()[0] + + # Размер файла БД + db_file = Path(self.db_path) + if db_file.exists(): + stats['db_size_mb'] = round(db_file.stat().st_size / 1024 / 1024, 2) + + return stats diff --git a/utils/feed_validator.py b/utils/feed_validator.py new file mode 100644 index 0000000..a4912d6 --- /dev/null +++ b/utils/feed_validator.py @@ -0,0 +1,131 @@ +# utils/feed_validator.py +""" +Валидатор YML фида +""" + +import xml.etree.ElementTree as ET +from pathlib import Path + + +class FeedValidator: + """Валидатор YML фида для Prom.ua""" + + def __init__(self): + self.errors = [] + self.warnings = [] + + def validate_feed(self, feed_path): + """Валидирует YML фид""" + self.errors = [] + self.warnings = [] + + try: + tree = ET.parse(feed_path) + root = tree.getroot() + + # Проверяем структуру + self._validate_structure(root) + + # Проверяем offers + offers = root.find('.//offers') + if offers is not None: + self._validate_offers(offers) + + # Проверяем категории + categories = root.find('.//categories') + if categories is not None: + self._validate_categories(categories) + + return len(self.errors) == 0 + + except ET.ParseError as e: + self.errors.append(f"XML parsing error: {e}") + return False + except Exception as e: + self.errors.append(f"Validation error: {e}") + return False + + def _validate_structure(self, root): + """Проверяет основную структуру""" + if root.tag != 'yml_catalog': + self.errors.append("Root element must be 'yml_catalog'") + + shop = root.find('shop') + if shop is None: + self.errors.append("Missing 'shop' element") + return + + required_elements = ['name', 'company', 'currencies', 'categories', 'offers'] + for element in required_elements: + if shop.find(element) is None: + self.errors.append(f"Missing required element: {element}") + + def _validate_offers(self, offers): + """Проверяет offers""" + offer_count = 0 + + for offer in offers.findall('offer'): + offer_count += 1 + offer_id = offer.get('id') + + if not offer_id: + self.errors.append(f"Offer {offer_count} missing id attribute") + + # Проверяем обязательные поля + required_fields = ['name', 'price', 'currencyId'] + for field in required_fields: + if offer.find(field) is None: + self.errors.append(f"Offer {offer_id} missing required field: {field}") + + # Проверяем цену + price_elem = offer.find('price') + if price_elem is not None: + try: + price = float(price_elem.text) + if price <= 0: + self.errors.append(f"Offer {offer_id} has invalid price: {price}") + except ValueError: + self.errors.append(f"Offer {offer_id} has non-numeric price") + + # Проверяем изображения + pictures = offer.findall('picture') + if not pictures: + self.warnings.append(f"Offer {offer_id} has no images") + + def _validate_categories(self, categories): + """Проверяет категории""" + category_ids = set() + + for category in categories.findall('category'): + cat_id = category.get('id') + + if not cat_id: + self.errors.append("Category missing id attribute") + continue + + if cat_id in category_ids: + self.errors.append(f"Duplicate category id: {cat_id}") + + category_ids.add(cat_id) + + if not category.text or not category.text.strip(): + self.errors.append(f"Category {cat_id} has empty name") + + def get_report(self): + """Возвращает отчёт валидации""" + report = [] + + if self.errors: + report.append("ERRORS:") + for error in self.errors: + report.append(f" - {error}") + + if self.warnings: + report.append("WARNINGS:") + for warning in self.warnings: + report.append(f" - {warning}") + + if not self.errors and not self.warnings: + report.append("Feed is valid!") + + return '\n'.join(report) diff --git a/utils/image_optimizer.py b/utils/image_optimizer.py new file mode 100644 index 0000000..eaeea57 --- /dev/null +++ b/utils/image_optimizer.py @@ -0,0 +1,66 @@ +# utils/image_optimizer.py +""" +Утилиты для оптимизации изображений +""" + +from PIL import Image +import os +from pathlib import Path +import logging + + +class ImageOptimizer: + """Оптимизатор изображений""" + + def __init__(self, quality=85, max_size=(1200, 1200)): + self.quality = quality + self.max_size = max_size + self.logger = logging.getLogger(__name__) + + def optimize_image(self, image_path, output_path=None): + """Оптимизирует одно изображение""" + if output_path is None: + output_path = image_path + + try: + with Image.open(image_path) as img: + # Конвертируем в RGB если нужно + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + if img.mode in ('RGBA', 'LA'): + background.paste(img, mask=img.split()[-1]) + img = background + + # Изменяем размер если нужно + if img.size[0] > self.max_size[0] or img.size[1] > self.max_size[1]: + img.thumbnail(self.max_size, Image.Resampling.LANCZOS) + + # Сохраняем с оптимизацией + img.save(output_path, 'JPEG', quality=self.quality, optimize=True) + + return True + + except Exception as e: + self.logger.error(f"Error optimizing image {image_path}: {e}") + return False + + def optimize_directory(self, directory_path, extensions=None): + """Оптимизирует все изображения в директории""" + if extensions is None: + extensions = ['.jpg', '.jpeg', '.png', '.webp'] + + directory = Path(directory_path) + optimized_count = 0 + error_count = 0 + + for file_path in directory.rglob('*'): + if file_path.suffix.lower() in extensions: + if self.optimize_image(file_path): + optimized_count += 1 + else: + error_count += 1 + + print(f"Optimized {optimized_count} images, {error_count} errors") + return optimized_count, error_count diff --git a/utils/monitor.py b/utils/monitor.py new file mode 100644 index 0000000..bee2311 --- /dev/null +++ b/utils/monitor.py @@ -0,0 +1,60 @@ +# utils/monitor.py +""" +Система мониторинга парсера +""" + +import psutil +import time +import json +from datetime import datetime +from pathlib import Path + + +class SystemMonitor: + """Мониторинг системных ресурсов""" + + def __init__(self, log_file='logs/monitoring.log'): + self.log_file = Path(log_file) + self.log_file.parent.mkdir(exist_ok=True) + + def get_system_stats(self): + """Получает статистику системы""" + stats = { + 'timestamp': datetime.now().isoformat(), + 'cpu_percent': psutil.cpu_percent(interval=1), + 'memory_percent': psutil.virtual_memory().percent, + 'disk_usage': psutil.disk_usage('/').percent, + 'network_io': dict(psutil.net_io_counters()._asdict()) if hasattr(psutil, 'net_io_counters') else {}, + 'process_count': len(psutil.pids()) + } + + return stats + + def log_stats(self): + """Записывает статистику в лог""" + stats = self.get_system_stats() + + with open(self.log_file, 'a', encoding='utf-8') as f: + f.write(json.dumps(stats, ensure_ascii=False) + '\n') + + def check_disk_space(self, warning_threshold=80, critical_threshold=90): + """Проверяет свободное место на диске""" + disk_usage = psutil.disk_usage('/').percent + + if disk_usage >= critical_threshold: + return 'critical', f"Критически мало места на диске: {disk_usage}%" + elif disk_usage >= warning_threshold: + return 'warning', f"Мало места на диске: {disk_usage}%" + else: + return 'ok', f"Место на диске: {disk_usage}%" + + def check_memory_usage(self, warning_threshold=80, critical_threshold=90): + """Проверяет использование памяти""" + memory_usage = psutil.virtual_memory().percent + + if memory_usage >= critical_threshold: + return 'critical', f"Критически высокое использование памяти: {memory_usage}%" + elif memory_usage >= warning_threshold: + return 'warning', f"Высокое использование памяти: {memory_usage}%" + else: + return 'ok', f"Использование памяти: {memory_usage}%"