first commit
This commit is contained in:
210
modules/feed_generator.py
Normal file
210
modules/feed_generator.py
Normal file
@@ -0,0 +1,210 @@
|
||||
# modules/feed_generator.py
|
||||
"""
|
||||
Модуль для генерации YML фида для Prom.ua
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.dom import minidom
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FeedGenerator:
|
||||
"""Генератор YML фида для Prom.ua"""
|
||||
|
||||
def __init__(self, config, storage):
|
||||
self.config = config
|
||||
self.storage = storage
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
self.output_path = Path(config.get('feed.output_path', 'feeds/prom_feed.yml'))
|
||||
self.shop_name = config.get('feed.shop_name', 'Ваш магазин')
|
||||
self.company = config.get('feed.company', 'Ваша компания')
|
||||
self.currency = config.get('feed.currency', 'UAH')
|
||||
self.margin_percent = config.get('feed.margin_percent', 10)
|
||||
|
||||
# Создаём директорию для фидов
|
||||
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def generate_yml_feed(self):
|
||||
"""Генерирует YML фид"""
|
||||
self.logger.info("Начинаем генерацию YML фида...")
|
||||
|
||||
# Получаем товары
|
||||
products = self.storage.get_products_for_feed()
|
||||
|
||||
if not products:
|
||||
self.logger.warning("Нет товаров для генерации фида")
|
||||
return
|
||||
|
||||
# Получаем курс валют
|
||||
pln_to_uah_rate = self._get_exchange_rate()
|
||||
|
||||
# Создаём XML структуру
|
||||
yml_catalog = ET.Element('yml_catalog', date=datetime.now().strftime('%Y-%m-%d %H:%M'))
|
||||
shop = ET.SubElement(yml_catalog, 'shop')
|
||||
|
||||
# Информация о магазине
|
||||
ET.SubElement(shop, 'name').text = self.shop_name
|
||||
ET.SubElement(shop, 'company').text = self.company
|
||||
ET.SubElement(shop, 'url').text = self.config.get('feed.shop_url', 'https://example.com')
|
||||
|
||||
# Валюты
|
||||
currencies = ET.SubElement(shop, 'currencies')
|
||||
currency = ET.SubElement(currencies, 'currency', id=self.currency, rate='1')
|
||||
|
||||
# Категории
|
||||
categories_elem = ET.SubElement(shop, 'categories')
|
||||
categories_map = self._build_categories(products, categories_elem)
|
||||
|
||||
# Товары
|
||||
offers = ET.SubElement(shop, 'offers')
|
||||
|
||||
for product in products:
|
||||
try:
|
||||
offer = self._create_offer(product, pln_to_uah_rate, categories_map)
|
||||
if offer is not None:
|
||||
offers.append(offer)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при создании оффера для товара {product['id']}: {e}")
|
||||
|
||||
# Сохраняем файл
|
||||
self._save_xml(yml_catalog)
|
||||
|
||||
self.logger.info(f"YML фид сгенерирован: {self.output_path}")
|
||||
self.logger.info(f"Товаров в фиде: {len(offers)}")
|
||||
|
||||
def _get_exchange_rate(self):
|
||||
"""Получает курс PLN к UAH"""
|
||||
rate_setting = self.config.get('feed.pln_to_uah_rate', 'auto')
|
||||
|
||||
if isinstance(rate_setting, (int, float)):
|
||||
return float(rate_setting)
|
||||
|
||||
try:
|
||||
# Пытаемся получить актуальный курс
|
||||
response = requests.get(
|
||||
'https://api.exchangerate-api.com/v4/latest/PLN',
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
rate = data['rates'].get('UAH', 10.0) # Fallback курс
|
||||
|
||||
self.logger.info(f"Получен курс PLN/UAH: {rate}")
|
||||
return rate
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка получения курса валют: {e}")
|
||||
return 10.0 # Fallback курс
|
||||
|
||||
def _build_categories(self, products, categories_elem):
|
||||
"""Строит список категорий"""
|
||||
categories = set()
|
||||
|
||||
for product in products:
|
||||
if product.get('category'):
|
||||
categories.add(product['category'])
|
||||
|
||||
categories_map = {}
|
||||
category_id = 1
|
||||
|
||||
for category_name in sorted(categories):
|
||||
category_elem = ET.SubElement(categories_elem, 'category', id=str(category_id))
|
||||
category_elem.text = category_name
|
||||
categories_map[category_name] = category_id
|
||||
category_id += 1
|
||||
|
||||
return categories_map
|
||||
|
||||
def _create_offer(self, product, pln_to_uah_rate, categories_map):
|
||||
"""Создаёт элемент offer для товара"""
|
||||
# Проверяем обязательные поля
|
||||
if not product.get('title_ua') or not product.get('price'):
|
||||
return None
|
||||
|
||||
# Рассчитываем цену в UAH с наценкой
|
||||
price_pln = float(product['price'])
|
||||
price_uah = price_pln * pln_to_uah_rate
|
||||
price_uah_with_margin = price_uah * (1 + self.margin_percent / 100)
|
||||
price_uah_final = round(price_uah_with_margin, 2)
|
||||
|
||||
# Создаём элемент offer
|
||||
offer = ET.Element('offer', id=str(product['external_id']), available='true')
|
||||
|
||||
# Основные поля
|
||||
ET.SubElement(offer, 'name').text = product['title_ua']
|
||||
ET.SubElement(offer, 'price').text = str(price_uah_final)
|
||||
ET.SubElement(offer, 'currencyId').text = self.currency
|
||||
|
||||
# Категория
|
||||
if product.get('category') and product['category'] in categories_map:
|
||||
ET.SubElement(offer, 'categoryId').text = str(categories_map[product['category']])
|
||||
|
||||
# Изображения
|
||||
if product.get('local_images'):
|
||||
for img_path in product['local_images'][:10]: # Ограничиваем количество
|
||||
# Преобразуем локальный путь в URL
|
||||
img_url = self._local_path_to_url(img_path)
|
||||
if img_url:
|
||||
ET.SubElement(offer, 'picture').text = img_url
|
||||
|
||||
# Описание
|
||||
description = product.get('description_ua', '').strip()
|
||||
if description:
|
||||
description_elem = ET.SubElement(offer, 'description')
|
||||
description_elem.text = description[:3000] # Ограничиваем длину
|
||||
|
||||
# Атрибуты товара
|
||||
if product.get('brand'):
|
||||
ET.SubElement(offer, 'vendor').text = product['brand']
|
||||
|
||||
if product.get('model'):
|
||||
ET.SubElement(offer, 'model').text = product['model']
|
||||
|
||||
if product.get('sku'):
|
||||
ET.SubElement(offer, 'vendorCode').text = product['sku']
|
||||
|
||||
# Характеристики
|
||||
if product.get('attributes_ua'):
|
||||
for attr_name, attr_value in product['attributes_ua'].items():
|
||||
if attr_name and attr_value and len(str(attr_value)) < 100:
|
||||
param = ET.SubElement(offer, 'param', name=str(attr_name))
|
||||
param.text = str(attr_value)
|
||||
|
||||
# Наличие
|
||||
stock_status = product.get('availability', '').lower()
|
||||
if 'нет' in stock_status or 'недоступ' in stock_status:
|
||||
offer.set('available', 'false')
|
||||
|
||||
# URL товара
|
||||
if product.get('url'):
|
||||
ET.SubElement(offer, 'url').text = product['url']
|
||||
|
||||
return offer
|
||||
|
||||
def _local_path_to_url(self, local_path):
|
||||
"""Преобразует локальный путь в URL"""
|
||||
# Здесь нужно настроить базовый URL вашего сервера
|
||||
base_url = self.config.get('feed.images_base_url', 'https://yoursite.com/')
|
||||
|
||||
if base_url.endswith('/'):
|
||||
base_url = base_url[:-1]
|
||||
|
||||
# Убираем начальную часть пути
|
||||
relative_path = str(local_path).replace('\\', '/')
|
||||
if relative_path.startswith('./'):
|
||||
relative_path = relative_path[2:]
|
||||
|
||||
return f"{base_url}/{relative_path}"
|
||||
|
||||
def _save_xml(self, root):
|
||||
"""Сохраняет XML в файл с красивым форматированием"""
|
||||
rough_string = ET.tostring(root, encoding='unicode')
|
||||
reparsed = minidom.parseString(rough_string)
|
||||
pretty_xml = reparsed.toprettyxml(indent=" ", encoding='utf-8')
|
||||
|
||||
with open(self.output_path, 'wb') as f:
|
||||
f.write(pretty_xml)
|
||||
Reference in New Issue
Block a user