first commit

This commit is contained in:
2025-06-18 21:22:55 +03:00
commit ad4d215f04
22 changed files with 3762 additions and 0 deletions

211
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
# modules/__init__.py
"""
Модули парсера morele.net
"""

362
modules/admin.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}%"