feat: auto update

This commit is contained in:
2025-04-18 10:41:32 +03:00
parent e7e6382a10
commit bb12fe5ca7
9 changed files with 1478 additions and 1045 deletions

View File

@@ -1,7 +1,68 @@
[ [
{ {
"id": "1", "id": "2",
"name": "Роботи-пилососи", "name": "Монітори",
"portal_id": "63023" "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"
} }
] ]

1
config.py Normal file
View File

@@ -0,0 +1 @@
BASE_URL = "https://mario.mrakells.com"

View File

@@ -1,3 +1,5 @@
from config import BASE_URL
import json import json
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import List, Dict from typing import List, Dict
@@ -9,8 +11,9 @@ class RobotVacuumYMLGenerator:
def __init__( def __init__(
self, self,
shop_name: str = "Euro Electronics", shop_name: str = "Euro Electronics",
base_url: str = "https://mario.mrakells.pp.ua", base_url: str = BASE_URL,
use_original_urls: bool = False, use_original_urls: bool = False,
categories_data: List[Dict] = None,
): ):
""" """
Initialize YML feed generator Initialize YML feed generator
@@ -29,6 +32,7 @@ class RobotVacuumYMLGenerator:
self.categories = ET.SubElement(self.shop, "categories") self.categories = ET.SubElement(self.shop, "categories")
self.offers = ET.SubElement(self.shop, "offers") 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): 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 :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', { offer = ET.SubElement(
'id': str(product['plu']), self.offers,
'available': 'true' if in_stock else 'false', "offer",
'in_stock': 'true' if in_stock else 'false' {
}) "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 # Clean product name before adding to feed
cleaned_name = self.clean_product_name(product["name"]) cleaned_name = self.clean_product_name(product["name"])
@@ -134,6 +142,28 @@ class RobotVacuumYMLGenerator:
product["portal_category_id"] 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 # Description with images
if "description" in product: if "description" in product:
description_html = "<div>" description_html = "<div>"

82
static/css/login.css Normal file
View File

@@ -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;
}

387
static/css/styles.css Normal file
View File

@@ -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);
}
}

530
static/js/main.js Normal file
View File

@@ -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 = `
<p>
Парсинг запущено для категорії: ${data.current_category}<br>
`;
if (data.total_products > 0) {
statusHtml += `
<strong>Товар ${data.current_product} із ${data.total_products}</strong><br>
Прогрес: ${data.progress}%
`;
} else {
statusHtml += `Отримання інформації про кількість товарів...`;
}
statusHtml += `</p>`;
status.innerHTML = statusHtml;
setTimeout(checkStatus, 500);
} else {
if (data.error) {
status.innerHTML = `<p class="error">Помилка: ${data.error}</p>`;
} else if (data.total_products > 0) {
status.innerHTML = `
<p>
Парсинг завершено<br>
<strong>Всього оброблено товарів: ${data.total_products}</strong>
</p>
`;
updateFilesList('parsed', 'parser', 'file-select');
}
button.disabled = false;
}
})
.catch(error => {
console.error('Status check error:', error);
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
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 = '<p class="error">Будь ласка, виберіть файл для обробки</p>';
return;
}
button.disabled = true;
status.innerHTML = '<p>Починаємо переклад...</p>';
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 = `<p class="error">${data.error}</p>`;
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 = `
<p>
Переклад в процесі...<br>
Оброблено: ${data.processed_items} з ${data.total_items}<br>
Прогрес: ${percent}%
</p>
`;
} else {
status.innerHTML = '<p>Підготовка до перекладу...</p>';
}
setTimeout(checkTranslationStatus, 1000);
} else {
if (data.error) {
status.innerHTML = `<p class="error">Помилка: ${data.error}</p>`;
} else if (data.total_items > 0) {
status.innerHTML = `
<p>
Переклад завершено<br>
Всього оброблено товарів: ${data.total_items}
</p>
`;
updateFilesList('translated', 'processor', 'yml-file-select');
}
button.disabled = false;
}
})
.catch(error => {
console.error('Translation status check error:', error);
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
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 = `
<span>${newId} - ${categoryName} ${portalId ? `(portal_id: ${portalId})` : ''}</span>
<button class="delete-btn" onclick="deleteCategory('${newId}')" title="Видалити категорію">🗑️</button>
`;
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 = '<p class="error">Будь ласка, виберіть категорію та файл</p>';
return;
}
button.disabled = true;
status.innerHTML = '<p>Генерація YML...</p>';
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 = `<p class="error">${data.error}</p>`;
} else {
status.innerHTML = '<p>YML файл успішно згенеровано</p>';
updateFilesList('yml', 'generator', null);
}
button.disabled = false;
})
.catch(error => {
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
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 => `
<li>
<div class="file-info">
<a href="/download/${file.name}" class="download-link" download>
${file.name}
</a>
<span class="file-date">${file.modified}</span>
<span class="file-size">${file.size}</span>
</div>
<button class="delete-btn" onclick="deleteFile('${file.name}', '${fileType}')" title="Видалити файл">
🗑️
</button>
</li>
`).join('');
// Обновляем select, если указан
if (selectId) {
const select = document.getElementById(selectId);
select.innerHTML = '<option value="">Виберіть файл...</option>' +
files.map(file => `
<option value="${file.name}">${file.name}</option>
`).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 = '<p class="error">Будь ласка, введіть URL</p>';
return;
}
button.disabled = true;
status.innerHTML = '<p>Починаємо парсинг...</p>';
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 = `<p class="error">${data.error}</p>`;
button.disabled = false;
} else {
checkParsingStatus();
}
})
.catch(error => {
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
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 = `
<p>
Парсинг в процесі...<br>
Оброблено: ${data.processed_items} з ${data.total_items}<br>
Прогрес: ${percent}%
</p>
`;
} else {
status.innerHTML = '<p>Отримання інформації про товари...</p>';
}
setTimeout(checkParsingStatus, 1000);
} else {
if (data.error) {
status.innerHTML = `<p class="error">Помилка: ${data.error}</p>`;
} else {
status.innerHTML = `
<p>
Парсинг завершено<br>
Всього оброблено товарів: ${data.total_items}
</p>
`;
updateFilesList('parsed', 'parser', 'file-select');
}
button.disabled = false;
}
})
.catch(error => {
console.error('Status check error:', error);
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
document.getElementById('parseButton').disabled = false;
});
}
function refreshOldestCategory() {
fetch('/manual-refresh-all', {
method: 'POST'
})
}

View File

@@ -3,393 +3,17 @@
<head> <head>
<title>Парсер mariotexno</title> <title>Парсер mariotexno</title>
<style> <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
: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); }
}
</style>
</head> </head>
<body> <body>
<h1>Парсер euro.com.pl</h1> <h1>Парсер euro.com.pl</h1>
{% if feed_file_info %}
<p>🕓 <a href="{{ url_for('serve_feed') }}" target="_blank">Фід</a> оновлено: {{ feed_file_info.modified }}</p>
{% endif %}
<div class="tabs"> <div class="tabs">
<div class="tab-buttons"> <div class="tab-buttons">
<button class="tab-button active" onclick="openTab('parser')">Парсер</button> <button class="tab-button active" onclick="openTab('parser')">Парсер</button>
@@ -402,6 +26,8 @@
onchange="updateItemsLimit(this.value)" title="Встановіть -1 для зняття обмежень"> onchange="updateItemsLimit(this.value)" title="Встановіть -1 для зняття обмежень">
</div> </div>
<button onclick="refreshOldestCategory()">🔄</button> <button onclick="refreshOldestCategory()">🔄</button>
<button onclick="translateAllCategories()">🇺🇦</button>
<button onclick="generateFullYML()">YML</button>
</div> </div>
@@ -535,25 +161,20 @@
<span class="file-date">{{ file.modified }}</span> <span class="file-date">{{ file.modified }}</span>
<span class="file-size">{{ file.size }}</span> <span class="file-size">{{ file.size }}</span>
</div> </div>
<button class="delete-btn" onclick="deleteFile('{{ file.name }}', 'yml')" <button class="delete-btn" onclick="deleteFile('{{ file.name }}', 'yml')" title="Видалити файл">
title="Видалити файл">
🗑️ 🗑️
</button> </button>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
</div> </div>
<!-- Вкладка управления категориями --> <!-- Вкладка управления категориями -->
<div id="categories" class="tab-content"> <div id="categories" class="tab-content">
<div class="categories-section"> <div class="categories-section">
<div class="category-form"> <div class="category-form">
<div class="form-group">
<label for="category-id">ID категорії:</label>
<input type="text" id="category-id" placeholder="1">
</div>
<div class="form-group"> <div class="form-group">
<label for="category-name">Назва категорії:</label> <label for="category-name">Назва категорії:</label>
<input type="text" id="category-name" placeholder="Роботи-пилососи"> <input type="text" id="category-name" placeholder="Роботи-пилососи">
@@ -562,17 +183,26 @@
<label for="portal-id">ID категорії Prom.ua:</label> <label for="portal-id">ID категорії Prom.ua:</label>
<input type="text" id="portal-id" placeholder="12345"> <input type="text" id="portal-id" placeholder="12345">
</div> </div>
<div class="form-group">
<label for="category-url">URL категорії:</label>
<input type="text" id="category-url"
placeholder="https://www.euro.com.pl/odkurzacze-automatyczne.bhtml">
</div>
<button onclick="addCategory()" class="secondary-button">Додати категорію</button> <button onclick="addCategory()" class="secondary-button">Додати категорію</button>
</div> </div>
<div class="categories-list"> <div class="categories-list">
<h4>Збережені категорії:</h4> <h4>Збережені категорії:</h4>
<ul id="categories-list"> <ul id="categories-list">
{% for category in categories %} {% for category in categories %}
<li> <li title="{{ category.url or '' }}">
<span>{{ category.id }} - {{ category.name }} {% if category.portal_id %} (portal_id: {{ category.portal_id }}){% endif %}</span> <span>
<button class="delete-btn" onclick="deleteCategory('{{ category.id }}')" title="Видалити категорію"> {{ category.id }} - {{ category.name }}
{% if category.portal_id %} (portal_id: {{ category.portal_id }}){% endif %}
</span>
<button class="delete-btn" onclick="deleteCategory('{{ category.id }}')"
title="Видалити категорію">
🗑️ 🗑️
</button> </button>
</li> </li>
@@ -583,515 +213,9 @@
</div> </div>
</div> </div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
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 = `
<p>
Парсинг запущено для категорії: ${data.current_category}<br>
`;
if (data.total_products > 0) {
statusHtml += `
<strong>Товар ${data.current_product} із ${data.total_products}</strong><br>
Прогрес: ${data.progress}%
`;
} else {
statusHtml += `Отримання інформації про кількість товарів...`;
}
statusHtml += `</p>`;
status.innerHTML = statusHtml;
setTimeout(checkStatus, 500);
} else {
if (data.error) {
status.innerHTML = `<p class="error">Помилка: ${data.error}</p>`;
} else if (data.total_products > 0) {
status.innerHTML = `
<p>
Парсинг завершено<br>
<strong>Всього оброблено товарів: ${data.total_products}</strong>
</p>
`;
updateFilesList('parsed', 'parser', 'file-select');
}
button.disabled = false;
}
})
.catch(error => {
console.error('Status check error:', error);
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
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 = '<p class="error">Будь ласка, виберіть файл для обробки</p>';
return;
}
button.disabled = true;
status.innerHTML = '<p>Починаємо переклад...</p>';
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 = `<p class="error">${data.error}</p>`;
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 = `
<p>
Переклад в процесі...<br>
Оброблено: ${data.processed_items} з ${data.total_items}<br>
Прогрес: ${percent}%
</p>
`;
} else {
status.innerHTML = '<p>Підготовка до перекладу...</p>';
}
setTimeout(checkTranslationStatus, 1000);
} else {
if (data.error) {
status.innerHTML = `<p class="error">Помилка: ${data.error}</p>`;
} else if (data.total_items > 0) {
status.innerHTML = `
<p>
Переклад завершено<br>
Всього оброблено товарів: ${data.total_items}
</p>
`;
updateFilesList('translated', 'processor', 'yml-file-select');
}
button.disabled = false;
}
})
.catch(error => {
console.error('Translation status check error:', error);
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
document.getElementById('translateButton').disabled = false;
});
}
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 categoryId = document.getElementById('category-id').value;
const categoryName = document.getElementById('category-name').value;
const portalId = document.getElementById('portal-id').value;
if (!categoryId || !categoryName) {
alert('Будь ласка, заповніть всі поля');
return;
}
fetch('/add-category', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: categoryId,
name: categoryName,
portal_id: portalId
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error);
} else {
// Очищаем поля ввода
document.getElementById('category-id').value = '';
document.getElementById('category-name').value = '';
document.getElementById('portal-id').value = '';
// Добавляем новую категорию в список
const categoriesList = document.getElementById('categories-list');
const li = document.createElement('li');
li.innerHTML = `
<span>${categoryId} - ${categoryName}</span>
<button class="delete-btn" onclick="deleteCategory('${categoryId}')" title="Видалити категорію">
</button>
`;
categoriesList.appendChild(li);
// Обновляем select в генераторе YML
const ymlSelect = document.getElementById('yml-category-select');
const option = document.createElement('option');
option.value = categoryId;
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 = '<p class="error">Будь ласка, виберіть категорію та файл</p>';
return;
}
button.disabled = true;
status.innerHTML = '<p>Генерація YML...</p>';
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 = `<p class="error">${data.error}</p>`;
} else {
status.innerHTML = '<p>YML файл успішно згенеровано</p>';
updateFilesList('yml', 'generator', null);
}
button.disabled = false;
})
.catch(error => {
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
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 => `
<li>
<div class="file-info">
<a href="/download/${file.name}" class="download-link" download>
${file.name}
</a>
<span class="file-date">${file.modified}</span>
<span class="file-size">${file.size}</span>
</div>
<button class="delete-btn" onclick="deleteFile('${file.name}', '${fileType}')" title="Видалити файл">
🗑️
</button>
</li>
`).join('');
// Обновляем select, если указан
if (selectId) {
const select = document.getElementById(selectId);
select.innerHTML = '<option value="">Виберіть файл...</option>' +
files.map(file => `
<option value="${file.name}">${file.name}</option>
`).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 startParsing() {
const url = document.getElementById('url').value;
const button = document.getElementById('parseButton');
const status = document.getElementById('status');
if (!url) {
status.innerHTML = '<p class="error">Будь ласка, введіть URL</p>';
return;
}
button.disabled = true;
status.innerHTML = '<p>Починаємо парсинг...</p>';
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 = `<p class="error">${data.error}</p>`;
button.disabled = false;
} else {
checkParsingStatus();
}
})
.catch(error => {
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
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 = `
<p>
Парсинг в процесі...<br>
Оброблено: ${data.processed_items} з ${data.total_items}<br>
Прогрес: ${percent}%
</p>
`;
} else {
status.innerHTML = '<p>Отримання інформації про товари...</p>';
}
setTimeout(checkParsingStatus, 1000);
} else {
if (data.error) {
status.innerHTML = `<p class="error">Помилка: ${data.error}</p>`;
} else {
status.innerHTML = `
<p>
Парсинг завершено<br>
Всього оброблено товарів: ${data.total_items}
</p>
`;
updateFilesList('parsed', 'parser', 'file-select');
}
button.disabled = false;
}
})
.catch(error => {
console.error('Status check error:', error);
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
document.getElementById('parseButton').disabled = false;
});
}
function refreshOldestCategory() {
fetch('/auto-refresh', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Оновлено: ${data.category}`);
} else {
alert(data.error || 'Помилка під час оновлення');
}
});
}
</script>
</body> </body>
</html> </html>

View File

@@ -2,79 +2,7 @@
<html> <html>
<head> <head>
<title>Вхід - Парсер mariotexno</title> <title>Вхід - Парсер mariotexno</title>
<style> <link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
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;
}
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">

View File

@@ -1,5 +1,20 @@
from flask import Flask, render_template, request, jsonify, send_from_directory, redirect, url_for from flask import (
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user 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 from werkzeug.security import generate_password_hash, check_password_hash
import re import re
from euro_scraper import create_retry_session, fetch_products, setup_selenium 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 pathlib import Path
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from urllib.parse import urljoin from urllib.parse import urljoin
from apscheduler.schedulers.background import BackgroundScheduler
BASE_URL = "https://mario.mrakells.pp.ua" import time
from config import BASE_URL
# Добавляем в начало файла # Добавляем в начало файла
login_manager = LoginManager() login_manager = LoginManager()
login_manager.login_view = 'login' login_manager.login_view = "login"
class User(UserMixin): class User(UserMixin):
def __init__(self, id, username, password_hash): def __init__(self, id, username, password_hash):
@@ -25,10 +42,10 @@ class User(UserMixin):
self.username = username self.username = username
self.password_hash = password_hash self.password_hash = password_hash
# Хранилище пользователей (в реальном приложении использовать базу данных) # Хранилище пользователей (в реальном приложении использовать базу данных)
users = { users = {"mario": User(1, "mario", generate_password_hash("2htC9YlEMXAhNE"))}
'mario': User(1, 'mario', generate_password_hash('2htC9YlEMXAhNE'))
}
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
@@ -37,11 +54,12 @@ def load_user(user_id):
return user return user
return None return None
app = Flask(__name__) app = Flask(__name__)
# Добавляем после создания app # Добавляем после создания app
login_manager.init_app(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} # Ограничение количества обрабатываемых товаров app_settings = {"items_limit": -1} # Ограничение количества обрабатываемых товаров
@@ -69,6 +87,7 @@ parsing_status = {
"processed_items": 0, "processed_items": 0,
"error": None, "error": None,
} }
active_translations = set()
def load_categories(): def load_categories():
@@ -94,8 +113,7 @@ def extract_category(url: str) -> str:
return category return category
return None return None
# def start_parsing(category):
def start_parsing(category):
"""Запуск парсинга категории""" """Запуск парсинга категории"""
global parsing_status global parsing_status
@@ -129,6 +147,48 @@ def start_parsing(category):
parsing_status["is_running"] = False 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"): def get_file_info(filename, directory="output"):
"""Получение информации о файле""" """Получение информации о файле"""
filepath = os.path.join(directory, filename) 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", "size": f"{stat.st_size / 1024:.1f} KB",
} }
def get_oldest_parsed_file(): def get_oldest_parsed_file():
"""Повертає найстаріший _products.json файл""" """Повертає найстаріший _products.json файл"""
folder = "output" folder = "output"
files = [ 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 f.endswith("_products.json") and not f.endswith("_translated_products.json")
] ]
if not files: if not files:
@@ -154,26 +216,28 @@ def get_oldest_parsed_file():
return category return category
@app.route('/login', methods=['GET', 'POST']) @app.route("/login", methods=["GET", "POST"])
def login(): def login():
if request.method == 'POST': if request.method == "POST":
username = request.form.get('username') username = request.form.get("username")
password = request.form.get('password') password = request.form.get("password")
user = users.get(username) user = users.get(username)
if user and check_password_hash(user.password_hash, password): if user and check_password_hash(user.password_hash, password):
login_user(user) login_user(user)
return redirect(url_for('index')) return redirect(url_for("index"))
return render_template('login.html', error='Неверный логин или пароль')
return render_template('login.html')
@app.route('/logout') return render_template("login.html", error="Неверный логин или пароль")
return render_template("login.html")
@app.route("/logout")
@login_required @login_required
def logout(): def logout():
logout_user() logout_user()
return redirect(url_for('login')) return redirect(url_for("login"))
@app.route("/") @app.route("/")
@login_required @login_required
@@ -201,6 +265,11 @@ def index():
files = [f for f in os.listdir("output/yml") if f.endswith(".yml")] 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] 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() categories = load_categories()
@@ -213,25 +282,60 @@ def index():
yml_files=yml_files, yml_files=yml_files,
categories=categories, categories=categories,
app_settings=app_settings, 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"]) @app.route("/auto-refresh", methods=["POST"])
def auto_refresh(): def auto_refresh():
"""Запускає парсинг найстарішої збереженої категорії""" try:
if parsing_status["is_running"]: if parsing_status["is_running"]:
return jsonify({"error": "Парсинг уже запущено"}) return jsonify({"error": "Парсинг вже запущений"})
category = get_oldest_parsed_file() categories = load_categories()
if not category:
return jsonify({"error": "Немає жодної категорії для оновлення"})
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,)) oldest = None
thread.start() 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"]) @app.route("/parse", methods=["POST"])
@@ -250,7 +354,7 @@ def parse():
return jsonify({"error": "Парсинг уже запущен"}) return jsonify({"error": "Парсинг уже запущен"})
# Запускаем парсинг в отдельном потоке # Запускаем парсинг в отдельном потоке
thread = threading.Thread(target=start_parsing, args=(category,)) thread = threading.Thread(target=start_parsing_with_status, args=(url,))
thread.start() thread.start()
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@@ -258,8 +362,21 @@ def parse():
@app.route("/status") @app.route("/status")
def get_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/<path:filename>") @app.route("/download/<path:filename>")
@@ -326,9 +443,16 @@ def get_translation_status():
def start_translation(filename: str): def start_translation(filename: str):
"""Функция для запуска перевода в отдельнм потоке"""
global translation_status 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["is_running"] = True
translation_status["processed_items"] = 0 translation_status["processed_items"] = 0
translation_status["error"] = None 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: with open(os.path.join("output", filename), "r", encoding="utf-8") as f:
products = json.load(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 # Ограничиваем количество товаров только если лимит больше 0
if app_settings["items_limit"] > 0: if app_settings["items_limit"] > 0:
products = products[: app_settings["items_limit"]] products = products[: app_settings["items_limit"]]
@@ -368,6 +503,78 @@ def start_translation(filename: str):
print(f"Ошибка перевода: {e}") print(f"Ошибка перевода: {e}")
finally: finally:
translation_status["is_running"] = False 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"]) @app.route("/update-settings", methods=["POST"])
@@ -386,6 +593,75 @@ def update_settings():
return jsonify({"error": str(e)}) 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"]) @app.route("/generate-yml", methods=["POST"])
def generate_yml(): def generate_yml():
"""Обработчик генерации YML файла""" """Обработчик генерации YML файла"""
@@ -401,14 +677,20 @@ def generate_yml():
# Загружаем категории # Загружаем категории
categories = load_categories() 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: if not category:
return jsonify({"error": "Категорія не знайдена"}) return jsonify({"error": "Категорія не знайдена"})
portal_category_id = category.get("portal_id") portal_category_id = category.get("portal_id")
if not portal_category_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) os.makedirs("output/yml", exist_ok=True)
@@ -426,7 +708,10 @@ def generate_yml():
product["local_category_id"] = category["id"] product["local_category_id"] = category["id"]
# Создаем генератор YML с указанием базового URL # Создаем генератор 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"]) generator.add_category(str(category["id"]), category["name"])
# Генерируем имя выходного файла # Генерируем имя выходного файла
@@ -453,23 +738,24 @@ def add_category():
data = request.json data = request.json
categories = load_categories() categories = load_categories()
# Перевірка обов'язкових полів # Перевірка наявності name
if "id" not in data or "name" not in data: if "name" not in data:
return jsonify({"error": "Обов'язкові поля: id, name"}) return jsonify({"error": "Поле name є обов'язковим"})
# Перевірка унікальності ID # Генерація нового ID (максимальний + 1 або 1)
if any(str(c["id"]) == str(data["id"]) for c in categories): if categories:
return jsonify({"error": "Категорія з таким ID вже існує"}) new_id = max(int(c["id"]) for c in categories) + 1
else:
new_id = 1
# Додаємо категорію з optional portal_id new_category = {"id": str(new_id), "name": data["name"]}
new_category = {
"id": data["id"],
"name": data["name"],
}
if "portal_id" in data: if "portal_id" in data:
new_category["portal_id"] = data["portal_id"] new_category["portal_id"] = data["portal_id"]
if "url" in data:
new_category["url"] = data["url"]
categories.append(new_category) categories.append(new_category)
save_categories(categories) save_categories(categories)
@@ -479,14 +765,15 @@ def add_category():
return jsonify({"error": str(e)}) return jsonify({"error": str(e)})
@app.route("/delete-category", methods=["POST"]) @app.route("/delete-category", methods=["POST"])
def delete_category(): def delete_category():
"""Удаление категории""" """Видалення категорії"""
try: try:
data = request.json data = request.json
target_id = str(data["id"])
categories = load_categories() 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) save_categories(categories)
return jsonify({"success": True}) return jsonify({"success": True})
@@ -602,12 +889,15 @@ def get_file_info(filename, directory):
if __name__ == "__main__": if __name__ == "__main__":
# Создаем необходимые директории
for directory in ["output", "output/translated", "output/yml"]: for directory in ["output", "output/translated", "output/yml"]:
os.makedirs(directory, exist_ok=True) os.makedirs(directory, exist_ok=True)
# Создаем файл категорий, если его нет
if not os.path.exists(CATEGORIES_FILE): if not os.path.exists(CATEGORIES_FILE):
save_categories([]) 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)