// ==UserScript==
// @name Ozon Interface Enhancer
// @namespace https://github.com/Zaomil
// @version 1.1.0
// @description Улучшает интерфейс Ozon.by: сортирует отзывы, раскрывает описание, отслеживает цены, строит графики цен
// @author Zaomil
// @license GPL-3.0-or-later
// @icon https://ozon.by/favicon.ico
// @match https://*.ozon.by/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @run-at document-idle
// @homepageURL https://github.com/Zaomil/ozon-enhancer
// @supportURL https://github.com/Zaomil/ozon-enhancer/issues
// @connect ozon.by
// ==/UserScript==
// Copyright (C) 2025 Zaomil
// Licensed under the GNU General Public License v3 or later
// See <https://www.gnu.org/licenses/> for details.
(function() {
'use strict';
// Конфигурация по умолчанию
const DEFAULT_CONFIG = {
sortReviews: true,
expandDescription: true,
trackPrices: true,
maxTrackedItems: 6,
priceDropNotifications: true
};
// Цветовая схема для темной темы интерфейса
const DARK_THEME = {
background: "#121212",
surface: "#1e1e1e",
primary: "#BB86FC",
primaryVariant: "#3700B3",
secondary: "#03DAC6",
text: "#E0E0E0",
textSecondary: "#A0A0A0",
error: "#CF6679",
success: "#00C853",
warning: "#FFAB00",
border: "rgba(255,255,255,0.1)",
shadow: "0 8px 24px rgba(0, 0, 0, 0.5)",
iconFilter: "none"
};
// Текущая цветовая схема
const COLORS = DARK_THEME;
// Форматировщик для белорусских рублей
const BYN_FORMATTER = new Intl.NumberFormat('ru-BY', {
style: 'currency',
currency: 'BYN',
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
// Управление конфигурацией пользователя
const CONFIG = {
get sortReviews() {
return GM_getValue('sortReviews', DEFAULT_CONFIG.sortReviews);
},
set sortReviews(value) {
GM_setValue('sortReviews', value);
},
get expandDescription() {
return GM_getValue('expandDescription', DEFAULT_CONFIG.expandDescription);
},
set expandDescription(value) {
GM_setValue('expandDescription', value);
},
get trackPrices() {
return GM_getValue('trackPrices', DEFAULT_CONFIG.trackPrices);
},
set trackPrices(value) {
GM_setValue('trackPrices', value);
},
get maxTrackedItems() {
const stored = GM_getValue('maxTrackedItems', DEFAULT_CONFIG.maxTrackedItems);
return Math.max(stored, DEFAULT_CONFIG.maxTrackedItems);
},
set maxTrackedItems(value) {
GM_setValue('maxTrackedItems', value);
},
get trackedItems() {
return GM_getValue('trackedItems', []);
},
set trackedItems(value) {
GM_setValue('trackedItems', value);
},
get priceDropNotifications() {
return GM_getValue('priceDropNotifications', DEFAULT_CONFIG.priceDropNotifications);
},
set priceDropNotifications(value) {
GM_setValue('priceDropNotifications', value);
},
get currentPanelTab() {
return GM_getValue('currentPanelTab', 'settings');
},
set currentPanelTab(value) {
GM_setValue('currentPanelTab', value);
},
get lastPriceCheckTime() {
return GM_getValue('lastPriceCheckTime', null);
},
set lastPriceCheckTime(value) {
GM_setValue('lastPriceCheckTime', value);
}
};
// Применение цветовой схемы к интерфейсу
function applyThemeStyles() {
if (panelCreated) {
refreshPanelStyles();
refreshPanel();
}
refreshToggleButton();
applyIconStyles();
}
// Применение стилей к иконкам
function applyIconStyles() {
const icons = document.querySelectorAll('#ozon-enhancer-panel img, #ozon-enhancer-panel .material-icons');
icons.forEach(icon => {
icon.style.filter = COLORS.iconFilter;
});
}
// Обновление стилей кнопки переключения панели
function refreshToggleButton() {
const toggle = document.getElementById('ozon-enhancer-toggle');
if (toggle) {
toggle.style.background = `linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryVariant})`;
}
}
// Состояние скрипта
let isSortingApplied = false;
let panelCreated = false;
let isDescriptionExpanded = false;
let currentTab = CONFIG.currentPanelTab;
let notificationQueue = [];
let isNotificationShowing = false;
let dragStartIndex = null;
let moScheduled = false;
// Селекторы для элементов страницы
const SELECTORS = {
price: [
'[data-widget="webPrice"]',
'[itemprop="price"]',
'.ui-p0-v',
'.ui-q5',
'.ui-q0',
'.ui-o0',
'.ui-o6'
],
gallerySelectors: [
'.gallery-modal',
'.image-gallery',
'.zoom-modal',
'[class*="galleryContainer"]',
'.image-viewer',
'.image-slider'
]
};
// Парсинг цены из текста
function parsePriceText(text) {
if (!text) return null;
const cleaned = text.replace(/\s|[\u00A0\u2007\u202F]/g, '')
.replace(/[^\d.,]/g, '');
const lastCommaIndex = cleaned.lastIndexOf(',');
const lastDotIndex = cleaned.lastIndexOf('.');
const useCommaAsDecimal = lastCommaIndex > lastDotIndex;
const normalized = useCommaAsDecimal
? cleaned.replace(/\./g, '').replace(',', '.')
: cleaned.replace(/,/g, '');
const num = parseFloat(normalized);
return Number.isFinite(num) && num > 0 ? num : null;
}
// Извлечение артикула товара из URL
function extractProductArticle() {
const urlMatch = location.pathname.match(/\/(\d+)(?:\/|\?|$)/);
if (urlMatch?.[1]) return urlMatch[1];
try {
const jsonLd = document.querySelector('script[type="application/ld+json"]');
if (jsonLd) {
const data = JSON.parse(jsonLd.textContent);
return data.sku || data.offers?.sku || data.productID;
}
} catch (e) {
console.error('Ошибка при парсинге JSON-LD:', e);
}
const metaArticle = document.querySelector('meta[property="og:url"]');
if (metaArticle) {
const metaMatch = metaArticle.content.match(/\/(\d+)(?:\/|\?|$)/);
if (metaMatch?.[1]) return metaMatch[1];
}
const cartButtons = document.querySelectorAll('[data-widget="webAddToCart"]');
for (const btn of cartButtons) {
const article = btn.getAttribute('data-article-id');
if (article) return article;
}
return null;
}
// Получение названия товара
function extractProductName() {
return document.querySelector('h1')?.textContent?.trim() || 'Неизвестный товар';
}
// Получение текущей цены товара
function extractCurrentPrice() {
try {
const jsonLd = document.querySelector('script[type="application/ld+json"]');
if (jsonLd) {
try {
const data = JSON.parse(jsonLd.textContent);
const price = data?.offers?.price || (Array.isArray(data?.offers) ? data.offers[0]?.price : null);
if (price) {
const parsed = parsePriceText(String(price));
if (parsed) return parsed;
}
} catch (e) {
console.error('Ошибка при парсинге JSON-LD:', e);
}
}
for (const selector of SELECTORS.price) {
const elements = document.querySelectorAll(selector);
for (const element of elements) {
const price = parsePriceText(element.textContent);
if (price && price > 1) return price;
}
}
return null;
} catch (e) {
console.error('Ошибка при извлечении цены:', e);
return null;
}
}
// Отслеживание текущего товара
function trackCurrentProduct() {
if (!CONFIG.trackPrices) {
showToast('Включите отслеживание цен в настройках расширения', 'warning');
return false;
}
if (CONFIG.trackedItems.length >= CONFIG.maxTrackedItems) {
showToast(`Достигнут лимит отслеживаемых товаров (${CONFIG.maxTrackedItems})`, 'error');
return false;
}
const article = extractProductArticle();
if (!article) {
showToast('Не удалось определить артикул товара', 'error');
return false;
}
if (CONFIG.trackedItems.some(item => item.article === article)) {
showToast('Этот товар уже отслеживается', 'info');
return false;
}
const name = extractProductName();
const price = extractCurrentPrice();
const url = location.href.split('?')[0];
if (!price) {
showToast('Не удалось определить цену товара', 'error');
return false;
}
const newItem = {
article,
name,
url,
currentPrice: price,
initialPrice: price,
priceHistory: [{
price,
date: new Date().toISOString().split('T')[0]
}],
addedDate: new Date().toISOString(),
lastNotifiedPrice: price,
lastUpdated: Date.now(),
notificationThreshold: 0.2
};
CONFIG.trackedItems = [...CONFIG.trackedItems, newItem];
showToast(`Товар добавлен в отслеживание: ${name}`, 'success');
return true;
}
// Отслеживание товара по артикулу
function trackProductByArticle(article) {
if (!CONFIG.trackPrices) {
showToast('Включите отслеживание цен в настройках расширения', 'warning');
return Promise.resolve(false);
}
if (!article || !/^\d+$/.test(article)) {
showToast('Пожалуйста, введите корректный артикул товара', 'error');
return Promise.resolve(false);
}
if (CONFIG.trackedItems.length >= CONFIG.maxTrackedItems) {
showToast(`Достигнут лимит отслеживаемых товаров (${CONFIG.maxTrackedItems})`, 'error');
return Promise.resolve(false);
}
if (CONFIG.trackedItems.some(item => item.article === article)) {
showToast('Этот товар уже отслеживается', 'info');
return Promise.resolve(false);
}
const url = `https://ozon.by/product/${article}/`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
timeout: 15000,
onload: function(response) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, "text/html");
const name = doc.querySelector('h1')?.textContent?.trim() || `Товар #${article}`;
const price = extractPriceFromDocument(doc);
if (!price) {
showToast('Не удалось определить цену товара', 'error');
resolve(false);
return;
}
const newItem = {
article,
name,
url,
currentPrice: price,
initialPrice: price,
priceHistory: [{
price,
date: new Date().toISOString().split('T')[0]
}],
addedDate: new Date().toISOString(),
lastNotifiedPrice: price,
lastUpdated: Date.now(),
notificationThreshold: 0.2
};
CONFIG.trackedItems = [...CONFIG.trackedItems, newItem];
showToast(`Товар добавлен в отслеживание: ${name}`, 'success');
resolve(true);
} catch (e) {
console.error('Ошибка при добавлении товара:', e);
showToast('Ошибка при добавлении товара', 'error');
resolve(false);
}
},
onerror: function() {
showToast('Ошибка при загрузке данных товара', 'error');
resolve(false);
},
ontimeout: function() {
showToast('Превышено время ожидания ответа от сервера', 'error');
resolve(false);
}
});
});
}
// Извлечение цены из DOM документа
function extractPriceFromDocument(doc) {
try {
const jsonLd = doc.querySelector('script[type="application/ld+json"]');
if (jsonLd) {
try {
const data = JSON.parse(jsonLd.textContent);
const price = data?.offers?.price || (Array.isArray(data?.offers) ? data.offers[0]?.price : null);
if (price) {
const parsed = parsePriceText(String(price));
if (parsed) return parsed;
}
} catch (e) {
console.error('Ошибка при парсинге JSON-LD:', e);
}
}
for (const selector of SELECTORS.price) {
const elements = doc.querySelectorAll(selector);
for (const element of elements) {
const price = parsePriceText(element.textContent);
if (price && price > 0) return price;
}
}
return null;
} catch (e) {
console.error('Ошибка при извлечении цены из документа:', e);
return null;
}
}
// Обновление цены отслеживаемого товара
function updateTrackedItemPrice(article, newPrice) {
let priceDropDetected = false;
let notificationItem = null;
let oldPrice = null;
const updatedItems = CONFIG.trackedItems.map(item => {
if (item.article === article) {
if (item.currentPrice === newPrice) {
return item;
}
const threshold = item.notificationThreshold !== undefined ?
item.notificationThreshold :
0.2;
const history = [...item.priceHistory, {
price: newPrice,
date: new Date().toISOString()
}];
const priceDiff = item.currentPrice - newPrice;
let newLastNotifiedPrice = item.lastNotifiedPrice;
if (newPrice < item.currentPrice &&
priceDiff >= threshold &&
(item.lastNotifiedPrice === null || newPrice < item.lastNotifiedPrice)) {
priceDropDetected = true;
notificationItem = { ...item, priceHistory: history };
oldPrice = item.currentPrice;
newLastNotifiedPrice = newPrice;
}
return {
...item,
currentPrice: newPrice,
priceHistory: history,
lastNotifiedPrice: newLastNotifiedPrice,
lastUpdated: Date.now()
};
}
return item;
});
CONFIG.trackedItems = updatedItems;
if (priceDropDetected && CONFIG.priceDropNotifications && notificationItem) {
const priceDiff = (oldPrice - newPrice).toFixed(2);
notificationQueue.push({
title: "🔔 Цена снизилась!",
text: `${notificationItem.name}: ${BYN_FORMATTER.format(newPrice)} (↓${BYN_FORMATTER.format(priceDiff)})`,
image: "https://ozon.by/favicon.ico",
url: notificationItem.url
});
processNotificationQueue();
}
return priceDropDetected;
}
// Показ всплывающего уведомления
function showToast(message, type = 'info') {
const colors = {
info: COLORS.primary,
success: COLORS.success,
warning: COLORS.warning,
error: COLORS.error
};
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 20px;
background: ${COLORS.surface};
color: ${colors[type] || COLORS.text};
border-left: 4px solid ${colors[type] || COLORS.primary};
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10000;
max-width: 400px;
animation: toastIn 0.3s ease-out;
font-size: 14px;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'toastOut 0.3s forwards';
setTimeout(() => {
if (toast.parentNode) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
// Обработка очереди уведомлений
function processNotificationQueue() {
if (isNotificationShowing || notificationQueue.length === 0) return;
isNotificationShowing = true;
const notification = notificationQueue.shift();
if (GM_notification) {
GM_notification({
title: notification.title,
text: notification.text,
image: notification.image,
timeout: 5000,
onclick: () => window.open(notification.url, '_blank'),
ondone: () => {
isNotificationShowing = false;
setTimeout(processNotificationQueue, 1000);
}
});
} else {
showToast(`${notification.title} - ${notification.text}`, 'info');
isNotificationShowing = false;
setTimeout(processNotificationQueue, 500);
}
}
// Удаление товара из отслеживания
function removeTrackedItem(article) {
CONFIG.trackedItems = CONFIG.trackedItems.filter(item => item.article !== article);
showToast('Товар удалён из отслеживания', 'info');
}
// Проверка цен отслеживаемых товаров
function checkTrackedPrices(force = false) {
if (!CONFIG.trackPrices || CONFIG.trackedItems.length === 0) return;
const now = Date.now();
const lastCheckTime = CONFIG.lastPriceCheckTime ? new Date(CONFIG.lastPriceCheckTime).getTime() : null;
const minCheckInterval = 30 * 60 * 1000;
if (!force && lastCheckTime && (now - lastCheckTime < minCheckInterval)) {
return;
}
CONFIG.lastPriceCheckTime = new Date().toISOString();
const requests = CONFIG.trackedItems
.filter(item => {
return force || !item.lastUpdated || (now - item.lastUpdated) > minCheckInterval;
})
.map(item => {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: "GET",
url: item.url,
timeout: 10000,
onload: function(response) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, "text/html");
const price = extractPriceFromDocument(doc);
if (price) updateTrackedItemPrice(item.article, price);
} catch (e) {
console.error('Ошибка при обновлении цены товара:', e);
}
resolve();
},
onerror: function() {
resolve();
},
ontimeout: function() {
resolve();
}
});
});
});
if (requests.length === 0) return;
Promise.all(requests).then(() => {
if (panelCreated) refreshPanel();
showToast(`Проверено ${requests.length} товаров`, 'info');
});
}
// Автоматическое раскрытие описания товара
function expandDescription() {
if (isDescriptionExpanded || !CONFIG.expandDescription) return;
if (!location.pathname.includes('/product/')) return;
const buttonTexts = ['Показать полностью', 'Развернуть описание', 'Читать полностью', 'Показать всё', 'Развернуть'];
for (const btn of document.querySelectorAll('button, [role="button"]')) {
const btnText = btn.textContent?.trim() || '';
if (buttonTexts.some(text => btnText.includes(text)) &&
btn.offsetParent !== null &&
btn.getAttribute('aria-expanded') !== 'true') {
try {
btn.click();
isDescriptionExpanded = true;
return;
} catch (e) {
console.error('Ошибка при раскрытии описания:', e);
}
}
}
}
// Форматирование даты
function formatDate(dateString) {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
// Показ настроек товара
function showItemSettings(item) {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 20000;
backdrop-filter: blur(4px);
animation: fadeIn 0.4s ease-out;
`;
const modalContent = document.createElement('div');
modalContent.style.cssText = `
background: ${COLORS.surface};
border-radius: 12px;
padding: 20px;
width: min(90vw, 400px);
max-height: 90vh;
overflow: hidden;
box-shadow: ${COLORS.shadow};
color: ${COLORS.text};
display: flex;
flex-direction: column;
transform: scale(0.95);
animation: scaleIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
`;
modal.appendChild(modalContent);
const title = document.createElement('div');
title.textContent = `Настройки товара: ${item.name.substring(0, 50)}${item.name.length > 50 ? '...' : ''}`;
title.style.cssText = `
font-weight: 600;
font-size: 18px;
margin-bottom: 15px;
text-align: center;
color: ${COLORS.primary};
text-shadow: 0 0 10px rgba(187, 134, 252, 0.3);
`;
modalContent.appendChild(title);
const thresholdContainer = document.createElement('div');
thresholdContainer.style.cssText = `
margin: 15px 0;
`;
const thresholdLabel = document.createElement('label');
thresholdLabel.textContent = 'Порог уведомления (BYN):';
thresholdLabel.style.cssText = `
display: block;
margin-bottom: 8px;
font-weight: 500;
color: ${COLORS.text};
`;
thresholdContainer.appendChild(thresholdLabel);
const thresholdInput = document.createElement('input');
thresholdInput.type = 'number';
thresholdInput.step = '0.1';
thresholdInput.min = '0.1';
thresholdInput.value = item.notificationThreshold !== undefined ?
item.notificationThreshold :
0.2;
thresholdInput.style.cssText = `
width: 100%;
padding: 10px;
border: 1px solid ${COLORS.border};
border-radius: 6px;
background: rgba(255,255,255,0.05);
color: ${COLORS.text};
font-size: 16px;
box-sizing: border-box;
`;
thresholdContainer.appendChild(thresholdInput);
modalContent.appendChild(thresholdContainer);
const buttonsContainer = document.createElement('div');
buttonsContainer.style.cssText = `
display: flex;
justify-content: space-between;
margin-top: 20px;
gap: 10px;
`;
const cancelButton = document.createElement('button');
cancelButton.textContent = 'Отмена';
cancelButton.style.cssText = `
padding: 10px 20px;
background: rgba(255,255,255,0.1);
color: ${COLORS.text};
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
flex: 1;
`;
cancelButton.addEventListener('mouseover', () => {
cancelButton.style.background = 'rgba(255,255,255,0.15)';
});
cancelButton.addEventListener('mouseout', () => {
cancelButton.style.background = 'rgba(255,255,255,0.1)';
});
cancelButton.addEventListener('click', () => {
modal.remove();
});
buttonsContainer.appendChild(cancelButton);
const saveButton = document.createElement('button');
saveButton.textContent = 'Сохранить';
saveButton.style.cssText = `
padding: 10px 20px;
background: linear-gradient(45deg, ${COLORS.primary}, ${COLORS.primaryVariant});
color: ${COLORS.background};
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
flex: 1;
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
`;
saveButton.addEventListener('mouseover', () => {
saveButton.style.transform = 'scale(1.03)';
saveButton.style.boxShadow = '0 6px 12px rgba(0,0,0,0.3)';
});
saveButton.addEventListener('mouseout', () => {
saveButton.style.transform = 'none';
saveButton.style.boxShadow = '0 4px 10px rgba(0,0,0,0.2)';
});
saveButton.addEventListener('click', () => {
const value = parseFloat(thresholdInput.value);
if (!isNaN(value) && value > 0) {
const updatedItems = CONFIG.trackedItems.map(trackedItem => {
if (trackedItem.article === item.article) {
return {
...trackedItem,
notificationThreshold: value
};
}
return trackedItem;
});
CONFIG.trackedItems = updatedItems;
showToast('Настройки товара сохранены', 'success');
} else {
showToast('Некорректное значение порога', 'error');
}
modal.remove();
});
buttonsContainer.appendChild(saveButton);
modalContent.appendChild(buttonsContainer);
document.body.appendChild(modal);
}
// Показ графика цены товара (исправленная версия)
function showPriceChart(item) {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 20000;
backdrop-filter: blur(4px);
animation: fadeIn 0.4s ease-out;
`;
const modalContent = document.createElement('div');
modalContent.style.cssText = `
background: ${COLORS.surface};
border-radius: 12px;
padding: 20px;
width: min(90vw, 700px);
max-height: 90vh;
overflow: hidden;
box-shadow: ${COLORS.shadow};
color: ${COLORS.text};
display: flex;
flex-direction: column;
transform: scale(0.95);
animation: scaleIn 0.3s cubic-bezier(0.175,0.885,0.32,1.275) forwards;
`;
modal.appendChild(modalContent);
const title = document.createElement('div');
title.textContent = `История цены: ${item.name}`;
title.style.cssText = `
font-weight: 600;
font-size: 18px;
margin-bottom: 15px;
text-align: center;
color: ${COLORS.primary};
text-shadow: 0 0 10px rgba(187,134,252,0.3);
`;
modalContent.appendChild(title);
const infoRow = document.createElement('div');
infoRow.style.cssText = `
display: flex;
justify-content: space-around;
margin-bottom: 15px;
background: linear-gradient(45deg, rgba(30,30,30,0.8), rgba(50,50,50,0.4));
border-radius: 8px;
padding: 12px;
gap: 10px;
flex-wrap: wrap;
`;
const initialPrice = item.initialPrice;
const currentPrice = item.currentPrice;
const minPrice = Math.min(...item.priceHistory.map(p => p.price));
const maxPrice = Math.max(...item.priceHistory.map(p => p.price));
const diff = currentPrice - initialPrice;
const diffPercent = ((Math.abs(diff) / initialPrice) * 100).toFixed(1);
infoRow.innerHTML = `
<div style="text-align:center; min-width:120px;">
<div style="font-size:12px; color:${COLORS.textSecondary}">Текущая</div>
<div style="font-weight:700; font-size:16px; color:${diff < 0 ? COLORS.success : COLORS.text}">
${BYN_FORMATTER.format(currentPrice)}
</div>
<div style="font-size:13px; color:${diff === 0 ? COLORS.textSecondary : diff < 0 ? COLORS.success : COLORS.error}; margin-top:4px;">
${diff === 0 ? 'Без изменений' :
diff < 0 ? `▼ ${BYN_FORMATTER.format(Math.abs(diff))} (${diffPercent}%)` :
`▲ ${BYN_FORMATTER.format(diff)} (${diffPercent}%)`}
</div>
</div>
<div style="text-align:center; min-width:120px;">
<div style="font-size:12px; color:${COLORS.textSecondary}">Начальная</div>
<div style="font-weight:700; font-size:16px;">${BYN_FORMATTER.format(initialPrice)}</div>
</div>
<div style="text-align:center; min-width:120px;">
<div style="font-size:12px; color:${COLORS.textSecondary}">Минимальная</div>
<div style="font-weight:700; font-size:16px; color:${COLORS.success}">${BYN_FORMATTER.format(minPrice)}</div>
</div>
<div style="text-align:center; min-width:120px;">
<div style="font-size:12px; color:${COLORS.textSecondary}">Максимальная</div>
<div style="font-weight:700; font-size:16px; color:${COLORS.error}">${BYN_FORMATTER.format(maxPrice)}</div>
</div>
`;
modalContent.appendChild(infoRow);
if (item.priceHistory.length < 2) {
const message = document.createElement('div');
message.textContent = 'Недостаточно данных для построения графика';
message.style.cssText = 'text-align: center; color: #666; padding: 20px 0;';
modalContent.appendChild(message);
} else {
const chartContainer = document.createElement('div');
chartContainer.style.cssText = 'height: 300px; position: relative;';
modalContent.appendChild(chartContainer);
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '100%';
chartContainer.appendChild(canvas);
// Используем requestAnimationFrame для гарантированной отрисовки
requestAnimationFrame(() => {
if (!canvas.parentElement) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Устанавливаем размеры canvas
const containerRect = chartContainer.getBoundingClientRect();
canvas.width = containerRect.width;
canvas.height = containerRect.height;
const sortedHistory = [...item.priceHistory].sort((a, b) =>
new Date(a.date) - new Date(b.date)
);
const prices = sortedHistory.map(entry => entry.price);
const dates = sortedHistory.map(entry => formatDate(entry.date));
const minVal = Math.min(...prices);
const maxVal = Math.max(...prices);
const range = maxVal - minVal || 1;
const padding = { top: 30, right: 30, bottom: 50, left: 60 };
const graphWidth = canvas.width - padding.left - padding.right;
const graphHeight = canvas.height - padding.top - padding.bottom;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Рисуем сетку
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 1;
ctx.beginPath();
const horizontalLineCount = 6;
for (let i = 0; i < horizontalLineCount; i++) {
const value = minVal + (i / (horizontalLineCount - 1)) * range;
const yCoord = padding.top + graphHeight - ((value - minVal) / range * graphHeight);
ctx.moveTo(padding.left, yCoord);
ctx.lineTo(canvas.width - padding.right, yCoord);
ctx.fillStyle = COLORS.textSecondary;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.font = '12px sans-serif';
ctx.fillText(value.toFixed(2), padding.left - 10, yCoord);
}
ctx.stroke();
// Рисуем оси
ctx.strokeStyle = COLORS.text;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, padding.top + graphHeight);
ctx.moveTo(padding.left, padding.top + graphHeight);
ctx.lineTo(canvas.width - padding.right, padding.top + graphHeight);
ctx.stroke();
// Подписи дат
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = COLORS.text;
ctx.font = '12px sans-serif';
const dateStep = Math.max(1, Math.floor(dates.length / 5));
for (let i = 0; i < dates.length; i += dateStep) {
const xCoord = padding.left + (i / (prices.length - 1)) * graphWidth;
ctx.fillText(dates[i], xCoord, padding.top + graphHeight + 15);
}
// Рисуем область под графиком
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + graphHeight);
gradient.addColorStop(0, 'rgba(187, 134, 252, 0.3)');
gradient.addColorStop(1, 'rgba(187, 134, 252, 0.05)');
ctx.beginPath();
ctx.moveTo(padding.left, padding.top + graphHeight);
for (let i = 0; i < prices.length; i++) {
const xCoord = padding.left + (i / (prices.length - 1)) * graphWidth;
const yCoord = padding.top + graphHeight - ((prices[i] - minVal) / range * graphHeight);
ctx.lineTo(xCoord, yCoord)
}
ctx.lineTo(padding.left + graphWidth, padding.top + graphHeight);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Рисуем линию графика
ctx.beginPath();
for (let i = 0; i < prices.length; i++) {
const xCoord = padding.left + (i / (prices.length - 1)) * graphWidth;
const yCoord = padding.top + graphHeight - ((prices[i] - minVal) / range * graphHeight);
if (i === 0) ctx.moveTo(xCoord, yCoord);
else ctx.lineTo(xCoord, yCoord);
}
ctx.lineWidth = 4;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = COLORS.primary;
ctx.shadowColor = 'rgba(187, 134, 252, 0.5)';
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
// Рисуем точки на графике
ctx.fillStyle = COLORS.primary;
const importantPoints = [
0,
prices.length - 1,
prices.indexOf(minVal),
prices.indexOf(maxVal)
];
for (const index of importantPoints) {
if (index < 0 || index >= prices.length) continue;
const xCoord = padding.left + (index / (prices.length - 1)) * graphWidth;
const yCoord = padding.top + graphHeight - ((prices[index] - minVal) / range * graphHeight);
ctx.beginPath();
ctx.arc(xCoord, yCoord, 8, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = COLORS.background;
ctx.lineWidth = 2;
ctx.stroke();
// Подписи к точкам
ctx.fillStyle = COLORS.primary;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(`${prices[index].toFixed(2)} BYN`, xCoord, yCoord - 10);
}
});
}
const buttonsContainer = document.createElement('div');
buttonsContainer.style.cssText = 'display: flex; justify-content: center; gap: 10px; margin-top: 15px;';
const exportBtn = document.createElement('button');
exportBtn.textContent = 'Экспорт графика';
exportBtn.style.cssText = `
padding: 10px 15px;
background: linear-gradient(45deg, ${COLORS.secondary}, #018786);
color: ${COLORS.background};
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
`;
exportBtn.addEventListener('click', () => {
const data = {
name: item.name,
article: item.article,
priceHistory: item.priceHistory
};
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ozon_price_history_${item.article}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Закрыть';
closeBtn.style.cssText = `
padding: 10px 25px;
background: linear-gradient(45deg, ${COLORS.primary}, ${COLORS.primaryVariant});
color: ${COLORS.background};
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
`;
closeBtn.addEventListener('click', () => modal.remove());
buttonsContainer.appendChild(exportBtn);
buttonsContainer.appendChild(closeBtn);
modalContent.appendChild(buttonsContainer);
document.body.appendChild(modal);
}
// Создание панели управления
function createControlPanel() {
if (panelCreated) return;
panelCreated = true;
const existingPanel = document.getElementById('ozon-enhancer-panel');
if (existingPanel) existingPanel.remove();
const panel = document.createElement('div');
panel.id = 'ozon-enhancer-panel';
panel.style.cssText = `
position: fixed;
top: 60px;
right: 10px;
background: ${COLORS.surface};
border-radius: 12px;
padding: 0;
box-shadow: ${COLORS.shadow};
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
width: 380px;
max-height: 80vh;
overflow: hidden;
border: 1px solid ${COLORS.border};
display: flex;
flex-direction: column;
color: ${COLORS.text};
transform: translateY(10px);
animation: slideIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
`;
const header = document.createElement('div');
header.innerHTML = `<span style="font-size: 20px; margin-right: 8px;">⚡</span> Ozon Enhancer`;
header.style.cssText = `
font-weight: 600;
font-size: 16px;
padding: 14px 16px;
background: linear-gradient(45deg, ${COLORS.background}, rgba(30,30,30,0.9));
color: ${COLORS.primary};
display: flex;
align-items: center;
gap: 8px;
position: relative;
border-bottom: 1px solid ${COLORS.border};
text-shadow: 0 0 10px rgba(187, 134, 252, 0.3);
`;
panel.appendChild(header);
const tabContainer = document.createElement('div');
tabContainer.id = 'ozon-tab-container';
tabContainer.style.cssText = `
display: flex;
background: ${COLORS.background};
border-bottom: 1px solid ${COLORS.border};
`;
const createTab = (id, label) => {
const tab = document.createElement('div');
tab.dataset.tab = id;
tab.textContent = label;
tab.style.cssText = `
padding: 12px 16px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
border-bottom: 2px solid transparent;
flex: 1;
text-align: center;
`;
if (currentTab === id) {
tab.style.borderBottomColor = COLORS.primary;
tab.style.color = COLORS.primary;
tab.style.background = 'rgba(187, 134, 252, 0.1)';
} else {
tab.style.color = COLORS.textSecondary;
}
tab.addEventListener('click', () => {
currentTab = id;
CONFIG.currentPanelTab = id;
refreshPanel();
});
return tab;
};
tabContainer.appendChild(createTab('settings', 'Настройки'));
tabContainer.appendChild(createTab('tracking', 'Отслеживание'));
panel.appendChild(tabContainer);
const contentContainer = document.createElement('div');
contentContainer.id = 'ozon-panel-content';
contentContainer.style.cssText = `
padding: 0;
overflow-y: auto;
flex-grow: 1;
`;
panel.appendChild(contentContainer);
document.body.appendChild(panel);
refreshPanel();
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.title = 'Закрыть панель';
closeBtn.style.cssText = `
position: absolute;
top: 14px;
right: 14px;
background: rgba(255,255,255,0.1);
border: none;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: ${COLORS.text};
font-size: 20px;
line-height: 1;
transition: all 0.2s;
`;
closeBtn.addEventListener('mouseover', () => {
closeBtn.style.background = 'rgba(255,255,255,0.2)';
closeBtn.style.transform = 'rotate(90deg)';
});
closeBtn.addEventListener('mouseout', () => {
closeBtn.style.background = 'rgba(255,255,255,0.1)';
closeBtn.style.transform = 'rotate(0)';
});
closeBtn.addEventListener('click', () => {
panel.style.animation = 'fadeOut 0.3s forwards';
setTimeout(() => {
panel.remove();
panelCreated = false;
}, 300);
});
header.appendChild(closeBtn);
return panel;
}
// Обновление стилей панели
function refreshPanelStyles() {
const panel = document.getElementById('ozon-enhancer-panel');
if (!panel) return;
panel.style.background = COLORS.surface;
panel.style.color = COLORS.text;
panel.style.borderColor = COLORS.border;
panel.style.boxShadow = COLORS.shadow;
const header = panel.querySelector('div:first-child');
if (header) {
header.style.background = `linear-gradient(45deg, ${COLORS.background}, rgba(30,30,30,0.9))`;
header.style.color = COLORS.primary;
header.style.borderBottomColor = COLORS.border;
}
const tabContainer = document.getElementById('ozon-tab-container');
if (tabContainer) {
tabContainer.style.background = COLORS.background;
tabContainer.style.borderBottomColor = COLORS.border;
}
}
// Обновление содержимого панели
function refreshPanel() {
if (!panelCreated) return;
const tabContainer = document.getElementById('ozon-tab-container');
if (tabContainer) {
const tabs = tabContainer.querySelectorAll('[data-tab]');
tabs.forEach(tab => {
if (tab.dataset.tab === currentTab) {
tab.style.borderBottomColor = COLORS.primary;
tab.style.color = COLORS.primary;
tab.style.background = 'rgba(187, 134, 252, 0.1)';
} else {
tab.style.borderBottomColor = 'transparent';
tab.style.color = COLORS.textSecondary;
tab.style.background = 'transparent';
}
});
}
const contentContainer = document.getElementById('ozon-panel-content');
if (!contentContainer) return;
contentContainer.innerHTML = '';
switch (currentTab) {
case 'settings':
renderSettingsTab(contentContainer);
break;
case 'tracking':
renderTrackingTab(contentContainer);
break;
}
}
// Рендер вкладки настроек
function renderSettingsTab(container) {
const settingsContainer = document.createElement('div');
settingsContainer.style.padding = '16px';
container.appendChild(settingsContainer);
settingsContainer.appendChild(createToggle(
'Сортировка отзывов (от худших)',
'📊',
CONFIG.sortReviews,
checked => {
CONFIG.sortReviews = checked;
if (checked) {
isSortingApplied = false;
sortReviews();
}
}
));
settingsContainer.appendChild(createToggle(
'Авто-раскрытие описания',
'📝',
CONFIG.expandDescription,
checked => {
CONFIG.expandDescription = checked;
if (checked) expandDescription();
}
));
settingsContainer.appendChild(createToggle(
'Отслеживание цен',
'💰',
CONFIG.trackPrices,
checked => CONFIG.trackPrices = checked
));
settingsContainer.appendChild(createToggle(
'Уведомления о снижении цен',
'🔔',
CONFIG.priceDropNotifications,
checked => CONFIG.priceDropNotifications = checked
));
}
// Рендер вкладки отслеживания
function renderTrackingTab(container) {
const trackingContainer = document.createElement('div');
trackingContainer.style.padding = '16px';
container.appendChild(trackingContainer);
const headerRow = document.createElement('div');
headerRow.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
`;
const title = document.createElement('div');
title.textContent = 'Отслеживание цен';
title.style.cssText = `
font-weight: 600;
font-size: 15px;
color: ${COLORS.text};
`;
headerRow.appendChild(title);
const stats = document.createElement('div');
stats.textContent = `${CONFIG.trackedItems.length} из ${CONFIG.maxTrackedItems}`;
stats.style.cssText = `
font-size: 13px;
color: ${COLORS.textSecondary};
`;
headerRow.appendChild(stats);
trackingContainer.appendChild(headerRow);
const actionsRow = document.createElement('div');
actionsRow.style.cssText = `
display: flex;
gap: 10px;
margin-bottom: 15px;
`;
const refreshButton = document.createElement('button');
refreshButton.textContent = 'Обновить цены';
refreshButton.style.cssText = `
background: rgba(255,255,255,0.1);
border: none;
padding: 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: ${COLORS.text};
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
`;
refreshButton.innerHTML = '🔄 ' + refreshButton.textContent;
refreshButton.addEventListener('mouseover', () => {
refreshButton.style.background = 'rgba(255,255,255,0.15)';
refreshButton.style.transform = 'translateY(-1px)';
});
refreshButton.addEventListener('mouseout', () => {
refreshButton.style.background = 'rgba(255,255,255,0.1)';
refreshButton.style.transform = 'none';
});
refreshButton.addEventListener('click', () => {
refreshButton.textContent = 'Обновление...';
refreshButton.disabled = true;
checkTrackedPrices(true);
setTimeout(() => {
refreshButton.textContent = 'Обновить цены';
refreshButton.disabled = false;
}, 3000);
});
actionsRow.appendChild(refreshButton);
if (location.pathname.includes('/product/')) {
const addButton = document.createElement('button');
addButton.textContent = 'Добавить текущий';
addButton.style.cssText = `
background: linear-gradient(45deg, ${COLORS.primary}, ${COLORS.primaryVariant});
color: ${COLORS.background};
border: none;
padding: 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
`;
addButton.innerHTML = '➕ ' + addButton.textContent;
addButton.addEventListener('mouseover', () => {
addButton.style.transform = 'scale(1.03)';
addButton.style.boxShadow = '0 6px 12px rgba(0,0,0,0.3)';
});
addButton.addEventListener('mouseout', () => {
addButton.style.transform = 'none';
addButton.style.boxShadow = '0 4px 10px rgba(0,0,0,0.2)';
});
addButton.addEventListener('click', () => trackCurrentProduct() && refreshPanel());
actionsRow.appendChild(addButton);
}
trackingContainer.appendChild(actionsRow);
const importExportRow = document.createElement('div');
importExportRow.style.cssText = `
display: flex;
gap: 8px;
margin: 12px 0 15px;
`;
const exportButton = document.createElement('button');
exportButton.textContent = 'Экспорт данных';
exportButton.style.cssText = `
flex: 1;
padding: 10px;
background: linear-gradient(45deg, ${COLORS.secondary}, #018786);
color: ${COLORS.background};
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
`;
exportButton.innerHTML = '📤 ' + exportButton.textContent;
exportButton.addEventListener('mouseover', () => {
exportButton.style.transform = 'scale(1.03)';
exportButton.style.boxShadow = '0 6px 12px rgba(0,0,0,0.3)';
});
exportButton.addEventListener('mouseout', () => {
exportButton.style.transform = 'none';
exportButton.style.boxShadow = '0 4px 10px rgba(0,0,0,0.2)';
});
exportButton.addEventListener('click', () => {
const data = JSON.stringify(CONFIG.trackedItems, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ozon_tracking_data_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
const importButton = document.createElement('button');
importButton.textContent = 'Импорт данных';
importButton.style.cssText = `
flex: 1;
padding: 10px;
background: linear-gradient(45deg, ${COLORS.primary}, ${COLORS.primaryVariant});
color: ${COLORS.background};
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
`;
importButton.innerHTML = '📥 ' + importButton.textContent;
importButton.addEventListener('mouseover', () => {
importButton.style.transform = 'scale(1.03)';
importButton.style.boxShadow = '0 6px 12px rgba(0,0,0,0.3)';
});
importButton.addEventListener('mouseout', () => {
importButton.style.transform = 'none';
importButton.style.boxShadow = '0 4px 10px rgba(0,0,0,0.2)';
});
importButton.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.style.display = 'none';
input.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const importedData = JSON.parse(event.target.result);
const currentItems = [...CONFIG.trackedItems];
importedData.forEach(importedItem => {
const existingIndex = currentItems.findIndex(item => item.article === importedItem.article);
if (existingIndex >= 0) {
const existingItem = currentItems[existingIndex];
const mergedHistory = [...existingItem.priceHistory];
importedItem.priceHistory.forEach(importedPrice => {
if (!mergedHistory.some(p => p.date === importedPrice.date)) {
mergedHistory.push(importedPrice);
}
});
mergedHistory.sort((a, b) => new Date(a.date) - new Date(b.date));
currentItems[existingIndex] = {
...existingItem,
priceHistory: mergedHistory,
initialPrice: Math.min(existingItem.initialPrice, importedItem.initialPrice),
currentPrice: importedItem.currentPrice || existingItem.currentPrice,
notificationThreshold: importedItem.notificationThreshold !== undefined ?
importedItem.notificationThreshold : existingItem.notificationThreshold
};
} else {
currentItems.push({
...importedItem,
notificationThreshold: importedItem.notificationThreshold !== undefined ?
importedItem.notificationThreshold : 0.2
});
}
});
CONFIG.trackedItems = currentItems;
refreshPanel();
showToast(`Успешно импортировано ${importedData.length} товаров`, 'success');
} catch (error) {
showToast('Ошибка при импорте данных: ' + error.message, 'error');
}
};
reader.readAsText(file);
});
document.body.appendChild(input);
input.click();
setTimeout(() => document.body.removeChild(input), 100);
});
importExportRow.appendChild(exportButton);
importExportRow.appendChild(importButton);
trackingContainer.appendChild(importExportRow);
const manualAddForm = document.createElement('div');
manualAddForm.style.cssText = `
display: flex;
gap: 8px;
margin: 12px 0 20px;
`;
const articleInput = document.createElement('input');
articleInput.type = 'text';
articleInput.placeholder = 'Введите артикул товара';
articleInput.style.cssText = `
flex-grow: 1;
padding: 10px;
border: 1px solid ${COLORS.border};
border-radius: 6px;
font-size: 13px;
background: rgba(255,255,255,0.05);
color: ${COLORS.text};
outline: none;
transition: all 0.2s;
`;
articleInput.addEventListener('focus', () => {
articleInput.style.borderColor = COLORS.primary;
articleInput.style.boxShadow = `0 0 0 2px ${COLORS.primary}33`;
});
articleInput.addEventListener('blur', () => {
articleInput.style.borderColor = COLORS.border;
articleInput.style.boxShadow = 'none';
});
const manualAddButton = document.createElement('button');
manualAddButton.textContent = 'Добавить';
manualAddButton.style.cssText = `
background: linear-gradient(45deg, ${COLORS.primary}, ${COLORS.primaryVariant});
color: ${COLORS.background};
border: none;
padding: 10px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
transition: all 0.2s;
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
`;
manualAddButton.addEventListener('mouseover', () => {
manualAddButton.style.transform = 'scale(1.03)';
manualAddButton.style.boxShadow = '0 6px 12px rgba(0,0,0,0.3)';
});
manualAddButton.addEventListener('mouseout', () => {
manualAddButton.style.transform = 'none';
manualAddButton.style.boxShadow = '0 4px 10px rgba(0,0,0,0.2)';
});
manualAddButton.addEventListener('click', () => {
const article = articleInput.value.trim();
if (!article) {
showToast('Пожалуйста, введите артикул товара', 'error');
return;
}
manualAddButton.textContent = 'Добавление...';
manualAddButton.disabled = true;
trackProductByArticle(article).then(success => {
manualAddButton.textContent = 'Добавить';
manualAddButton.disabled = false;
if (success) {
articleInput.value = '';
refreshPanel();
}
});
});
manualAddForm.appendChild(articleInput);
manualAddForm.appendChild(manualAddButton);
trackingContainer.appendChild(manualAddForm);
const trackedItemsContainer = document.createElement('div');
trackedItemsContainer.id = 'ozon-tracked-items';
trackedItemsContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
overflow-y: auto;
padding-right: 4px;
`;
if (CONFIG.trackedItems.length === 0) {
const emptyState = document.createElement('div');
emptyState.textContent = 'Нет отслеживаемых товаров';
emptyState.style.cssText = `
text-align: center;
padding: 30px 15px;
color: ${COLORS.textSecondary};
font-size: 13px;
background: rgba(255,255,255,0.03);
border-radius: 8px;
`;
trackedItemsContainer.appendChild(emptyState);
} else {
CONFIG.trackedItems.forEach((item, index) => {
const itemEl = document.createElement('div');
itemEl.dataset.index = index;
itemEl.draggable = true;
itemEl.style.cssText = `
background: linear-gradient(45deg, rgba(30,30,30,0.8), rgba(50,50,50,0.4));
border-radius: 8px;
padding: 12px;
position: relative;
transition: all 0.2s;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
cursor: grab;
`;
itemEl.addEventListener('mouseover', () => {
itemEl.style.transform = 'translateY(-2px)';
itemEl.style.boxShadow = '0 6px 12px rgba(0,0,0,0.2)';
});
itemEl.addEventListener('mouseout', () => {
itemEl.style.transform = 'none';
itemEl.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
});
itemEl.addEventListener('dragstart', (e) => {
dragStartIndex = parseInt(itemEl.dataset.index);
e.dataTransfer.setData('text/plain', dragStartIndex.toString());
itemEl.style.opacity = '0.4';
});
itemEl.addEventListener('dragenter', (e) => {
e.preventDefault();
itemEl.style.border = `2px dashed ${COLORS.primary}`;
});
itemEl.addEventListener('dragover', (e) => {
e.preventDefault();
});
itemEl.addEventListener('dragleave', () => {
itemEl.style.border = 'none';
});
itemEl.addEventListener('drop', (e) => {
e.preventDefault();
itemEl.style.border = 'none';
const dragEndIndex = parseInt(itemEl.dataset.index);
if (dragStartIndex === dragEndIndex) return;
const items = [...CONFIG.trackedItems];
const draggedItem = items[dragStartIndex];
items.splice(dragStartIndex, 1);
items.splice(dragEndIndex, 0, draggedItem);
CONFIG.trackedItems = items;
refreshPanel();
});
itemEl.addEventListener('dragend', () => {
itemEl.style.opacity = '1';
dragStartIndex = null;
});
const itemName = document.createElement('a');
itemName.href = item.url;
itemName.textContent = item.name.length > 40 ? item.name.substring(0, 40) + '...' : item.name;
itemName.title = item.name;
itemName.target = '_blank';
itemName.style.cssText = `
font-weight: 500;
display: block;
margin-bottom: 8px;
text-decoration: none;
color: ${COLORS.primary};
font-size: 14px;
transition: all 0.2s;
`;
itemName.addEventListener('mouseover', () => {
itemName.style.textShadow = `0 0 8px ${COLORS.primary}80`;
});
itemName.addEventListener('mouseout', () => {
itemName.style.textShadow = 'none';
});
const priceInfo = document.createElement('div');
const initialPrice = item.initialPrice;
const currentPrice = item.currentPrice;
const diff = currentPrice - initialPrice;
const diffPercent = ((Math.abs(diff) / initialPrice) * 100).toFixed(1);
priceInfo.innerHTML = `
<div style="font-size: 16px; font-weight: 700; color: ${diff < 0 ? COLORS.success : COLORS.text}">
${BYN_FORMATTER.format(currentPrice)}
</div>
<div style="font-size: 13px; color: ${COLORS.textSecondary}; margin-top: 4px;">
${diff === 0 ? 'Без изменений' :
diff < 0 ? `▼ ${BYN_FORMATTER.format(Math.abs(diff))} (${diffPercent}%)` :
`▲ ${BYN_FORMATTER.format(diff)} (${diffPercent}%)`}
</div>
<div style="font-size: 12px; color: ${COLORS.textSecondary}; margin-top: 2px;">
Добавлен: ${new Date(item.addedDate).toLocaleDateString()}
</div>
`;
const buttonsContainer = document.createElement('div');
buttonsContainer.style.cssText = `
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 10px;
`;
const settingsBtn = document.createElement('button');
settingsBtn.title = 'Настройки товара';
settingsBtn.style.cssText = `
padding: 6px 12px;
background: rgba(255,255,255,0.1);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: ${COLORS.text};
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
`;
settingsBtn.innerHTML = '⚙️ Настройки';
settingsBtn.addEventListener('mouseover', () => {
settingsBtn.style.background = 'rgba(255,255,255,0.15)';
settingsBtn.style.transform = 'translateY(-1px)';
});
settingsBtn.addEventListener('mouseout', () => {
settingsBtn.style.background = 'rgba(255,255,255,0.1)';
settingsBtn.style.transform = 'none';
});
settingsBtn.addEventListener('click', (e) => {
e.preventDefault();
showItemSettings(item);
});
const chartBtn = document.createElement('button');
chartBtn.title = 'Показать график цены';
chartBtn.style.cssText = `
padding: 6px 12px;
background: rgba(255,255,255,0.1);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: ${COLORS.text};
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
`;
chartBtn.innerHTML = '📈 График';
chartBtn.addEventListener('mouseover', () => {
chartBtn.style.background = 'rgba(255,255,255,0.15)';
chartBtn.style.transform = 'translateY(-1px)';
});
chartBtn.addEventListener('mouseout', () => {
chartBtn.style.background = 'rgba(255,255,255,0.1)';
chartBtn.style.transform = 'none';
});
chartBtn.addEventListener('click', (e) => {
e.preventDefault();
showPriceChart(item);
});
const removeBtn = document.createElement('button');
removeBtn.title = 'Удалить из отслеживания';
removeBtn.style.cssText = `
padding: 6px 12px;
background: rgba(255, 100, 100, 0.1);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: ${COLORS.error};
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
`;
removeBtn.innerHTML = '✕ Удалить';
removeBtn.addEventListener('mouseover', () => {
removeBtn.style.background = 'rgba(255, 100, 100, 0.2)';
removeBtn.style.transform = 'translateY(-1px)';
});
removeBtn.addEventListener('mouseout', () => {
removeBtn.style.background = 'rgba(255, 100, 100, 0.1)';
removeBtn.style.transform = 'none';
});
removeBtn.addEventListener('click', (e) => {
e.preventDefault();
removeTrackedItem(item.article);
refreshPanel();
});
buttonsContainer.appendChild(settingsBtn);
buttonsContainer.appendChild(chartBtn);
buttonsContainer.appendChild(removeBtn);
itemEl.appendChild(itemName);
itemEl.appendChild(priceInfo);
itemEl.appendChild(buttonsContainer);
trackedItemsContainer.appendChild(itemEl);
});
}
trackingContainer.appendChild(trackedItemsContainer);
if (CONFIG.lastPriceCheckTime) {
const lastCheckTime = new Date(CONFIG.lastPriceCheckTime);
const lastCheck = document.createElement('div');
lastCheck.textContent = `Последняя проверка: ${lastCheckTime.toLocaleDateString('ru-RU')} ${lastCheckTime.toLocaleTimeString('ru-RU', {hour: '2-digit', minute:'2-digit'})}`;
lastCheck.style.cssText = `
font-size: 12px;
color: ${COLORS.textSecondary};
text-align: right;
margin-top: 10px;
`;
trackingContainer.appendChild(lastCheck);
}
}
// Создание элемента переключателя
function createToggle(label, icon, checked, onChange) {
const container = document.createElement('div');
container.style.cssText = `
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid ${COLORS.border};
`;
const iconEl = document.createElement('div');
iconEl.textContent = icon;
iconEl.style.cssText = 'font-size: 18px; margin-right: 10px; width: 22px; text-align: center;';
container.appendChild(iconEl);
const textContainer = document.createElement('div');
textContainer.style.flex = '1';
const labelEl = document.createElement('div');
labelEl.textContent = label;
labelEl.style.cssText = `
font-weight: 500;
font-size: 13px;
color: ${COLORS.text};
`;
textContainer.appendChild(labelEl);
container.appendChild(textContainer);
const toggleContainer = document.createElement('label');
toggleContainer.style.cssText = `
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
`;
const toggleInput = document.createElement('input');
toggleInput.type = 'checkbox';
toggleInput.checked = checked;
toggleInput.style.cssText = `
opacity: 0;
width: 0;
height: 0;
`;
toggleInput.addEventListener('change', () => onChange(toggleInput.checked));
const toggleSlider = document.createElement('span');
toggleSlider.style.cssText = `
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #444;
transition: .4s;
border-radius: 22px;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.3);
`;
const toggleKnob = document.createElement('span');
toggleKnob.style.cssText = `
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: white;
transition: .4s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
`;
toggleSlider.appendChild(toggleKnob);
toggleContainer.appendChild(toggleInput);
toggleContainer.appendChild(toggleSlider);
container.appendChild(toggleContainer);
const updateToggleStyle = () => {
if (toggleInput.checked) {
toggleSlider.style.backgroundColor = COLORS.primary;
toggleSlider.style.boxShadow = `inset 0 0 8px ${COLORS.primary}80`;
toggleKnob.style.transform = 'translateX(18px)';
iconEl.style.textShadow = `0 0 8px ${COLORS.primary}80`;
} else {
toggleSlider.style.backgroundColor = '#444';
toggleSlider.style.boxShadow = 'inset 0 1px 3px rgba(0,0,0,0.3)';
toggleKnob.style.transform = 'translateX(0)';
iconEl.style.textShadow = 'none';
}
};
toggleInput.addEventListener('change', updateToggleStyle);
updateToggleStyle();
return container;
}
// Создание кнопки активации панели
function createPanelToggle() {
if (document.getElementById('ozon-enhancer-toggle')) return;
const toggle = document.createElement('button');
toggle.id = 'ozon-enhancer-toggle';
toggle.innerHTML = '<span style="font-size: 16px; margin-right: 6px;">⚡</span> Ozon Enhancer';
toggle.addEventListener('click', createControlPanel);
document.body.appendChild(toggle);
return toggle;
}
// Проверка открытия галереи изображений
function isGalleryOpen() {
for (const selector of SELECTORS.gallerySelectors) {
if (document.querySelector(selector)) {
return true;
}
}
return false;
}
// Сортировка отзывов по рейтингу
function sortReviews() {
if (!CONFIG.sortReviews || isSortingApplied) return;
if (!location.pathname.includes('/product/')) return;
const urlObj = new URL(location.href);
const params = urlObj.searchParams;
if (params.get('sort') !== 'score_asc') {
params.set('sort', 'score_asc');
history.replaceState(null, '', urlObj.toString());
isSortingApplied = true;
setTimeout(() => window.location.href = urlObj.toString(), 100);
}
}
// Управление DOM-обновлениями
function scheduleDomUpdate() {
if (moScheduled) return;
moScheduled = true;
requestAnimationFrame(() => {
moScheduled = false;
createPanelToggle();
isDescriptionExpanded = false;
expandDescription();
const toggleBtn = document.getElementById('ozon-enhancer-toggle');
if (toggleBtn) {
toggleBtn.style.display = isGalleryOpen() ? 'none' : 'flex';
}
});
}
// Добавление глобальных стилей
GM_addStyle(`
#ozon-enhancer-panel {
transition: all 0.3s ease;
}
#ozon-enhancer-toggle {
position: fixed !important;
top: 10px !important;
right: 10px !important;
background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryVariant}) !important;
color: ${COLORS.background} !important;
border: none !important;
border-radius: 6px !important;
padding: 10px 16px !important;
cursor: pointer !important;
z-index: 2147483647 !important;
font-size: 14px !important;
font-weight: 600 !important;
box-shadow: 0 5px 15px rgba(0,0,0,0.4) !important;
transition: all 0.2s ease !important;
display: flex;
align-items: center;
gap: 6px;
animation: pulse 2s infinite;
}
#ozon-enhancer-toggle:hover {
background: linear-gradient(135deg, #9a65d1 0%, #5d3a9e 100%) !important;
transform: translateY(-2px) scale(1.05) !important;
box-shadow: 0 8px 20px rgba(0,0,0,0.5) !important;
animation: none;
}
#ozon-enhancer-toggle:active {
transform: translateY(0) scale(1) !important;
}
#ozon-tracked-items::-webkit-scrollbar {
width: 6px;
}
#ozon-tracked-items::-webkit-scrollbar-track {
background: rgba(255,255,255,0.05);
}
#ozon-tracked-items::-webkit-scrollbar-thumb {
background: ${COLORS.primary};
border-radius: 4px;
}
#ozon-panel-content::-webkit-scrollbar {
width: 6px;
}
#ozon-panel-content::-webkit-scrollbar-track {
background: transparent;
}
#ozon-panel-content::-webkit-scrollbar-thumb {
background: ${COLORS.primary};
border-radius: 4px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes slideIn {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(187, 134, 252, 0.5); }
70% { box-shadow: 0 0 0 10px rgba(187, 134, 252, 0); }
100% { box-shadow: 0 0 0 0 rgba(187, 134, 252, 0); }
}
@keyframes toastIn {
from { transform: translateY(100px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes toastOut {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(100px); opacity: 0; }
}
`);
// Обработчики изменения истории браузера
const updateState = (type) => {
const orig = history[type];
return function() {
const result = orig.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return result;
};
};
history.pushState = updateState('pushState');
history.replaceState = updateState('replaceState');
window.addEventListener('popstate', () => window.dispatchEvent(new Event('locationchange')));
// Инициализация скрипта
function init() {
if (CONFIG.maxTrackedItems < DEFAULT_CONFIG.maxTrackedItems) {
CONFIG.maxTrackedItems = DEFAULT_CONFIG.maxTrackedItems;
}
applyThemeStyles();
createPanelToggle();
sortReviews();
expandDescription();
const observer = new MutationObserver(scheduleDomUpdate);
observer.observe(document.body, {
childList: true,
subtree: true
});
let expandAttempts = 0;
const expandInterval = setInterval(() => {
if (!location.pathname.includes('/product/')) return;
if (isDescriptionExpanded || expandAttempts >= 5) {
clearInterval(expandInterval);
return;
}
expandDescription();
expandAttempts++;
}, 3000);
setInterval(() => checkTrackedPrices(), 6 * 60 * 60 * 1000);
setTimeout(() => checkTrackedPrices(), 60000);
window.addEventListener('beforeunload', () => {
clearInterval(expandInterval);
observer.disconnect();
});
}
// Запуск скрипта
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
setTimeout(init, 1000);
}
// Обработка смены URL
window.addEventListener('locationchange', () => {
isSortingApplied = false;
isDescriptionExpanded = false;
sortReviews();
expandDescription();
if (panelCreated) refreshPanel();
});
})();