diff --git a/categories.json b/categories.json index d80e027..4aaae61 100644 --- a/categories.json +++ b/categories.json @@ -1,7 +1,68 @@ [ { - "id": "1", - "name": "Роботи-пилососи", - "portal_id": "63023" + "id": "2", + "name": "Монітори", + "portal_id": "706", + "url": "https://www.euro.com.pl/monitory-led-i-lcd.bhtml" + }, + { + "id": "3", + "name": "Саундбар", + "portal_id": "518", + "url": "https://www.euro.com.pl/soundbar-speakerbar.bhtml" + }, + { + "id": "4", + "name": "Процесори", + "portal_id": "70701", + "url": "https://www.euro.com.pl/procesory.bhtml" + }, + { + "id": "5", + "name": "Навушники", + "portal_id": "63715", + "url": "https://www.euro.com.pl/sluchawki.bhtml" + }, + { + "id": "6", + "name": "Колонки", + "portal_id": "518", + "url": "https://www.euro.com.pl/glosniki-komputerowe.bhtml" + }, + { + "id": "7", + "name": "Ноутбуки", + "portal_id": "702", + "url": "https://www.euro.com.pl/laptopy-i-netbooki.bhtml" + }, + { + "id": "8", + "name": "Smart Watch Годинники", + "portal_id": "721", + "url": "https://www.euro.com.pl/smartwatch.bhtml" + }, + { + "id": "9", + "name": "Відеокарти", + "portal_id": "70703", + "url": "https://www.euro.com.pl/karty-graficzne.bhtml" + }, + { + "id": "10", + "name": "Проектори", + "portal_id": "6370701", + "url": "https://www.euro.com.pl/projektory-multimedialne.bhtml" + }, + { + "id": "11", + "name": "Сервери", + "portal_id": "70302", + "url": "https://www.euro.com.pl/chmura-osobista-dyski-sieciowe1.bhtml" + }, + { + "id": "12", + "name": "Роботи пилососи", + "portal_id": "63023", + "url": "https://www.euro.com.pl/odkurzacze-automatyczne.bhtml" } ] \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..f8201d5 --- /dev/null +++ b/config.py @@ -0,0 +1 @@ +BASE_URL = "https://mario.mrakells.com" diff --git a/feed_generator.py b/feed_generator.py index 7a975d3..22d2ff1 100644 --- a/feed_generator.py +++ b/feed_generator.py @@ -1,3 +1,5 @@ +from config import BASE_URL + import json import xml.etree.ElementTree as ET from typing import List, Dict @@ -9,8 +11,9 @@ class RobotVacuumYMLGenerator: def __init__( self, shop_name: str = "Euro Electronics", - base_url: str = "https://mario.mrakells.pp.ua", + base_url: str = BASE_URL, use_original_urls: bool = False, + categories_data: List[Dict] = None, ): """ Initialize YML feed generator @@ -29,6 +32,7 @@ class RobotVacuumYMLGenerator: self.categories = ET.SubElement(self.shop, "categories") self.offers = ET.SubElement(self.shop, "offers") + self.categories_data = categories_data or [] def add_category(self, category_id: str, category_name: str, parent_id: str = None): """ @@ -110,13 +114,17 @@ class RobotVacuumYMLGenerator: :param product: Product dictionary from JSON """ - in_stock = product.get('in_stock', False) + in_stock = product.get("in_stock", False) - offer = ET.SubElement(self.offers, 'offer', { - 'id': str(product['plu']), - 'available': 'true' if in_stock else 'false', - 'in_stock': 'true' if in_stock else 'false' - }) + offer = ET.SubElement( + self.offers, + "offer", + { + "id": str(product["plu"]), + "available": "true" if in_stock else "false", + "in_stock": "true" if in_stock else "false", + }, + ) # Clean product name before adding to feed cleaned_name = self.clean_product_name(product["name"]) @@ -134,6 +142,28 @@ class RobotVacuumYMLGenerator: product["portal_category_id"] ) # ОБОВ'ЯЗКОВО + # Додаємо keywords із назви категорії + category_name = "" + local_category_id = product.get("local_category_id") + if self.categories_data: + match = next( + ( + c + for c in self.categories_data + if str(c["id"]) == str(local_category_id) + ), + None, + ) + if match: + category_name = match["name"] + + keywords = product.get("keywords", "") + combined_keywords = category_name + if keywords: + combined_keywords += f", {keywords}" + + ET.SubElement(offer, "keywords").text = combined_keywords + # Description with images if "description" in product: description_html = "
" diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..b6d7d51 --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,82 @@ +body { + margin: 0; + padding: 0; + font-family: Arial, sans-serif; + background-color: #1a1b26; + color: #ffffff; +} + +.container { + width: 100%; + max-width: 400px; + margin: 100px auto; + padding: 20px; +} + +.login-card { + background-color: #282a36; + border-radius: 8px; + padding: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.login-header { + text-align: center; + margin-bottom: 30px; +} + +.login-header h1 { + color: #ffffff; + font-size: 24px; + margin: 0; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + color: #8be9fd; +} + +.form-group input { + width: 100%; + padding: 10px; + border: 1px solid #44475a; + border-radius: 4px; + background-color: #1a1b26; + color: #ffffff; + box-sizing: border-box; +} + +.form-group input:focus { + outline: none; + border-color: #6272a4; +} + +.login-button { + width: 100%; + padding: 12px; + background-color: #7aa2f7; + border: none; + border-radius: 4px; + color: #ffffff; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +.login-button:hover { + background-color: #6b91e4; +} + +.error-message { + background-color: #ff5555; + color: #ffffff; + padding: 10px; + border-radius: 4px; + margin-bottom: 20px; + text-align: center; +} \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..dc868eb --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,387 @@ +:root { + /* Catppuccin Mocha */ + --rosewater: #f5e0dc; + --flamingo: #f2cdcd; + --pink: #f5c2e7; + --mauve: #cba6f7; + --red: #f38ba8; + --maroon: #eba0ac; + --peach: #fab387; + --yellow: #f9e2af; + --green: #a6e3a1; + --teal: #94e2d5; + --sky: #89dceb; + --sapphire: #74c7ec; + --blue: #89b4fa; + --lavender: #b4befe; + --text: #cdd6f4; + --subtext1: #bac2de; + --subtext0: #a6adc8; + --overlay2: #9399b2; + --overlay1: #7f849c; + --overlay0: #6c7086; + --surface2: #585b70; + --surface1: #45475a; + --surface0: #313244; + --base: #1e1e2e; + --mantle: #181825; + --crust: #11111b; +} + +* { + box-sizing: border-box; +} + +body { + font-family: 'Inter', sans-serif; + max-width: 800px; + margin: 0 auto; + padding: 20px; + color: var(--text); + background: var(--base); +} + +h1, +h2, +h3 { + color: var(--text); + margin-bottom: 1.5rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +input[type="text"], +input[type="number"] { + width: 100%; + padding: 0.75rem; + margin-top: 0.5rem; + border: 1px solid var(--surface1); + border-radius: 8px; + font-size: 1rem; + background: var(--surface0); + color: var(--text); + transition: border-color 0.2s; +} + +input[type="text"]:focus, +input[type="number"]:focus { + outline: none; + border-color: var(--blue); +} + +/* Убираем стрелки для input number */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"] { + -moz-appearance: textfield; +} + +button { + padding: 0.75rem 1.5rem; + background-color: var(--blue); + color: var(--base); + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s; +} + +button:hover { + background-color: var(--lavender); +} + +button:disabled { + background-color: var(--overlay0); + cursor: not-allowed; +} + +.status { + margin: 1.5rem 0; + padding: 1rem; + border: 1px solid var(--surface1); + border-radius: 8px; + background-color: var(--surface0); +} + +.error { + color: var(--red); +} + +.files { + margin-top: 2rem; +} + +.files ul { + list-style: none; + padding: 0; +} + +.files li { + margin: 0.75rem 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border: 1px solid var(--surface1); + border-radius: 8px; + transition: all 0.2s; + background: var(--surface0); +} + +.files li:hover { + background-color: var(--surface1); +} + +.file-info { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 1.5rem; + width: 100%; +} + +.download-link { + color: var(--text); + text-decoration: none; + position: relative; + padding-left: 2rem; + margin-right: auto; + font-weight: 500; + transition: color 0.2s; +} + +.download-link:hover { + color: var(--blue); +} + +.download-link::before { + content: "📥"; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); +} + +.file-date, +.file-size { + color: var(--subtext0); + font-size: 0.9rem; +} + +.delete-btn { + background: none; + border: none; + color: var(--red); + cursor: pointer; + padding: 0.5rem; + font-size: 1.2rem; + transition: all 0.2s; +} + +.delete-btn:hover { + color: var(--maroon); +} + +/* Стилі для вкладок */ +.tabs { + margin-bottom: 20px; +} + +.tab-buttons { + display: flex; + gap: 10px; + margin-bottom: 20px; + align-items: center; + justify-content: flex-start; +} + +.tab-button { + height: 44px; + padding: 0 24px; + background-color: var(--surface0); + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + color: var(--subtext0); + transition: all 0.2s; +} + +.tab-button:hover { + background-color: var(--surface1); + color: var(--text); +} + +.tab-button.active { + background-color: var(--blue); + color: var(--base); +} + +.tab-content { + display: none; + background: var(--surface0); + padding: 20px; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.tab-content.active { + display: block; +} + +.file-select { + width: 100%; + padding: 0.75rem; + margin-top: 0.5rem; + border: 1px solid var(--surface1); + border-radius: 8px; + font-size: 1rem; + background: var(--surface0); + color: var(--text); + transition: border-color 0.2s; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23cdd6f4' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 1em; +} + +.file-select:focus { + outline: none; + border-color: var(--blue); +} + +.items-limit-group { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + height: 44px; +} + +.items-limit-group label { + color: var(--subtext0); + font-size: 14px; +} + +#items-limit { + margin: 0; + text-align: center; + max-width: 4rem; +} + +.items-limit-group input[type="number"] { + height: 44px; + width: 4rem; + padding: 0 0.75rem; + flex-shrink: 0; + text-align: center; +} + +.categories-section { + margin-bottom: 2rem; + background: var(--surface0); + border-radius: 8px; +} + +.category-form { + display: flex; + gap: 1rem; + align-items: flex-end; + margin-bottom: 1rem; +} + +.category-form .form-group { + margin-bottom: 0; +} + +.category-form button { + flex: 0 0 auto; +} + +.secondary-button { + background-color: var(--surface1); + color: var(--text); +} + +.secondary-button:hover { + background-color: var(--surface2); +} + +.categories-list ul { + list-style: none; + padding: 0; +} + +.categories-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + margin: 0.5rem 0; + background: var(--surface1); + border-radius: 4px; +} + +.yml-generation-section { + margin-bottom: 2rem; + background: var(--surface0); + border-radius: 8px; +} + +.loading { + position: relative; + pointer-events: none; + opacity: 0.7; +} + +.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid var(--blue); + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.error-message { + position: fixed; + top: 20px; + right: 20px; + padding: 1rem; + background: var(--red); + color: var(--base); + border-radius: 8px; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..905c968 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,530 @@ +let statusCheckInterval; + +function startStatusCheck() { + // Очищаем предыдущий интервал, если он существует + if (statusCheckInterval) { + clearInterval(statusCheckInterval); + } + // Проверяем статус каждые 500мс + statusCheckInterval = setInterval(checkStatus, 500); + // Сразу делаем первую проверку + checkStatus(); +} + +function checkStatus() { + fetch('/status') + .then(response => response.json()) + .then(data => { + const status = document.getElementById('status'); + const button = document.getElementById('parseButton'); + + if (data.is_running) { + let statusHtml = ` +

+ Парсинг запущено для категорії: ${data.current_category}
+ `; + + if (data.total_products > 0) { + statusHtml += ` + Товар ${data.current_product} із ${data.total_products}
+ Прогрес: ${data.progress}% + `; + } else { + statusHtml += `Отримання інформації про кількість товарів...`; + } + + statusHtml += `

`; + status.innerHTML = statusHtml; + setTimeout(checkStatus, 500); + } else { + if (data.error) { + status.innerHTML = `

Помилка: ${data.error}

`; + } else if (data.total_products > 0) { + status.innerHTML = ` +

+ Парсинг завершено
+ Всього оброблено товарів: ${data.total_products} +

+ `; + updateFilesList('parsed', 'parser', 'file-select'); + } + button.disabled = false; + } + }) + .catch(error => { + console.error('Status check error:', error); + status.innerHTML = `

Помилка: ${error.message}

`; + button.disabled = false; + }); +} + +function stopStatusCheck() { + if (statusCheckInterval) { + clearInterval(statusCheckInterval); + statusCheckInterval = null; + } +} + +function deleteFile(filename, type) { + if (confirm('Ви впевнені, що хочете видалити цей файл?')) { + fetch(`/delete/${filename}`, { + method: 'POST' + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + alert(data.error); + } else { + // Удаляем файл из списка + const fileItem = document.querySelector(`a[href*="${filename}"]`).closest('li'); + fileItem.remove(); + + // Обновляем соответствующий select в зависимости от типа файла + if (type === 'parsed') { + const fileSelect = document.getElementById('file-select'); + const optionToRemove = Array.from(fileSelect.options).find(opt => opt.value === filename); + if (optionToRemove) { + optionToRemove.remove(); + } + } else if (type === 'translated') { + const ymlFileSelect = document.getElementById('yml-file-select'); + const optionToRemove = Array.from(ymlFileSelect.options).find(opt => opt.value === filename); + if (optionToRemove) { + optionToRemove.remove(); + } + } + } + }) + .catch(error => { + console.error('Error:', error); + alert('Помилка при видаленні файлу'); + }); + } +} + +function openTab(tabName) { + // Приховуємо всі вкладки + const tabContents = document.getElementsByClassName('tab-content'); + for (let content of tabContents) { + content.classList.remove('active'); + } + + // Деактивуємо всі кнопки + const tabButtons = document.getElementsByClassName('tab-button'); + for (let button of tabButtons) { + button.classList.remove('active'); + } + + // Показуємо вибрану вкладку + document.getElementById(tabName).classList.add('active'); + + // Активуємо потрібну кнопку + event.currentTarget.classList.add('active'); +} + +function startTranslation() { + const fileSelect = document.getElementById('file-select'); + const button = document.getElementById('translateButton'); + const status = document.getElementById('translation-status'); + + if (!fileSelect.value) { + status.innerHTML = '

Будь ласка, виберіть файл для обробки

'; + return; + } + + button.disabled = true; + status.innerHTML = '

Починаємо переклад...

'; + + fetch('/translate', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `filename=${encodeURIComponent(fileSelect.value)}` + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + status.innerHTML = `

${data.error}

`; + button.disabled = false; + } else { + checkTranslationStatus(); + } + }); +} + +function checkTranslationStatus() { + fetch('/translation-status') + .then(response => response.json()) + .then(data => { + const status = document.getElementById('translation-status'); + const button = document.getElementById('translateButton'); + + if (data.is_running) { + if (data.total_items > 0) { + const percent = Math.round((data.processed_items / data.total_items) * 100); + status.innerHTML = ` +

+ Переклад в процесі...
+ Оброблено: ${data.processed_items} з ${data.total_items}
+ Прогрес: ${percent}% +

+ `; + } else { + status.innerHTML = '

Підготовка до перекладу...

'; + } + setTimeout(checkTranslationStatus, 1000); + } else { + if (data.error) { + status.innerHTML = `

Помилка: ${data.error}

`; + } else if (data.total_items > 0) { + status.innerHTML = ` +

+ Переклад завершено
+ Всього оброблено товарів: ${data.total_items} +

+ `; + updateFilesList('translated', 'processor', 'yml-file-select'); + } + button.disabled = false; + } + }) + .catch(error => { + console.error('Translation status check error:', error); + status.innerHTML = `

Помилка: ${error.message}

`; + document.getElementById('translateButton').disabled = false; + }); +} + +function generateFullYML() { + if (!confirm("Згенерувати загальний YML-файл для всіх категорій?")) return; + + fetch('/generate-full-yml', { method: 'POST' }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert("Повний YML успішно згенеровано"); + updateFilesList('yml', 'generator'); + } else { + alert("Помилка: " + (data.error || "Невідома помилка")); + } + }); +} + +function updateItemsLimit(value) { + fetch('/update-settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + items_limit: parseInt(value) + }) + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + alert(data.error); + } + }); +} + +function addCategory() { + const categoryName = document.getElementById('category-name').value; + const portalId = document.getElementById('portal-id').value; + const categoryUrl = document.getElementById('category-url').value; + + if (!categoryName || !categoryUrl) { + alert('Будь ласка, заповніть назву і URL категорії'); + return; + } + + fetch('/add-category', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: categoryName, + portal_id: portalId, + url: categoryUrl + }) + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + alert(data.error); + } else { + const newId = data.id; + + document.getElementById('category-name').value = ''; + document.getElementById('portal-id').value = ''; + document.getElementById('category-url').value = ''; + + const categoriesList = document.getElementById('categories-list'); + const li = document.createElement('li'); + li.title = categoryUrl; + li.innerHTML = ` + ${newId} - ${categoryName} ${portalId ? `(portal_id: ${portalId})` : ''} + + `; + categoriesList.appendChild(li); + + const ymlSelect = document.getElementById('yml-category-select'); + const option = document.createElement('option'); + option.value = newId; + option.text = categoryName; + ymlSelect.appendChild(option); + } + }); +} + +function deleteCategory(categoryId) { + if (confirm('Ви впевнені, що хочете видалити цю категорію?')) { + fetch('/delete-category', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: categoryId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + alert(data.error); + } else { + // Удаляем категорию из списка + const categoryItems = document.querySelectorAll('#categories-list li'); + categoryItems.forEach(item => { + if (item.querySelector('span').textContent.startsWith(categoryId + ' -')) { + item.remove(); + } + }); + + // Удаляем опцию из select в генераторе YML + const ymlSelect = document.getElementById('yml-category-select'); + const option = Array.from(ymlSelect.options).find(opt => opt.value === categoryId); + if (option) option.remove(); + } + }); + } +} + +function generateYML() { + console.log('generateYML called'); // Отладка + const categoryId = document.getElementById('yml-category-select').value; + const fileSelect = document.getElementById('yml-file-select'); + console.log('Selected category:', categoryId); // Отладка + console.log('Selected file:', fileSelect.value); // Отладка + const button = document.getElementById('generateButton'); + const status = document.getElementById('yml-status'); + + if (!categoryId || !fileSelect.value) { + status.innerHTML = '

Будь ласка, виберіть категорію та файл

'; + return; + } + + button.disabled = true; + status.innerHTML = '

Генерація YML...

'; + + fetch('/generate-yml', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filename: fileSelect.value, + category_id: categoryId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + status.innerHTML = `

${data.error}

`; + } else { + status.innerHTML = '

YML файл успішно згенеровано

'; + updateFilesList('yml', 'generator', null); + } + button.disabled = false; + }) + .catch(error => { + status.innerHTML = `

Помилка: ${error.message}

`; + button.disabled = false; + }); +} + +// Добавляем вспомогательную функцию для поиска по тексту +jQuery.expr[':'].contains = function (a, i, m) { + return jQuery(a).text().toUpperCase() + .indexOf(m[3].toUpperCase()) >= 0; +}; + +function updateFilesList(fileType, containerId, selectId = null) { + fetch(`/get-files/${fileType}`) + .then(response => response.json()) + .then(files => { + // Обновляем список файлов + const filesList = document.querySelector(`#${containerId} .files ul`); + filesList.innerHTML = files.map(file => ` +
  • +
    + + ${file.name} + + ${file.modified} + ${file.size} +
    + +
  • + `).join(''); + + // Обновляем select, если указан + if (selectId) { + const select = document.getElementById(selectId); + select.innerHTML = '' + + files.map(file => ` + + `).join(''); + } + }); +} + +function showLoader(element) { + element.classList.add('loading'); +} + +function hideLoader(element) { + element.classList.remove('loading'); +} + +function showError(message) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'error-message'; + errorDiv.textContent = message; + document.body.appendChild(errorDiv); + setTimeout(() => errorDiv.remove(), 3000); +} + +// Использование: +async function someAction() { + const element = document.getElementById('someElement'); + showLoader(element); + try { + const response = await fetch('/some-endpoint'); + const data = await response.json(); + if (data.error) throw new Error(data.error); + // обработка успешного ответа + } catch (error) { + showError(error.message); + } finally { + hideLoader(element); + } +} + +function translateAllCategories() { + if (!confirm("Запустити переклад для всіх категорій?")) return; + + fetch('/manual-translate-all', { + method: 'POST' + }) + .then(response => response.json()) + .then(data => { + if (data.message) { + alert(data.message); + } else { + alert("Переклад запущено."); + } + }) + .catch(error => { + alert("Помилка при запуску перекладу: " + error.message); + }); +} + + +function startParsing() { + const url = document.getElementById('url').value; + const button = document.getElementById('parseButton'); + const status = document.getElementById('status'); + + if (!url) { + status.innerHTML = '

    Будь ласка, введіть URL

    '; + return; + } + + button.disabled = true; + status.innerHTML = '

    Починаємо парсинг...

    '; + + fetch('/parse', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `url=${encodeURIComponent(url)}` + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + status.innerHTML = `

    ${data.error}

    `; + button.disabled = false; + } else { + checkParsingStatus(); + } + }) + .catch(error => { + status.innerHTML = `

    Помилка: ${error.message}

    `; + button.disabled = false; + }); +} + +function checkParsingStatus() { + fetch('/status') + .then(response => response.json()) + .then(data => { + const status = document.getElementById('status'); + const button = document.getElementById('parseButton'); + + if (data.is_running) { + if (data.total_items > 0) { + const percent = Math.round((data.processed_items / data.total_items) * 100); + status.innerHTML = ` +

    + Парсинг в процесі...
    + Оброблено: ${data.processed_items} з ${data.total_items}
    + Прогрес: ${percent}% +

    + `; + } else { + status.innerHTML = '

    Отримання інформації про товари...

    '; + } + setTimeout(checkParsingStatus, 1000); + } else { + if (data.error) { + status.innerHTML = `

    Помилка: ${data.error}

    `; + } else { + status.innerHTML = ` +

    + Парсинг завершено
    + Всього оброблено товарів: ${data.total_items} +

    + `; + updateFilesList('parsed', 'parser', 'file-select'); + } + button.disabled = false; + } + }) + .catch(error => { + console.error('Status check error:', error); + status.innerHTML = `

    Помилка: ${error.message}

    `; + document.getElementById('parseButton').disabled = false; + }); +} + +function refreshOldestCategory() { + fetch('/manual-refresh-all', { + method: 'POST' + }) +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 51ac82f..10ea1e5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,393 +3,17 @@ Парсер mariotexno - +

    Парсер euro.com.pl

    + {% if feed_file_info %} +

    🕓 Фід оновлено: {{ feed_file_info.modified }}

    + {% endif %} + +
    @@ -402,6 +26,8 @@ onchange="updateItemsLimit(this.value)" title="Встановіть -1 для зняття обмежень">
    + +
    @@ -535,25 +161,20 @@ {{ file.modified }} {{ file.size }}
    - {% endfor %} - +
    -
    - - -
    @@ -562,17 +183,26 @@
    +
    + + +
    - - + +

    Збережені категорії:

      {% for category in categories %} -
    • - {{ category.id }} - {{ category.name }} {% if category.portal_id %} (portal_id: {{ category.portal_id }}){% endif %} -
    • @@ -583,515 +213,9 @@
    - - - + + - + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index 539f746..c813cd5 100644 --- a/templates/login.html +++ b/templates/login.html @@ -2,79 +2,7 @@ Вхід - Парсер mariotexno - +
    diff --git a/web_interface.py b/web_interface.py index 27a2735..9593537 100644 --- a/web_interface.py +++ b/web_interface.py @@ -1,5 +1,20 @@ -from flask import Flask, render_template, request, jsonify, send_from_directory, redirect, url_for -from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user +from flask import ( + Flask, + render_template, + request, + jsonify, + send_from_directory, + redirect, + url_for, +) +from flask_login import ( + LoginManager, + UserMixin, + login_user, + login_required, + logout_user, + current_user, +) from werkzeug.security import generate_password_hash, check_password_hash import re from euro_scraper import create_retry_session, fetch_products, setup_selenium @@ -12,12 +27,14 @@ from feed_generator import RobotVacuumYMLGenerator from pathlib import Path from werkzeug.exceptions import NotFound from urllib.parse import urljoin - -BASE_URL = "https://mario.mrakells.pp.ua" +from apscheduler.schedulers.background import BackgroundScheduler +import time +from config import BASE_URL # Добавляем в начало файла login_manager = LoginManager() -login_manager.login_view = 'login' +login_manager.login_view = "login" + class User(UserMixin): def __init__(self, id, username, password_hash): @@ -25,10 +42,10 @@ class User(UserMixin): self.username = username self.password_hash = password_hash + # Хранилище пользователей (в реальном приложении использовать базу данных) -users = { - 'mario': User(1, 'mario', generate_password_hash('2htC9YlEMXAhNE')) -} +users = {"mario": User(1, "mario", generate_password_hash("2htC9YlEMXAhNE"))} + @login_manager.user_loader def load_user(user_id): @@ -37,11 +54,12 @@ def load_user(user_id): return user return None + app = Flask(__name__) # Добавляем после создания app login_manager.init_app(app) -app.config['SECRET_KEY'] = 'your-secret-key-here' # Замените на случайный ключ +app.config["SECRET_KEY"] = "your-secret-key-here" # Замените на случайный ключ # Глобальные настройки app_settings = {"items_limit": -1} # Ограничение количества обрабатываемых товаров @@ -69,6 +87,7 @@ parsing_status = { "processed_items": 0, "error": None, } +active_translations = set() def load_categories(): @@ -94,8 +113,7 @@ def extract_category(url: str) -> str: return category return None - -def start_parsing(category): + # def start_parsing(category): """Запуск парсинга категории""" global parsing_status @@ -129,6 +147,48 @@ def start_parsing(category): parsing_status["is_running"] = False +def start_parsing_with_status(category_url: str): + """Універсальний запуск парсингу з оновленням статусу""" + category = extract_category(category_url) + + parsing_status.update( + { + "is_running": True, + "total_items": 0, + "processed_items": 0, + "error": None, + "current_category": category, + } + ) + + try: + session = create_retry_session() + driver = setup_selenium() + + try: + products = fetch_products(category, session, driver, parsing_status) + + if products: + output_file = os.path.join("output", f"{category}_products.json") + with open(output_file, "w", encoding="utf-8") as f: + json.dump(products, f, ensure_ascii=False, indent=2) + + # ⏩ Після успішного збереження — запустити переклад + threading.Thread( + target=start_translation, args=(f"{category}_products.json",) + ).start() + + finally: + driver.quit() + + except Exception as e: + parsing_status["error"] = str(e) + print(f"❌ Error during parsing {category}: {e}") + + finally: + parsing_status["is_running"] = False + + def get_file_info(filename, directory="output"): """Получение информации о файле""" filepath = os.path.join(directory, filename) @@ -139,11 +199,13 @@ def get_file_info(filename, directory="output"): "size": f"{stat.st_size / 1024:.1f} KB", } + def get_oldest_parsed_file(): """Повертає найстаріший _products.json файл""" folder = "output" files = [ - f for f in os.listdir(folder) + f + for f in os.listdir(folder) if f.endswith("_products.json") and not f.endswith("_translated_products.json") ] if not files: @@ -154,26 +216,28 @@ def get_oldest_parsed_file(): return category -@app.route('/login', methods=['GET', 'POST']) +@app.route("/login", methods=["GET", "POST"]) def login(): - if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') - + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + user = users.get(username) if user and check_password_hash(user.password_hash, password): login_user(user) - return redirect(url_for('index')) - - return render_template('login.html', error='Неверный логин или пароль') - - return render_template('login.html') + return redirect(url_for("index")) -@app.route('/logout') + return render_template("login.html", error="Неверный логин или пароль") + + return render_template("login.html") + + +@app.route("/logout") @login_required def logout(): logout_user() - return redirect(url_for('login')) + return redirect(url_for("login")) + @app.route("/") @login_required @@ -201,6 +265,11 @@ def index(): files = [f for f in os.listdir("output/yml") if f.endswith(".yml")] yml_files = [get_file_info(f, "output/yml") for f in files] + feed_file_info = None + feed_path = os.path.join("output/yml", "feed.yml") + if os.path.exists(feed_path): + feed_file_info = get_file_info("feed.yml", "output/yml") + # Загружаем категории categories = load_categories() @@ -213,25 +282,60 @@ def index(): yml_files=yml_files, categories=categories, app_settings=app_settings, + feed_file_info=feed_file_info, ) +@app.route("/manual-refresh-all", methods=["POST"]) +def manual_refresh_all(): + threading.Thread(target=refresh_all_categories_daily).start() + return jsonify({"message": "Оновлення всіх категорій запущено вручну"}) + + @app.route("/auto-refresh", methods=["POST"]) def auto_refresh(): - """Запускає парсинг найстарішої збереженої категорії""" - if parsing_status["is_running"]: - return jsonify({"error": "Парсинг уже запущено"}) + try: + if parsing_status["is_running"]: + return jsonify({"error": "Парсинг вже запущений"}) - category = get_oldest_parsed_file() - if not category: - return jsonify({"error": "Немає жодної категорії для оновлення"}) + categories = load_categories() - print(f"[AUTO REFRESH] Повторний парсинг для категорії: {category}") + valid_categories = [c for c in categories if "url" in c and c["url"]] + if not valid_categories: + return jsonify({"error": "Немає категорій з URL"}) - thread = threading.Thread(target=start_parsing, args=(category,)) - thread.start() + oldest = None + oldest_mtime = float("inf") - return jsonify({"success": True, "category": category}) + for cat in valid_categories: + category_url = cat["url"] + category_key = extract_category(category_url) + filename = f"{category_key}_products.json" + path = os.path.join("output", filename) + + # ❗ Якщо файл не існує — одразу обираємо цю категорію + if not os.path.exists(path): + oldest = cat + break + + mtime = os.path.getmtime(path) + if mtime < oldest_mtime: + oldest_mtime = mtime + oldest = cat + + if not oldest: + return jsonify({"error": "Не вдалося знайти категорію для оновлення"}) + + # category = extract_category(oldest["url"]) + thread = threading.Thread( + target=start_parsing_with_status, args=(oldest["url"],) + ) + thread.start() + + return jsonify({"success": True, "category": oldest["name"]}) + + except Exception as e: + return jsonify({"error": str(e)}) @app.route("/parse", methods=["POST"]) @@ -250,7 +354,7 @@ def parse(): return jsonify({"error": "Парсинг уже запущен"}) # Запускаем парсинг в отдельном потоке - thread = threading.Thread(target=start_parsing, args=(category,)) + thread = threading.Thread(target=start_parsing_with_status, args=(url,)) thread.start() return jsonify({"status": "ok"}) @@ -258,8 +362,21 @@ def parse(): @app.route("/status") def get_status(): - """Получение статуса парсинга""" - return jsonify(parsing_status) + # Повертаємо додатково обчислений прогрес + progress = 0 + if parsing_status["total_items"]: + progress = round( + (parsing_status["processed_items"] / parsing_status["total_items"]) * 100 + ) + + return jsonify( + { + **parsing_status, + "current_product": parsing_status["processed_items"], + "total_products": parsing_status["total_items"], + "progress": progress, + } + ) @app.route("/download/") @@ -326,9 +443,16 @@ def get_translation_status(): def start_translation(filename: str): - """Функция для запуска перевода в отдельнм потоке""" global translation_status + print(f"[TRANSLATE] Починаємо переклад: {filename}") + + if filename in active_translations: + print(f"[SKIP] Переклад вже йде: {filename}") + return + + active_translations.add(filename) + translation_status["is_running"] = True translation_status["processed_items"] = 0 translation_status["error"] = None @@ -339,6 +463,17 @@ def start_translation(filename: str): with open(os.path.join("output", filename), "r", encoding="utf-8") as f: products = json.load(f) + # Витягуємо category_id з назви файлу + category_id = filename.split("_")[0] + categories = load_categories() + category = next((c for c in categories if str(c["id"]) == category_id), None) + + # Якщо категорія знайдена — вставляємо ID в продукти + if category and "portal_id" in category: + for product in products: + product["portal_category_id"] = category["portal_id"] + product["local_category_id"] = category["id"] + # Ограничиваем количество товаров только если лимит больше 0 if app_settings["items_limit"] > 0: products = products[: app_settings["items_limit"]] @@ -368,6 +503,78 @@ def start_translation(filename: str): print(f"Ошибка перевода: {e}") finally: translation_status["is_running"] = False + active_translations.discard(filename) + print(f"[DONE] Переклад завершено: {filename}") + + +def refresh_all_categories_daily(): + """Оновлює всі категорії один раз (але не частіше ніж раз на добу кожна)""" + if parsing_status["is_running"]: + print("[SKIP] Парсинг уже запущено. Пропускаємо автоматичне оновлення.") + return + + print("[START] Щоденне оновлення категорій...") + categories = load_categories() + + for category in categories: + category_name = category.get("name") + category_url = category.get("url") + + if not category_url: + print(f"[SKIP] Категорія '{category_name}' не має URL") + continue + + category_key = extract_category(category_url) + filename = f"{category_key}_products.json" + filepath = os.path.join("output", filename) + + # ⏱ Перевіряємо, коли файл востаннє оновлювався + if os.path.exists(filepath): + modified_time = os.path.getmtime(filepath) + age_seconds = time.time() - modified_time + if age_seconds < 86400: + print(f"[SKIP] {category_name} — оновлено менше доби тому") + continue + + print(f"[PARSE] {category_name}") + try: + parsing_status["is_running"] = True + start_parsing_with_status(category_url) + except Exception as e: + print(f"[ERROR] Не вдалося оновити {category_name}: {e}") + finally: + parsing_status["is_running"] = False + time.sleep(5) + + print("[DONE] Автоматичне оновлення завершено") + + +def translate_all_parsed_once(): + """Одноразово запускає переклад для всіх ще не перекладених файлів""" + print("[START] Одноразовий переклад всіх категорій...") + + parsed_folder = "output" + translated_folder = "output/translated" + + for filename in os.listdir(parsed_folder): + if filename.endswith("_products.json") and not filename.endswith( + "_translated_products.json" + ): + parsed_path = os.path.join(parsed_folder, filename) + translated_name = filename.replace( + "_products.json", "_translated_products.json" + ) + translated_path = os.path.join(translated_folder, translated_name) + + if os.path.exists(translated_path): + print(f"[SKIP] Вже перекладено: {filename}") + continue + + print(f"[TRANSLATE] Запускаємо переклад: {filename}") + threading.Thread(target=start_translation, args=(filename,)).start() + time.sleep(2) # трохи затримки між потоками, щоб не навантажувати + + print("[DONE] Запуск перекладу завершено.") @app.route("/update-settings", methods=["POST"]) @@ -386,6 +593,75 @@ def update_settings(): return jsonify({"error": str(e)}) +@app.route("/manual-translate-all", methods=["POST"]) +@login_required +def manual_translate_all(): + threading.Thread(target=translate_all_parsed_once).start() + return jsonify({"message": "Запущено переклад усіх категорій"}) + + +@app.route("/generate-full-yml", methods=["POST"]) +def generate_full_yml(): + try: + translated_folder = "output/translated" + categories = load_categories() + all_products = [] + + for file in os.listdir(translated_folder): + if file.endswith("_translated_products.json"): + with open( + os.path.join(translated_folder, file), "r", encoding="utf-8" + ) as f: + products = json.load(f) + + if not products: + continue + + # Перевірка на наявність ID + if not all( + "portal_category_id" in p and "local_category_id" in p + for p in products + ): + print(f"[SKIP] {file} — відсутні ID") + continue + + all_products.extend(products) + + if not all_products: + return jsonify({"error": "Не знайдено товарів з валідними категоріями"}) + + generator = RobotVacuumYMLGenerator( + base_url=BASE_URL, categories_data=categories + ) + + # Додаємо тільки ті категорії, що реально використовуються + added_categories = set() + for product in all_products: + cid = str(product.get("local_category_id")) + if cid and cid not in added_categories: + match = next((c for c in categories if str(c["id"]) == cid), None) + if match: + generator.add_category(cid, match["name"]) + added_categories.add(cid) + + output_path = "output/yml/feed.yml" + os.makedirs("output/yml", exist_ok=True) + success = generator.generate_yml(all_products, output_path) + + if success: + return jsonify({"success": True}) + else: + return jsonify({"error": "Помилка при генерації повного YML"}) + + except Exception as e: + return jsonify({"error": str(e)}) + + +@app.route("/yml/feed.yml") +def serve_feed(): + return send_from_directory("output/yml", "feed.yml", mimetype="application/xml") + + @app.route("/generate-yml", methods=["POST"]) def generate_yml(): """Обработчик генерации YML файла""" @@ -401,14 +677,20 @@ def generate_yml(): # Загружаем категории categories = load_categories() - category = next((c for c in categories if str(c["id"]) == str(category_id)), None) + category = next( + (c for c in categories if str(c["id"]) == str(category_id)), None + ) if not category: return jsonify({"error": "Категорія не знайдена"}) portal_category_id = category.get("portal_id") if not portal_category_id: - return jsonify({"error": "Категорія не має portal_id (ідентифікатор категорії Prom.ua)"}) + return jsonify( + { + "error": "Категорія не має portal_id (ідентифікатор категорії Prom.ua)" + } + ) os.makedirs("output/yml", exist_ok=True) @@ -426,7 +708,10 @@ def generate_yml(): product["local_category_id"] = category["id"] # Создаем генератор YML с указанием базового URL - generator = RobotVacuumYMLGenerator(base_url=BASE_URL) + categories = load_categories() + generator = RobotVacuumYMLGenerator( + base_url=BASE_URL, categories_data=categories + ) generator.add_category(str(category["id"]), category["name"]) # Генерируем имя выходного файла @@ -453,23 +738,24 @@ def add_category(): data = request.json categories = load_categories() - # Перевірка обов'язкових полів - if "id" not in data or "name" not in data: - return jsonify({"error": "Обов'язкові поля: id, name"}) + # Перевірка наявності name + if "name" not in data: + return jsonify({"error": "Поле name є обов'язковим"}) - # Перевірка унікальності ID - if any(str(c["id"]) == str(data["id"]) for c in categories): - return jsonify({"error": "Категорія з таким ID вже існує"}) + # Генерація нового ID (максимальний + 1 або 1) + if categories: + new_id = max(int(c["id"]) for c in categories) + 1 + else: + new_id = 1 - # Додаємо категорію з optional portal_id - new_category = { - "id": data["id"], - "name": data["name"], - } + new_category = {"id": str(new_id), "name": data["name"]} if "portal_id" in data: new_category["portal_id"] = data["portal_id"] + if "url" in data: + new_category["url"] = data["url"] + categories.append(new_category) save_categories(categories) @@ -479,14 +765,15 @@ def add_category(): return jsonify({"error": str(e)}) - @app.route("/delete-category", methods=["POST"]) def delete_category(): - """Удаление категории""" + """Видалення категорії""" try: data = request.json + target_id = str(data["id"]) + categories = load_categories() - categories = [c for c in categories if c["id"] != data["id"]] + categories = [c for c in categories if str(c["id"]) != target_id] save_categories(categories) return jsonify({"success": True}) @@ -602,12 +889,15 @@ def get_file_info(filename, directory): if __name__ == "__main__": - # Создаем необходимые директории for directory in ["output", "output/translated", "output/yml"]: os.makedirs(directory, exist_ok=True) - # Создаем файл категорий, если его нет if not os.path.exists(CATEGORIES_FILE): save_categories([]) - app.run(host='0.0.0.0', port=5000, debug=True) + # Запуск планувальника + scheduler = BackgroundScheduler() + scheduler.add_job(refresh_all_categories_daily, "interval", days=1) + scheduler.start() + + app.run(host="0.0.0.0", port=5000, debug=True)