first commit
This commit is contained in:
211
README.md
Normal file
211
README.md
Normal file
@@ -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 переводчиков
|
||||||
|
- Законодательство о парсинге данных
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Удачного парсинга! 🚀**
|
||||||
159
cli_tools.py
Normal file
159
cli_tools.py
Normal file
@@ -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()
|
||||||
130
config.py
Normal file
130
config.py
Normal file
@@ -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)
|
||||||
406
deploy.sh
Normal file
406
deploy.sh
Normal file
@@ -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 <<EOF
|
||||||
|
[program:morele_parser_admin]
|
||||||
|
command=$PROJECT_DIR/venv/bin/python $PROJECT_DIR/main.py --admin --host 127.0.0.1 --port 5000
|
||||||
|
directory=$PROJECT_DIR
|
||||||
|
user=$SERVICE_USER
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/var/log/morele_parser/admin.log
|
||||||
|
stdout_logfile_maxbytes=10MB
|
||||||
|
stdout_logfile_backups=3
|
||||||
|
environment=PYTHONPATH="$PROJECT_DIR"
|
||||||
|
|
||||||
|
[program:morele_parser_monitor]
|
||||||
|
command=$PROJECT_DIR/venv/bin/python $PROJECT_DIR/cli_tools.py monitor
|
||||||
|
directory=$PROJECT_DIR
|
||||||
|
user=$SERVICE_USER
|
||||||
|
autostart=false
|
||||||
|
autorestart=false
|
||||||
|
startsecs=0
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/var/log/morele_parser/monitor.log
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo supervisorctl reread
|
||||||
|
sudo supervisorctl update
|
||||||
|
}
|
||||||
|
|
||||||
|
# Настройка Nginx
|
||||||
|
setup_nginx() {
|
||||||
|
log "Setting up Nginx..."
|
||||||
|
|
||||||
|
# Резервная копия существующей конфигурации
|
||||||
|
if [ -f "/etc/nginx/sites-available/default" ]; then
|
||||||
|
sudo cp /etc/nginx/sites-available/default /etc/nginx/sites-available/default.backup
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Создание конфигурации для парсера
|
||||||
|
sudo tee /etc/nginx/sites-available/morele_parser > /dev/null <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
# Раздача изображений
|
||||||
|
location /images/ {
|
||||||
|
alias $PROJECT_DIR/images/;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Раздача фидов
|
||||||
|
location /feeds/ {
|
||||||
|
alias $PROJECT_DIR/feeds/;
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Админка
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Активация сайта
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/morele_parser /etc/nginx/sites-enabled/
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# Проверка конфигурации
|
||||||
|
sudo nginx -t
|
||||||
|
}
|
||||||
|
|
||||||
|
# Настройка cron
|
||||||
|
setup_cron() {
|
||||||
|
log "Setting up cron jobs..."
|
||||||
|
|
||||||
|
sudo -u $SERVICE_USER crontab -l > /tmp/morele_cron 2>/dev/null || true
|
||||||
|
|
||||||
|
cat >> /tmp/morele_cron <<EOF
|
||||||
|
# Morele Parser Jobs
|
||||||
|
0 2 * * * cd $PROJECT_DIR && $PROJECT_DIR/venv/bin/python main.py --parse >> 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 <<EOF
|
||||||
|
$PROJECT_DIR/logs/*.log {
|
||||||
|
daily
|
||||||
|
missingok
|
||||||
|
rotate 30
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
su $SERVICE_USER $SERVICE_USER
|
||||||
|
}
|
||||||
|
|
||||||
|
/var/log/morele_parser/*.log {
|
||||||
|
daily
|
||||||
|
missingok
|
||||||
|
rotate 30
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
postrotate
|
||||||
|
supervisorctl restart morele_parser_admin
|
||||||
|
endscript
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Запуск сервисов
|
||||||
|
start_services() {
|
||||||
|
log "Starting services..."
|
||||||
|
|
||||||
|
sudo systemctl enable supervisor
|
||||||
|
sudo systemctl start supervisor
|
||||||
|
|
||||||
|
sudo systemctl enable nginx
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
|
||||||
|
sudo supervisorctl start morele_parser_admin
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверка установки
|
||||||
|
verify_installation() {
|
||||||
|
log "Verifying installation..."
|
||||||
|
|
||||||
|
# Проверка админки
|
||||||
|
if curl -s http://localhost:5000 > /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
|
||||||
273
main.py
Normal file
273
main.py
Normal file
@@ -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()
|
||||||
4
modules/__init__.py
Normal file
4
modules/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# modules/__init__.py
|
||||||
|
"""
|
||||||
|
Модули парсера morele.net
|
||||||
|
"""
|
||||||
362
modules/admin.py
Normal file
362
modules/admin.py
Normal file
@@ -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/<int:category_id>')
|
||||||
|
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 = '''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Morele.net Parser - Админка</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
.header { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.nav { margin-bottom: 20px; }
|
||||||
|
.nav a { background: #007bff; color: white; padding: 10px 15px; text-decoration: none; margin-right: 10px; border-radius: 4px; }
|
||||||
|
.nav a:hover { background: #0056b3; }
|
||||||
|
.card { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
|
||||||
|
.stat-item { text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; }
|
||||||
|
.stat-number { font-size: 2em; font-weight: bold; color: #007bff; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||||
|
th { background: #f8f9fa; }
|
||||||
|
.status-active { color: #28a745; font-weight: bold; }
|
||||||
|
.status-inactive { color: #dc3545; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔍 Morele.net Parser - Админка</h1>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="{{ url_for('index') }}">Главная</a>
|
||||||
|
<a href="{{ url_for('categories') }}">Категории</a>
|
||||||
|
<a href="{{ url_for('products') }}">Товары</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>📊 Статистика</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{{ categories|length }}</div>
|
||||||
|
<div>Активных категорий</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{{ stats|length }}</div>
|
||||||
|
<div>Сессий парсинга за неделю</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>📋 Последние сессии парсинга</h2>
|
||||||
|
{% if stats %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th>Найдено</th>
|
||||||
|
<th>Новых</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th>Ошибок</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for stat in stats[:10] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ stat.completed_at }}</td>
|
||||||
|
<td>{{ stat.category_url }}</td>
|
||||||
|
<td>{{ stat.products_found }}</td>
|
||||||
|
<td>{{ stat.products_new }}</td>
|
||||||
|
<td>{{ stat.products_updated }}</td>
|
||||||
|
<td>{{ stat.errors_count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>Пока нет данных о парсинге</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
'''
|
||||||
|
|
||||||
|
CATEGORIES_TEMPLATE = '''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Категории - Morele.net Parser</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
.header { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.nav { margin-bottom: 20px; }
|
||||||
|
.nav a { background: #007bff; color: white; padding: 10px 15px; text-decoration: none; margin-right: 10px; border-radius: 4px; }
|
||||||
|
.nav a:hover { background: #0056b3; }
|
||||||
|
.card { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.form-group { margin-bottom: 15px; }
|
||||||
|
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
|
||||||
|
.form-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
|
||||||
|
.btn { background: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; }
|
||||||
|
.btn:hover { background: #218838; }
|
||||||
|
.btn-danger { background: #dc3545; }
|
||||||
|
.btn-danger:hover { background: #c82333; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||||
|
th { background: #f8f9fa; }
|
||||||
|
.alert { padding: 15px; margin-bottom: 20px; border-radius: 4px; }
|
||||||
|
.alert-success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
|
||||||
|
.alert-error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>📁 Управление категориями</h1>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="{{ url_for('index') }}">Главная</a>
|
||||||
|
<a href="{{ url_for('categories') }}">Категории</a>
|
||||||
|
<a href="{{ url_for('products') }}">Товары</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>➕ Добавить категорию</h2>
|
||||||
|
<form method="POST" action="{{ url_for('add_category') }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Название категории:</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="url">URL категории на morele.net:</label>
|
||||||
|
<input type="url" id="url" name="url" required placeholder="https://www.morele.net/kategoria/...">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Добавить категорию</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>📋 Список категорий</h2>
|
||||||
|
{% if categories %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Дата добавления</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for category in categories %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ category.id }}</td>
|
||||||
|
<td>{{ category.name }}</td>
|
||||||
|
<td><a href="{{ category.url }}" target="_blank">{{ category.url[:50] }}...</a></td>
|
||||||
|
<td>{{ category.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('deactivate_category', category_id=category.id) }}"
|
||||||
|
class="btn btn-danger"
|
||||||
|
onclick="return confirm('Уверены, что хотите деактивировать эту категорию?')">
|
||||||
|
Деактивировать
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>Пока нет добавленных категорий. Добавьте первую категорию выше.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
'''
|
||||||
|
|
||||||
|
PRODUCTS_TEMPLATE = '''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Товары - Morele.net Parser</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
.header { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.nav { margin-bottom: 20px; }
|
||||||
|
.nav a { background: #007bff; color: white; padding: 10px 15px; text-decoration: none; margin-right: 10px; border-radius: 4px; }
|
||||||
|
.nav a:hover { background: #0056b3; }
|
||||||
|
.card { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||||
|
th { background: #f8f9fa; position: sticky; top: 0; }
|
||||||
|
.product-title { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.product-image { width: 50px; height: 50px; object-fit: cover; border-radius: 4px; }
|
||||||
|
.price { font-weight: bold; color: #28a745; }
|
||||||
|
.status-available { color: #28a745; }
|
||||||
|
.status-unavailable { color: #dc3545; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>📦 Товары</h1>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="{{ url_for('index') }}">Главная</a>
|
||||||
|
<a href="{{ url_for('categories') }}">Категории</a>
|
||||||
|
<a href="{{ url_for('products') }}">Товары</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>📋 Список товаров</h2>
|
||||||
|
{% if products %}
|
||||||
|
<p>Показано товаров: {{ products|length }}</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Изображение</th>
|
||||||
|
<th>Название (UA)</th>
|
||||||
|
<th>Цена (PLN)</th>
|
||||||
|
<th>Наличие</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th>Ссылка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for product in products %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if product.local_images %}
|
||||||
|
<img src="{{ product.local_images[0] }}" class="product-image" alt="Product image">
|
||||||
|
{% else %}
|
||||||
|
<div style="width: 50px; height: 50px; background: #f8f9fa; border-radius: 4px;"></div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="product-title" title="{{ product.title_ua }}">{{ product.title_ua }}</td>
|
||||||
|
<td class="price">{{ "%.2f"|format(product.price) }} PLN</td>
|
||||||
|
<td class="{% if 'наличии' in product.availability %}status-available{% else %}status-unavailable{% endif %}">
|
||||||
|
{{ product.availability }}
|
||||||
|
</td>
|
||||||
|
<td>{{ product.category }}</td>
|
||||||
|
<td>{{ product.updated_at[:16] }}</td>
|
||||||
|
<td><a href="{{ product.url }}" target="_blank">Открыть</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>Пока нет товаров. Запустите парсинг для получения товаров.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
'''
|
||||||
210
modules/feed_generator.py
Normal file
210
modules/feed_generator.py
Normal file
@@ -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)
|
||||||
126
modules/image_downloader.py
Normal file
126
modules/image_downloader.py
Normal file
@@ -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}")
|
||||||
402
modules/parser.py
Normal file
402
modules/parser.py
Normal file
@@ -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
|
||||||
272
modules/storage.py
Normal file
272
modules/storage.py
Normal file
@@ -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()]
|
||||||
154
modules/translator.py
Normal file
154
modules/translator.py
Normal file
@@ -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 # Возвращаем оригинальный текст при ошибке
|
||||||
|
|
||||||
|
|
||||||
333
monitor_health.py
Normal file
333
monitor_health.py
Normal file
@@ -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()
|
||||||
101
nginx.conf
Normal file
101
nginx.conf
Normal file
@@ -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/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
requirements.txt
Normal file
23
requirements.txt
Normal file
@@ -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
|
||||||
27
run_tests.py
Normal file
27
run_tests.py
Normal file
@@ -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())
|
||||||
59
setup.py
Normal file
59
setup.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
160
test_parser.py
Normal file
160
test_parser.py
Normal file
@@ -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)
|
||||||
93
utils/db_manager.py
Normal file
93
utils/db_manager.py
Normal file
@@ -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
|
||||||
131
utils/feed_validator.py
Normal file
131
utils/feed_validator.py
Normal file
@@ -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)
|
||||||
66
utils/image_optimizer.py
Normal file
66
utils/image_optimizer.py
Normal file
@@ -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
|
||||||
60
utils/monitor.py
Normal file
60
utils/monitor.py
Normal file
@@ -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}%"
|
||||||
Reference in New Issue
Block a user