Size
139.5 KB
Version
1.8.9
Created
Dec 11, 2025
Updated
23 days ago
1// ==UserScript==
2// @name Ozon Product Parser
3// @description Парсер товаров с Ozon для анализа поисковой выдачи
4// @version 1.8.9
5// @match https://*.ozon.ru/*
6// @match https://seller.ozon.ru/*
7// @grant GM.getValue
8// @grant GM.setValue
9// @grant GM.deleteValue
10// @icon https://st.ozone.ru/assets/favicon.ico
11// ==/UserScript==
12(function() {
13 'use strict';
14 console.log('Ozon Product Parser: Extension loaded');
15
16 // Утилита для дебаунса
17 function debounce(func, wait) {
18 let timeout;
19 return function executedFunction(...args) {
20 const later = () => {
21 clearTimeout(timeout);
22 func(...args);
23 };
24 clearTimeout(timeout);
25 timeout = setTimeout(later, wait);
26 };
27 }
28
29 // Добавляем стили
30 function addStyles() {
31 const styles = `
32 .ozon-parser-container {
33 position: fixed;
34 top: 20px;
35 right: 20px;
36 z-index: 10000;
37 display: flex;
38 gap: 10px;
39 }
40 .ozon-parser-btn {
41 background: linear-gradient(135deg, #005bff 0%, #0043c7 100%);
42 color: white;
43 border: none;
44 padding: 12px 24px;
45 border-radius: 8px;
46 cursor: pointer;
47 font-size: 14px;
48 font-weight: 600;
49 box-shadow: 0 4px 12px rgba(0, 91, 255, 0.3);
50 transition: all 0.3s ease;
51 }
52 .ozon-parser-btn:hover {
53 background: linear-gradient(135deg, #0043c7 0%, #002f8f 100%);
54 box-shadow: 0 6px 16px rgba(0, 91, 255, 0.4);
55 transform: translateY(-2px);
56 }
57 .ozon-parser-btn:active {
58 transform: translateY(0);
59 }
60 .ozon-parser-btn.secondary {
61 background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
62 box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
63 }
64 .ozon-parser-btn.secondary:hover {
65 background: linear-gradient(135deg, #1e7e34 0%, #155724 100%);
66 box-shadow: 0 6px 16px rgba(40, 167, 69, 0.4);
67 }
68 .ozon-parser-modal {
69 position: fixed;
70 top: 0;
71 left: 0;
72 width: 100%;
73 height: 100%;
74 background: rgba(0, 0, 0, 0.7);
75 display: flex;
76 justify-content: center;
77 align-items: center;
78 z-index: 10001;
79 }
80 .ozon-parser-modal-content {
81 background: white;
82 padding: 30px;
83 border-radius: 12px;
84 max-width: 800px;
85 width: 90%;
86 max-height: 80vh;
87 overflow-y: auto;
88 box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
89 }
90 .ozon-parser-modal-header {
91 font-size: 24px;
92 font-weight: 700;
93 margin-bottom: 20px;
94 color: #333;
95 }
96 .ozon-parser-modal-body {
97 margin-bottom: 20px;
98 }
99 .ozon-parser-textarea {
100 width: 100%;
101 min-height: 200px;
102 padding: 12px;
103 border: 2px solid #e0e0e0;
104 border-radius: 8px;
105 font-size: 14px;
106 font-family: inherit;
107 resize: vertical;
108 box-sizing: border-box;
109 }
110 .ozon-parser-textarea:focus {
111 outline: none;
112 border-color: #005bff;
113 }
114 .ozon-parser-modal-footer {
115 display: flex;
116 gap: 10px;
117 justify-content: flex-end;
118 }
119 .ozon-parser-progress {
120 margin-top: 20px;
121 padding: 15px;
122 background: #f8f9fa;
123 border-radius: 8px;
124 font-size: 14px;
125 color: #333;
126 }
127 .ozon-parser-progress-bar {
128 width: 100%;
129 height: 8px;
130 background: #e0e0e0;
131 border-radius: 4px;
132 margin-top: 10px;
133 overflow: hidden;
134 }
135 .ozon-parser-progress-fill {
136 height: 100%;
137 background: linear-gradient(90deg, #005bff 0%, #0043c7 100%);
138 transition: width 0.3s ease;
139 }
140 .ozon-parser-results-table {
141 width: 100%;
142 border-collapse: collapse;
143 margin-top: 20px;
144 font-size: 13px;
145 }
146 .ozon-parser-results-table th,
147 .ozon-parser-results-table td {
148 padding: 12px;
149 text-align: left;
150 border-bottom: 1px solid #e0e0e0;
151 white-space: nowrap;
152 }
153 .ozon-parser-results-table th {
154 background: #f8f9fa;
155 font-weight: 600;
156 color: #333;
157 position: sticky;
158 top: 0;
159 }
160 .ozon-parser-results-table td:nth-child(3) {
161 max-width: 500px;
162 white-space: normal;
163 word-wrap: break-word;
164 }
165 .ozon-parser-results-table tr:hover {
166 background: #f8f9fa;
167 }
168 .ozon-parser-highlight {
169 background: #fff3cd !important;
170 font-weight: 600;
171 }
172 .ozon-parser-sku-link {
173 color: #005bff;
174 text-decoration: none;
175 font-weight: 600;
176 }
177 .ozon-parser-sku-link:hover {
178 text-decoration: underline;
179 }
180 .ozon-parser-tabs {
181 display: flex;
182 gap: 5px;
183 margin-bottom: 20px;
184 flex-wrap: wrap;
185 }
186 .ozon-parser-tab {
187 padding: 10px 20px;
188 background: #f8f9fa;
189 border: none;
190 border-radius: 6px;
191 cursor: pointer;
192 font-size: 14px;
193 transition: all 0.2s ease;
194 }
195 .ozon-parser-tab:hover {
196 background: #e9ecef;
197 }
198 .ozon-parser-tab.active {
199 background: #005bff;
200 color: white;
201 font-weight: 600;
202 }
203 .ozon-parser-info {
204 padding: 10px;
205 background: #e7f3ff;
206 border-left: 4px solid #005bff;
207 border-radius: 4px;
208 margin-bottom: 15px;
209 font-size: 13px;
210 color: #333;
211 }
212 .ozon-parser-search {
213 width: 100%;
214 padding: 10px 12px;
215 border: 2px solid #e0e0e0;
216 border-radius: 8px;
217 font-size: 14px;
218 margin-bottom: 15px;
219 box-sizing: border-box;
220 }
221 .ozon-parser-search:focus {
222 outline: none;
223 border-color: #005bff;
224 }
225 .ozon-parser-search::placeholder {
226 color: #999;
227 }
228 .ozon-parser-analytics {
229 margin-top: 30px;
230 padding: 20px;
231 background: #f8f9fa;
232 border-radius: 8px;
233 }
234 .ozon-parser-analytics-header {
235 font-size: 20px;
236 font-weight: 700;
237 margin-bottom: 20px;
238 color: #333;
239 }
240 .ozon-parser-analytics-section {
241 margin-bottom: 25px;
242 }
243 .ozon-parser-analytics-section-title {
244 font-size: 16px;
245 font-weight: 600;
246 margin-bottom: 12px;
247 color: #005bff;
248 }
249 .ozon-parser-analytics-grid {
250 display: grid;
251 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
252 gap: 15px;
253 margin-bottom: 20px;
254 }
255 .ozon-parser-analytics-card {
256 background: white;
257 padding: 15px;
258 border-radius: 8px;
259 border: 1px solid #e0e0e0;
260 }
261 .ozon-parser-analytics-card-title {
262 font-size: 13px;
263 color: #666;
264 margin-bottom: 8px;
265 }
266 .ozon-parser-analytics-card-value {
267 font-size: 24px;
268 font-weight: 700;
269 color: #333;
270 }
271 .ozon-parser-analytics-table {
272 width: 100%;
273 border-collapse: collapse;
274 background: white;
275 border-radius: 8px;
276 overflow: hidden;
277 }
278 .ozon-parser-analytics-table th,
279 .ozon-parser-analytics-table td {
280 padding: 12px;
281 text-align: left;
282 border-bottom: 1px solid #e0e0e0;
283 }
284 .ozon-parser-analytics-table th {
285 background: #f8f9fa;
286 font-weight: 600;
287 color: #333;
288 font-size: 13px;
289 }
290 .ozon-parser-analytics-table td {
291 font-size: 13px;
292 }
293 .ozon-parser-analytics-bar {
294 height: 20px;
295 background: linear-gradient(90deg, #005bff 0%, #0043c7 100%);
296 border-radius: 4px;
297 transition: width 0.3s ease;
298 }
299 `;
300 const styleElement = document.createElement('style');
301 styleElement.textContent = styles;
302 document.head.appendChild(styleElement);
303 console.log('Ozon Product Parser: Styles added');
304 }
305
306 // Создаем UI кнопок
307 function createUI() {
308 const container = document.createElement('div');
309 container.className = 'ozon-parser-container';
310
311 const parseBtn = document.createElement('button');
312 parseBtn.className = 'ozon-parser-btn';
313 parseBtn.textContent = 'Парсинг';
314 parseBtn.addEventListener('click', showParseModal);
315
316 const resultsBtn = document.createElement('button');
317 resultsBtn.className = 'ozon-parser-btn secondary';
318 resultsBtn.textContent = 'Посмотреть результаты';
319 resultsBtn.addEventListener('click', showResultsModal);
320
321 container.appendChild(parseBtn);
322 container.appendChild(resultsBtn);
323 document.body.appendChild(container);
324 console.log('Ozon Product Parser: UI created');
325 }
326
327 // Показываем модальное окно для ввода запросов
328 function showParseModal() {
329 const modal = document.createElement('div');
330 modal.className = 'ozon-parser-modal';
331
332 const content = document.createElement('div');
333 content.className = 'ozon-parser-modal-content';
334 content.innerHTML = `
335 <div class="ozon-parser-modal-header">Парсинг товаров Ozon</div>
336 <div class="ozon-parser-modal-body">
337 <div class="ozon-parser-info">
338 Введите поисковые запросы (каждый с новой строки). Парсер извлечет топ-16 товаров для каждого запроса.
339 </div>
340 <div style="margin-bottom: 15px;">
341 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Скидка Ozon (%):</label>
342 <div style="display: flex; gap: 10px; align-items: center;">
343 <input type="number" class="ozon-parser-search" id="ozon-discount-input" placeholder="50" value="50" min="0" max="100" step="1" style="margin-bottom: 0; flex: 1;">
344 <button class="ozon-parser-btn" id="calculate-discount-btn" style="background: linear-gradient(135deg, #ff6b00 0%, #e65100 100%); box-shadow: 0 4px 12px rgba(255, 107, 0, 0.3); margin-bottom: 0;">Рассчитать автоматически</button>
345 </div>
346 <div style="font-size: 12px; color: #666; margin-top: 5px;">
347 Укажите среднюю скидку Ozon для расчета базовой цены (цены поручения). Например, если товар продается по 500₽ со скидкой 50%, то базовая цена = 1000₽.
348 </div>
349 </div>
350 <div style="margin-bottom: 15px;">
351 <button class="ozon-parser-btn" id="load-list-btn" style="width: 100%; margin-bottom: 10px;">Загрузить сохраненный список</button>
352 </div>
353 <textarea class="ozon-parser-textarea" placeholder="Например: гинкго билоба аргинин витамин д"></textarea>
354 <div style="margin-top: 15px; display: flex; gap: 10px; align-items: center;">
355 <input type="text" class="ozon-parser-search" id="list-name-input" placeholder="Название списка (например: БАДы март 2024)" style="flex: 1; margin-bottom: 0;">
356 <button class="ozon-parser-btn secondary" id="save-list-btn">Сохранить список</button>
357 </div>
358 <div style="margin-top: 15px;">
359 <button class="ozon-parser-btn secondary" id="manage-costs-btn" style="width: 100%;">Управление расходами (себестоимость, комиссия, доставка)</button>
360 </div>
361 <div class="ozon-parser-progress" style="display: none;">
362 <div class="ozon-parser-progress-text">Обработка запросов...</div>
363 <div class="ozon-parser-progress-bar">
364 <div class="ozon-parser-progress-fill" style="width: 0%"></div>
365 </div>
366 </div>
367 </div>
368 <div class="ozon-parser-modal-footer">
369 <button class="ozon-parser-btn" id="cancel-parse-btn">Отмена</button>
370 <button class="ozon-parser-btn" id="start-parsing-btn">Начать парсинг</button>
371 </div>
372 `;
373
374 modal.appendChild(content);
375 document.body.appendChild(modal);
376
377 // Закрытие по клику на фон
378 modal.addEventListener('click', (e) => {
379 if (e.target === modal) {
380 modal.remove();
381 }
382 });
383
384 // Обработчик кнопки отмены
385 const cancelBtn = content.querySelector('#cancel-parse-btn');
386 cancelBtn.addEventListener('click', () => {
387 modal.remove();
388 });
389
390 // Обработчик кнопки расчета скидки
391 const calculateDiscountBtn = content.querySelector('#calculate-discount-btn');
392 calculateDiscountBtn.addEventListener('click', async () => {
393 calculateDiscountBtn.disabled = true;
394 calculateDiscountBtn.textContent = 'Расчет...';
395
396 try {
397 // Сохраняем флаг для автоматического расчета
398 await GM.setValue('ozon_parser_calculate_discount', 'true');
399
400 // Открываем страницу в новой вкладке
401 await GM.openInTab('https://seller.ozon.ru/app/prices/control', false);
402
403 // Ждем результата расчета
404 let attempts = 0;
405 const maxAttempts = 60; // 60 секунд максимум
406
407 const checkInterval = setInterval(async () => {
408 attempts++;
409 const calculatedDiscount = await GM.getValue('ozon_parser_calculated_discount', null);
410 const calculateFlag = await GM.getValue('ozon_parser_calculate_discount', 'false');
411
412 if (calculatedDiscount !== null && calculateFlag === 'false') {
413 // Расчет завершен
414 clearInterval(checkInterval);
415
416 // Вставляем значение в поле
417 const discountInput = content.querySelector('#ozon-discount-input');
418 discountInput.value = parseFloat(calculatedDiscount).toFixed(1);
419
420 // Очищаем временное значение
421 await GM.deleteValue('ozon_parser_calculated_discount');
422
423 calculateDiscountBtn.disabled = false;
424 calculateDiscountBtn.textContent = 'Рассчитать автоматически';
425
426 alert(`Скидка Ozon успешно рассчитана: ${parseFloat(calculatedDiscount).toFixed(1)}%`);
427 } else if (attempts >= maxAttempts) {
428 // Таймаут
429 clearInterval(checkInterval);
430 calculateDiscountBtn.disabled = false;
431 calculateDiscountBtn.textContent = 'Рассчитать автоматически';
432 alert('Не удалось рассчитать скидку. Попробуйте еще раз.');
433 }
434 }, 1000);
435 } catch (error) {
436 console.error('Ozon Product Parser: Error calculating discount:', error);
437 calculateDiscountBtn.disabled = false;
438 calculateDiscountBtn.textContent = 'Рассчитать автоматически';
439 alert('Ошибка при расчете скидки: ' + error.message);
440 }
441 });
442
443 // Обработчик кнопки сохранения списка
444 const saveListBtn = content.querySelector('#save-list-btn');
445 saveListBtn.addEventListener('click', async () => {
446 const textarea = content.querySelector('.ozon-parser-textarea');
447 const listNameInput = content.querySelector('#list-name-input');
448 const queries = textarea.value.split('\n').filter(q => q.trim());
449 const listName = listNameInput.value.trim();
450
451 if (queries.length === 0) {
452 alert('Пожалуйста, введите хотя бы один запрос');
453 return;
454 }
455
456 if (!listName) {
457 alert('Пожалуйста, введите название списка');
458 return;
459 }
460
461 // Сохраняем список
462 const savedListsJson = await GM.getValue('ozon_parser_saved_lists', '{}');
463 const savedLists = JSON.parse(savedListsJson);
464
465 savedLists[listName] = {
466 queries: queries,
467 createdAt: new Date().toISOString(),
468 updatedAt: new Date().toISOString()
469 };
470
471 await GM.setValue('ozon_parser_saved_lists', JSON.stringify(savedLists));
472
473 alert(`Список "${listName}" успешно сохранен!`);
474 console.log(`Ozon Product Parser: List "${listName}" saved with ${queries.length} queries`);
475 });
476
477 // Обработчик кнопки загрузки списка
478 const loadListBtn = content.querySelector('#load-list-btn');
479 loadListBtn.addEventListener('click', async () => {
480 await showLoadListModal(content);
481 });
482
483 // Обработчик кнопки управления расходами
484 const manageCostsBtn = content.querySelector('#manage-costs-btn');
485 manageCostsBtn.addEventListener('click', async () => {
486 await showManageCostsModal();
487 });
488
489 // Обработчик кнопки парсинга
490 const startBtn = content.querySelector('#start-parsing-btn');
491 startBtn.addEventListener('click', async () => {
492 const textarea = content.querySelector('.ozon-parser-textarea');
493 const listNameInput = content.querySelector('#list-name-input');
494 const ozonDiscountInput = content.querySelector('#ozon-discount-input');
495 const queries = textarea.value.split('\n').filter(q => q.trim());
496 const listName = listNameInput.value.trim() || 'Без названия';
497 const ozonDiscount = parseFloat(ozonDiscountInput.value) || 50;
498
499 if (queries.length === 0) {
500 alert('Пожалуйста, введите хотя бы один запрос');
501 return;
502 }
503
504 if (ozonDiscount < 0 || ozonDiscount > 100) {
505 alert('Скидка Ozon должна быть от 0 до 100%');
506 return;
507 }
508
509 // Сохраняем скидку Ozon
510 await GM.setValue('ozon_parser_discount', ozonDiscount);
511
512 startBtn.disabled = true;
513 startBtn.textContent = 'Парсинг...';
514 await startParsing(queries, listName, content);
515 });
516
517 console.log('Ozon Product Parser: Parse modal shown');
518 }
519
520 // Показываем модальное окно для загрузки сохраненного списка
521 async function showLoadListModal(parentContent) {
522 const savedListsJson = await GM.getValue('ozon_parser_saved_lists', '{}');
523 const savedLists = JSON.parse(savedListsJson);
524 const listNames = Object.keys(savedLists);
525
526 if (listNames.length === 0) {
527 alert('Нет сохраненных списков');
528 return;
529 }
530
531 const loadModal = document.createElement('div');
532 loadModal.className = 'ozon-parser-modal';
533 loadModal.style.zIndex = '10002';
534
535 const loadContent = document.createElement('div');
536 loadContent.className = 'ozon-parser-modal-content';
537 loadContent.style.maxWidth = '600px';
538
539 let listsHTML = '<div class="ozon-parser-modal-header">Выберите список</div><div class="ozon-parser-modal-body">';
540
541 listNames.forEach(listName => {
542 const list = savedLists[listName];
543 const date = new Date(list.createdAt).toLocaleDateString('ru-RU');
544 const queriesCount = list.queries.length;
545 listsHTML += `
546 <div style="padding: 15px; background: white; border: 2px solid #e0e0e0; border-radius: 8px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; transition: all 0.2s;"
547 class="saved-list-item" data-list-name="${listName}">
548 <div style="flex: 1; cursor: pointer;" class="saved-list-info">
549 <div style="font-weight: 600; font-size: 16px; margin-bottom: 5px;">${listName}</div>
550 <div style="font-size: 13px; color: #666;">Запросов: ${queriesCount} | Создан: ${date}</div>
551 </div>
552 <button class="ozon-parser-btn" style="background: linear-gradient(135deg, #dc3545 0%, #c82333 100%); padding: 8px 16px; font-size: 13px; margin-left: 10px;" data-delete-list="${listName}">Удалить</button>
553 </div>
554 `;
555 });
556
557 listsHTML += '</div><div class="ozon-parser-modal-footer"><button class="ozon-parser-btn" id="close-load-modal">Отмена</button></div>';
558
559 loadContent.innerHTML = listsHTML;
560 loadModal.appendChild(loadContent);
561 document.body.appendChild(loadModal);
562
563 // Обработчик выбора списка (только для info блока)
564 loadContent.querySelectorAll('.saved-list-info').forEach(info => {
565 info.addEventListener('click', () => {
566 const listItem = info.closest('.saved-list-item');
567 const listName = listItem.getAttribute('data-list-name');
568 const list = savedLists[listName];
569
570 // Заполняем textarea
571 const textarea = parentContent.querySelector('.ozon-parser-textarea');
572 const listNameInput = parentContent.querySelector('#list-name-input');
573 textarea.value = list.queries.join('\n');
574 listNameInput.value = listName;
575
576 loadModal.remove();
577 });
578
579 // Hover эффект
580 const listItem = info.closest('.saved-list-item');
581 info.addEventListener('mouseenter', () => {
582 listItem.style.borderColor = '#005bff';
583 listItem.style.background = '#f8f9fa';
584 });
585 info.addEventListener('mouseleave', () => {
586 listItem.style.borderColor = '#e0e0e0';
587 listItem.style.background = 'white';
588 });
589 });
590
591 // Обработчик удаления списка
592 loadContent.querySelectorAll('[data-delete-list]').forEach(deleteBtn => {
593 deleteBtn.addEventListener('click', async (e) => {
594 e.stopPropagation();
595 const listName = deleteBtn.getAttribute('data-delete-list');
596
597 if (!confirm(`Вы уверены, что хотите удалить список "${listName}"?`)) {
598 return;
599 }
600
601 // Удаляем список из savedLists
602 delete savedLists[listName];
603 await GM.setValue('ozon_parser_saved_lists', JSON.stringify(savedLists));
604
605 // Также удаляем результаты парсинга для этого списка
606 const listResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
607 const listResults = JSON.parse(listResultsJson);
608 delete listResults[listName];
609 await GM.setValue('ozon_parser_list_results', JSON.stringify(listResults));
610
611 console.log(`Ozon Product Parser: List "${listName}" deleted from load modal`);
612
613 // Удаляем элемент из DOM
614 const listItem = deleteBtn.closest('.saved-list-item');
615 listItem.remove();
616
617 // Если списков не осталось, закрываем модальное окно
618 const remainingLists = loadContent.querySelectorAll('.saved-list-item');
619 if (remainingLists.length === 0) {
620 alert('Все списки удалены');
621 loadModal.remove();
622 }
623 });
624 });
625
626 // Закрытие
627 loadContent.querySelector('#close-load-modal').addEventListener('click', () => {
628 loadModal.remove();
629 });
630
631 loadModal.addEventListener('click', (e) => {
632 if (e.target === loadModal) {
633 loadModal.remove();
634 }
635 });
636 }
637
638 // Показываем модальное окно управления расходами
639 async function showManageCostsModal() {
640 const costsJson = await GM.getValue('ozon_parser_costs', '{}');
641 const costs = JSON.parse(costsJson);
642
643 const modal = document.createElement('div');
644 modal.className = 'ozon-parser-modal';
645 modal.style.zIndex = '10002';
646
647 const content = document.createElement('div');
648 content.className = 'ozon-parser-modal-content';
649 content.style.maxWidth = '800px';
650
651 content.innerHTML = `
652 <div class="ozon-parser-modal-header">Управление расходами</div>
653 <div class="ozon-parser-modal-body">
654 <div class="ozon-parser-info">
655 Укажите расходы для ваших товаров (SKU). Эти данные будут использоваться для расчета прибыли и оптимальной цены.
656 </div>
657
658 <!-- Блок загрузки файла -->
659 <div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
660 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Загрузить данные из файла:</label>
661 <div style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px;">
662 <input type="file" id="cost-file-input" accept=".csv,.json,.txt" style="flex: 1; padding: 8px; border: 2px solid #e0e0e0; border-radius: 8px;">
663 <button class="ozon-parser-btn" id="upload-costs-btn">Загрузить</button>
664 </div>
665 <details style="margin-top: 10px;">
666 <summary style="cursor: pointer; font-size: 12px; color: #666;">Формат файлов</summary>
667 <div style="font-size: 12px; color: #666; margin-top: 8px; line-height: 1.6;">
668 <strong>CSV/TXT:</strong> SKU,Себестоимость,Комиссия,Доставка<br>
669 Пример: 320244429,158.4,50,90<br><br>
670 <strong>JSON:</strong> {"SKU": {"cost": 158.4, "commission": 0.5, "delivery": 90}}<br>
671 Примечание: Комиссия в CSV указывается в процентах (50), в JSON - как десятичная дробь (0.5)
672 </div>
673 </details>
674 </div>
675
676 <div style="margin-bottom: 15px;">
677 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Добавить новый товар:</label>
678 <div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr auto; gap: 10px; align-items: end;">
679 <div>
680 <label style="font-size: 12px; color: #666;">SKU</label>
681 <input type="text" class="ozon-parser-search" id="new-sku" placeholder="320244429" style="margin-bottom: 0;">
682 </div>
683 <div>
684 <label style="font-size: 12px; color: #666;">Себестоимость (₽)</label>
685 <input type="number" class="ozon-parser-search" id="new-cost" placeholder="158.4" step="0.01" style="margin-bottom: 0;">
686 </div>
687 <div>
688 <label style="font-size: 12px; color: #666;">Комиссия (%)</label>
689 <input type="number" class="ozon-parser-search" id="new-commission" placeholder="50" step="0.1" style="margin-bottom: 0;">
690 </div>
691 <div>
692 <label style="font-size: 12px; color: #666;">Доставка (₽)</label>
693 <input type="number" class="ozon-parser-search" id="new-delivery" placeholder="90" step="0.01" style="margin-bottom: 0;">
694 </div>
695 <button class="ozon-parser-btn" id="add-cost-btn" style="margin-bottom: 0;">Добавить</button>
696 </div>
697 </div>
698 <div id="costs-list" style="max-height: 400px; overflow-y: auto;">
699 ${Object.keys(costs).length === 0 ? '<p style="text-align: center; color: #999;">Нет добавленных товаров</p>' : ''}
700 </div>
701 </div>
702 <div class="ozon-parser-modal-footer">
703 <button class="ozon-parser-btn" id="close-costs-modal">Закрыть</button>
704 </div>
705 `;
706
707 modal.appendChild(content);
708 document.body.appendChild(modal);
709
710 // Функция для парсинга CSV
711 function parseCSV(text) {
712 const lines = text.trim().split('\n');
713 const result = {};
714
715 for (let i = 0; i < lines.length; i++) {
716 const line = lines[i].trim();
717 if (!line || line.startsWith('SKU')) continue; // Пропускаем заголовок
718
719 const parts = line.split(',').map(p => p.trim());
720 if (parts.length < 4) continue;
721
722 // Удаляем кавычки из SKU, если они есть
723 let sku = parts[0].replace(/^["']|["']$/g, '');
724 const cost = parseFloat(parts[1]);
725 const commission = parseFloat(parts[2]);
726 const delivery = parseFloat(parts[3]);
727
728 if (sku && !isNaN(cost) && !isNaN(commission) && !isNaN(delivery)) {
729 result[sku] = {
730 cost: cost,
731 commission: commission / 100, // Конвертируем проценты в десятичную дробь
732 delivery: delivery
733 };
734 }
735 }
736
737 return result;
738 }
739
740 // Обработчик загрузки файла
741 const uploadBtn = content.querySelector('#upload-costs-btn');
742 uploadBtn.addEventListener('click', async () => {
743 const fileInput = content.querySelector('#cost-file-input');
744 const file = fileInput.files[0];
745
746 if (!file) {
747 alert('Пожалуйста, выберите файл');
748 return;
749 }
750
751 try {
752 const text = await file.text();
753 let newCosts = {};
754
755 if (file.name.endsWith('.json')) {
756 // Парсим JSON
757 try {
758 newCosts = JSON.parse(text);
759 } catch (jsonError) {
760 throw new Error(`Ошибка парсинга JSON: ${jsonError.message}. Проверьте, что файл содержит корректный JSON формат.`);
761 }
762
763 // Валидация JSON формата
764 for (const sku in newCosts) {
765 const item = newCosts[sku];
766 if (typeof item.cost !== 'number' || typeof item.commission !== 'number' || typeof item.delivery !== 'number') {
767 throw new Error(`Неверный формат данных для SKU ${sku}. Ожидается: {"cost": число, "commission": число, "delivery": число}`);
768 }
769 }
770 } else {
771 // Парсим CSV/TXT
772 newCosts = parseCSV(text);
773 }
774
775 if (Object.keys(newCosts).length === 0) {
776 alert('Не удалось извлечь данные из файла. Проверьте формат.');
777 return;
778 }
779
780 // Объединяем с существующими данными
781 const mergedCosts = { ...costs, ...newCosts };
782 await GM.setValue('ozon_parser_costs', JSON.stringify(mergedCosts));
783
784 // Обновляем costs переменную
785 Object.assign(costs, newCosts);
786
787 alert(`Успешно загружено ${Object.keys(newCosts).length} товаров`);
788 displayCostsList();
789
790 // Очищаем input
791 fileInput.value = '';
792 } catch (error) {
793 console.error('Ozon Product Parser: Error uploading costs file:', error);
794 alert('Ошибка при загрузке файла: ' + error.message);
795 }
796 });
797
798 // Функция для отображения списка расходов
799 function displayCostsList() {
800 const costsList = content.querySelector('#costs-list');
801
802 if (Object.keys(costs).length === 0) {
803 costsList.innerHTML = '<p style="text-align: center; color: #999;">Нет добавленных товаров</p>';
804 return;
805 }
806
807 let html = '<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">';
808 html += '<div style="font-size: 14px; font-weight: 600;">Всего товаров: ' + Object.keys(costs).length + '</div>';
809 html += '<button class="ozon-parser-btn" id="delete-all-costs-btn" style="background: linear-gradient(135deg, #dc3545 0%, #c82333 100%); padding: 8px 16px; font-size: 13px;">Удалить все расходы</button>';
810 html += '</div>';
811 html += '<table class="ozon-parser-results-table"><thead><tr><th>SKU</th><th>Себестоимость</th><th>Комиссия</th><th>Доставка</th><th>Действия</th></tr></thead><tbody>';
812
813 Object.keys(costs).forEach(sku => {
814 const cost = costs[sku];
815 html += `
816 <tr>
817 <td><a href="https://www.ozon.ru/product/${sku}" target="_blank" class="ozon-parser-sku-link">${sku}</a></td>
818 <td>${cost.cost.toFixed(2)} ₽</td>
819 <td>${(cost.commission * 100).toFixed(2)}%</td>
820 <td>${cost.delivery.toFixed(2)} ₽</td>
821 <td>
822 <button class="ozon-parser-btn" data-edit-sku="${sku}" style="padding: 6px 12px; font-size: 12px; margin-right: 5px;">Изменить</button>
823 <button class="ozon-parser-btn" data-delete-sku="${sku}" style="background: linear-gradient(135deg, #dc3545 0%, #c82333 100%); padding: 6px 12px; font-size: 12px;">Удалить</button>
824 </td>
825 </tr>
826 `;
827 });
828
829 html += '</tbody></table>';
830 costsList.innerHTML = html;
831
832 // Обработчик для кнопки удаления всех расходов
833 const deleteAllBtn = costsList.querySelector('#delete-all-costs-btn');
834 if (deleteAllBtn) {
835 deleteAllBtn.addEventListener('click', async () => {
836 if (confirm('Вы уверены, что хотите удалить ВСЕ данные о расходах? Это действие нельзя отменить.')) {
837 // Очищаем все данные
838 Object.keys(costs).forEach(key => delete costs[key]);
839 await GM.setValue('ozon_parser_costs', JSON.stringify({}));
840 displayCostsList();
841 alert('Все данные о расходах удалены');
842 }
843 });
844 }
845
846 // Обработчики для кнопок удаления
847 costsList.querySelectorAll('[data-delete-sku]').forEach(btn => {
848 btn.addEventListener('click', async () => {
849 const sku = btn.getAttribute('data-delete-sku');
850 if (confirm(`Удалить данные о расходах для SKU ${sku}?`)) {
851 delete costs[sku];
852 await GM.setValue('ozon_parser_costs', JSON.stringify(costs));
853 displayCostsList();
854 }
855 });
856 });
857
858 // Обработчики для кнопок изменения
859 costsList.querySelectorAll('[data-edit-sku]').forEach(btn => {
860 btn.addEventListener('click', () => {
861 const sku = btn.getAttribute('data-edit-sku');
862 const cost = costs[sku];
863
864 content.querySelector('#new-sku').value = sku;
865 content.querySelector('#new-cost').value = cost.cost;
866 content.querySelector('#new-commission').value = cost.commission * 100;
867 content.querySelector('#new-delivery').value = cost.delivery;
868
869 content.querySelector('#new-sku').scrollIntoView({ behavior: 'smooth', block: 'center' });
870 });
871 });
872 }
873
874 displayCostsList();
875
876 // Обработчик добавления нового товара
877 const addBtn = content.querySelector('#add-cost-btn');
878 addBtn.addEventListener('click', async () => {
879 const sku = content.querySelector('#new-sku').value.trim();
880 const cost = parseFloat(content.querySelector('#new-cost').value);
881 const commission = parseFloat(content.querySelector('#new-commission').value);
882 const delivery = parseFloat(content.querySelector('#new-delivery').value);
883
884 if (!sku) {
885 alert('Пожалуйста, введите SKU');
886 return;
887 }
888
889 if (isNaN(cost) || cost < 0) {
890 alert('Пожалуйста, введите корректную себестоимость');
891 return;
892 }
893
894 if (isNaN(commission) || commission < 0 || commission > 100) {
895 alert('Пожалуйста, введите корректную комиссию (0-100%)');
896 return;
897 }
898
899 if (isNaN(delivery) || delivery < 0) {
900 alert('Пожалуйста, введите корректную стоимость доставки');
901 return;
902 }
903
904 costs[sku] = {
905 cost: cost,
906 commission: commission / 100, // Сохраняем как десятичную дробь
907 delivery: delivery
908 };
909
910 await GM.setValue('ozon_parser_costs', JSON.stringify(costs));
911
912 // Очищаем поля
913 content.querySelector('#new-sku').value = '';
914 content.querySelector('#new-cost').value = '';
915 content.querySelector('#new-commission').value = '';
916 content.querySelector('#new-delivery').value = '';
917
918 displayCostsList();
919 });
920
921 // Закрытие
922 content.querySelector('#close-costs-modal').addEventListener('click', () => {
923 modal.remove();
924 });
925
926 modal.addEventListener('click', (e) => {
927 if (e.target === modal) {
928 modal.remove();
929 }
930 });
931 }
932
933 // Рассчитываем скидку Ozon
934 async function calculateOzonDiscount() {
935 console.log('Ozon Product Parser: Starting Ozon discount calculation');
936
937 // Проверяем, находимся ли мы уже на странице управления ценами
938 if (window.location.href.includes('seller.ozon.ru/app/prices/control')) {
939 // Уже на нужной странице, извлекаем данные
940 await extractDiscountFromPage();
941 } else {
942 // Переходим на страницу управления ценами
943 await GM.setValue('ozon_parser_calculate_discount', 'true');
944 window.location.href = 'https://seller.ozon.ru/app/prices/control';
945 }
946 }
947
948 // Извлекаем скидку со страницы управления ценами
949 async function extractDiscountFromPage() {
950 console.log('Ozon Product Parser: Extracting discount from prices page');
951
952 // Ждем загрузки таблицы с ценами
953 await waitForPricesTable();
954
955 // Прокручиваем страницу вниз для загрузки цен (ленивая загрузка)
956 console.log('Ozon Product Parser: Scrolling to load prices');
957 for (let i = 0; i < 3; i++) {
958 window.scrollBy(0, 500);
959 await new Promise(resolve => setTimeout(resolve, 2000));
960 }
961
962 try {
963 // Ищем цены по правильным классам
964 // Цена продажи (с картой Ozon): index_priceByOzonCardCurrency_3DLKf
965 // Базовая цена (цена поручения): index_priceAmount_3dfpL
966
967 const salePriceElements = document.querySelectorAll('.index_priceByOzonCardCurrency_3DLKf');
968 const basePriceElements = document.querySelectorAll('.index_priceAmount_3dfpL');
969
970 console.log(`Ozon Product Parser: Found ${salePriceElements.length} sale prices and ${basePriceElements.length} base prices`);
971
972 if (salePriceElements.length === 0 || basePriceElements.length === 0) {
973 console.error('Ozon Product Parser: Could not find price elements on the page');
974 alert('Не удалось найти цены на странице. Убедитесь, что у вас есть товары с ценами.');
975 await GM.setValue('ozon_parser_calculate_discount', 'false');
976 return;
977 }
978
979 // Собираем пары цен (базовая и продажная)
980 let validDiscounts = [];
981
982 // Ищем строки таблицы с обеими ценами
983 const rows = document.querySelectorAll('tr');
984 for (const row of rows) {
985 const salePrice = row.querySelector('.index_priceByOzonCardCurrency_3DLKf');
986 const basePrice = row.querySelector('.index_priceAmount_3dfpL');
987
988 if (salePrice && basePrice) {
989 const salePriceText = salePrice.textContent.trim();
990 const basePriceText = basePrice.textContent.trim();
991
992 // Парсим цены (убираем пробелы и символ рубля)
993 const salePriceValue = parseFloat(salePriceText.replace(/\s/g, '').replace('₽', '').replace(',', '.'));
994 const basePriceValue = parseFloat(basePriceText.replace(/\s/g, '').replace('₽', '').replace(',', '.'));
995
996 if (!isNaN(salePriceValue) && !isNaN(basePriceValue) && basePriceValue > 0 && salePriceValue > 0) {
997 // Рассчитываем скидку: (базовая цена - цена продажи) / базовая цена * 100
998 const discountAmount = basePriceValue - salePriceValue;
999 const discountPercent = (discountAmount / basePriceValue) * 100;
1000
1001 if (discountPercent > 0 && discountPercent < 100) {
1002 validDiscounts.push({
1003 basePrice: basePriceValue,
1004 salePrice: salePriceValue,
1005 discount: discountPercent
1006 });
1007
1008 console.log(`Ozon Product Parser: Base price: ${basePriceValue}₽, Sale price: ${salePriceValue}₽, Discount: ${discountPercent.toFixed(1)}%`);
1009 }
1010 }
1011 }
1012 }
1013
1014 if (validDiscounts.length === 0) {
1015 console.error('Ozon Product Parser: No valid price pairs found');
1016 alert('Не удалось найти валидные пары цен на странице.');
1017 await GM.setValue('ozon_parser_calculate_discount', 'false');
1018 return;
1019 }
1020
1021 // Рассчитываем среднюю скидку
1022 const avgDiscount = validDiscounts.reduce((sum, item) => sum + item.discount, 0) / validDiscounts.length;
1023
1024 console.log(`Ozon Product Parser: Calculated average discount from ${validDiscounts.length} products: ${avgDiscount.toFixed(1)}%`);
1025
1026 // Сохраняем рассчитанную скидку для передачи в основное окно
1027 await GM.setValue('ozon_parser_calculated_discount', avgDiscount);
1028
1029 // Сбрасываем флаг расчета
1030 await GM.setValue('ozon_parser_calculate_discount', 'false');
1031
1032 // Закрываем текущую вкладку
1033 window.close();
1034 } catch (error) {
1035 console.error('Ozon Product Parser: Error extracting discount:', error);
1036 alert('Ошибка при извлечении данных о скидке: ' + error.message);
1037
1038 // Сбрасываем флаг расчета
1039 await GM.setValue('ozon_parser_calculate_discount', 'false');
1040 }
1041 }
1042
1043 // Ждем появления таблицы с ценами
1044 function waitForPricesTable() {
1045 return new Promise((resolve) => {
1046 const checkTable = () => {
1047 const rows = document.querySelectorAll('tr');
1048 if (rows.length > 0) {
1049 console.log('Ozon Product Parser: Prices table found');
1050 // Дополнительная задержка для полной загрузки данных
1051 setTimeout(resolve, 3000);
1052 } else {
1053 setTimeout(checkTable, 1000);
1054 }
1055 };
1056 checkTable();
1057 });
1058 }
1059
1060 // Прокручиваем страницу для подгрузки товаров
1061 async function scrollToLoadProducts(targetCount = 20) {
1062 console.log(`Ozon Product Parser: Scrolling to load ${targetCount} products`);
1063
1064 const table = document.querySelector('#mpstat-ozone-search-result table');
1065 if (!table) {
1066 console.error('Ozon Product Parser: Table not found for scrolling');
1067 return;
1068 }
1069
1070 let previousRowCount = 0;
1071 let attempts = 0;
1072 const maxAttempts = 10;
1073
1074 while (attempts < maxAttempts) {
1075 const rows = table.querySelectorAll('tbody tr');
1076 const currentRowCount = rows.length;
1077
1078 console.log(`Ozon Product Parser: Current row count: ${currentRowCount}, target: ${targetCount}`);
1079
1080 if (currentRowCount >= targetCount) {
1081 console.log(`Ozon Product Parser: Loaded ${currentRowCount} products`);
1082 break;
1083 }
1084
1085 // Если количество строк не изменилось, значит больше товаров нет
1086 if (currentRowCount === previousRowCount && attempts > 2) {
1087 console.log(`Ozon Product Parser: No more products to load (${currentRowCount} total)`);
1088 break;
1089 }
1090
1091 previousRowCount = currentRowCount;
1092
1093 // Прокручиваем к последней строке таблицы
1094 const lastRow = rows[rows.length - 1];
1095 if (lastRow) {
1096 lastRow.scrollIntoView({ behavior: 'smooth', block: 'end' });
1097 }
1098
1099 // Также прокручиваем окно вниз
1100 window.scrollBy(0, 500);
1101
1102 // Ждем подгрузки новых товаров
1103 await new Promise(resolve => setTimeout(resolve, 2000));
1104 attempts++;
1105 }
1106
1107 console.log(`Ozon Product Parser: Scrolling completed after ${attempts} attempts`);
1108 }
1109
1110 // Начинаем парсинг
1111 async function startParsing(queries, listName, modalContent) {
1112 const progressDiv = modalContent.querySelector('.ozon-parser-progress');
1113 progressDiv.style.display = 'block';
1114
1115 // Сохраняем список запросов и название списка
1116 await GM.setValue('ozon_parser_queries', JSON.stringify(queries));
1117 await GM.setValue('ozon_parser_current_list_name', listName);
1118 await GM.setValue('ozon_parser_current_index', 0);
1119 await GM.setValue('ozon_parser_active', 'true');
1120 console.log(`Ozon Product Parser: Starting parsing process for list "${listName}"`);
1121
1122 // Переходим к первому запросу
1123 const firstQuery = queries[0].trim();
1124 const searchUrl = `https://www.ozon.ru/search/?text=${encodeURIComponent(firstQuery)}&from_global=true`;
1125 window.location.href = searchUrl;
1126 }
1127
1128 // Продолжаем парсинг после загрузки страницы
1129 async function continueParsingIfActive() {
1130 const isActive = await GM.getValue('ozon_parser_active', 'false');
1131 if (isActive !== 'true') {
1132 return;
1133 }
1134
1135 console.log('Ozon Product Parser: Continuing parsing process');
1136
1137 // Ждем появления таблицы
1138 await waitForTable();
1139
1140 // Получаем текущее состояние
1141 const queriesJson = await GM.getValue('ozon_parser_queries', '[]');
1142 const queries = JSON.parse(queriesJson);
1143 const currentIndex = await GM.getValue('ozon_parser_current_index', 0);
1144 const currentListName = await GM.getValue('ozon_parser_current_list_name', 'Без названия');
1145
1146 // Получаем результаты для текущего списка
1147 const allListResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
1148 const allListResults = JSON.parse(allListResultsJson);
1149
1150 if (!allListResults[currentListName]) {
1151 allListResults[currentListName] = {
1152 queries: {},
1153 createdAt: new Date().toISOString(),
1154 updatedAt: new Date().toISOString()
1155 };
1156 }
1157
1158 if (currentIndex >= queries.length) {
1159 // Парсинг завершен
1160 await GM.setValue('ozon_parser_active', 'false');
1161
1162 // Обновляем дату последнего обновления списка
1163 allListResults[currentListName].updatedAt = new Date().toISOString();
1164 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
1165
1166 console.log('Ozon Product Parser: Parsing completed');
1167 return;
1168 }
1169
1170 const currentQuery = queries[currentIndex].trim();
1171 console.log(`Ozon Product Parser: Processing query ${currentIndex + 1}/${queries.length}: "${currentQuery}"`);
1172
1173 // Парсим данные текущей страницы
1174 const products = await parseProducts();
1175 allListResults[currentListName].queries[currentQuery] = products;
1176
1177 // Сохраняем результаты
1178 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
1179 console.log(`Ozon Product Parser: Parsed ${products.length} products for "${currentQuery}" in list "${currentListName}"`);
1180
1181 // Переходим к следующему запросу
1182 const nextIndex = currentIndex + 1;
1183 await GM.setValue('ozon_parser_current_index', nextIndex);
1184
1185 if (nextIndex < queries.length) {
1186 // Есть еще запросы - переходим к следующему
1187 const nextQuery = queries[nextIndex].trim();
1188 const searchUrl = `https://www.ozon.ru/search/?text=${encodeURIComponent(nextQuery)}&from_global=true`;
1189 setTimeout(() => {
1190 window.location.href = searchUrl;
1191 }, 2000); // Небольшая задержка между запросами
1192 } else {
1193 // Все запросы обработаны
1194 await GM.setValue('ozon_parser_active', 'false');
1195
1196 // Обновляем дату последнего обновления списка
1197 allListResults[currentListName].updatedAt = new Date().toISOString();
1198 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
1199
1200 console.log('Ozon Product Parser: All queries processed');
1201
1202 // Показываем результаты
1203 setTimeout(() => {
1204 showResultsModal();
1205 }, 1000);
1206 }
1207 }
1208
1209 // Продолжаем расчет скидки после загрузки страницы
1210 async function continueDiscountCalculationIfActive() {
1211 const shouldCalculate = await GM.getValue('ozon_parser_calculate_discount', 'false');
1212 if (shouldCalculate === 'true') {
1213 await GM.setValue('ozon_parser_calculate_discount', 'false');
1214 console.log('Ozon Product Parser: Continuing discount calculation');
1215 await extractDiscountFromPage();
1216 }
1217 }
1218
1219 // Ждем появления таблицы
1220 function waitForTable() {
1221 return new Promise((resolve) => {
1222 const checkTable = () => {
1223 const table = document.querySelector('#mpstat-ozone-search-result table tbody');
1224 if (table && table.querySelectorAll('tr').length > 0) {
1225 console.log('Ozon Product Parser: Table found');
1226 // Дополнительная задержка для полной загрузки данных
1227 setTimeout(resolve, 5000);
1228 } else {
1229 setTimeout(checkTable, 1000);
1230 }
1231 };
1232 checkTable();
1233 });
1234 }
1235
1236 // Прокручиваем страницу для подгрузки товаров
1237 async function scrollToLoadProducts(targetCount = 20) {
1238 console.log(`Ozon Product Parser: Scrolling to load ${targetCount} products`);
1239
1240 const table = document.querySelector('#mpstat-ozone-search-result table');
1241 if (!table) {
1242 console.error('Ozon Product Parser: Table not found for scrolling');
1243 return;
1244 }
1245
1246 let previousRowCount = 0;
1247 let attempts = 0;
1248 const maxAttempts = 10;
1249
1250 while (attempts < maxAttempts) {
1251 const rows = table.querySelectorAll('tbody tr');
1252 const currentRowCount = rows.length;
1253
1254 console.log(`Ozon Product Parser: Current row count: ${currentRowCount}, target: ${targetCount}`);
1255
1256 if (currentRowCount >= targetCount) {
1257 console.log(`Ozon Product Parser: Loaded ${currentRowCount} products`);
1258 break;
1259 }
1260
1261 // Если количество строк не изменилось, значит больше товаров нет
1262 if (currentRowCount === previousRowCount && attempts > 2) {
1263 console.log(`Ozon Product Parser: No more products to load (${currentRowCount} total)`);
1264 break;
1265 }
1266
1267 previousRowCount = currentRowCount;
1268
1269 // Прокручиваем к последней строке таблицы
1270 const lastRow = rows[rows.length - 1];
1271 if (lastRow) {
1272 lastRow.scrollIntoView({ behavior: 'smooth', block: 'end' });
1273 }
1274
1275 // Также прокручиваем окно вниз
1276 window.scrollBy(0, 500);
1277
1278 // Ждем подгрузки новых товаров
1279 await new Promise(resolve => setTimeout(resolve, 2000));
1280 attempts++;
1281 }
1282
1283 console.log(`Ozon Product Parser: Scrolling completed after ${attempts} attempts`);
1284 }
1285
1286 // Парсим товары из таблицы
1287 async function parseProducts() {
1288 const table = document.querySelector('#mpstat-ozone-search-result table');
1289 if (!table) {
1290 console.error('Ozon Product Parser: Table not found');
1291 return [];
1292 }
1293
1294 // Прокручиваем страницу для подгрузки товаров
1295 await scrollToLoadProducts(20);
1296
1297 const rows = table.querySelectorAll('tbody tr');
1298 const products = [];
1299 const maxProducts = Math.min(20, rows.length);
1300
1301 // Получаем названия товаров из карточек на странице
1302 const productLinks = document.querySelectorAll('a[href*="/product/"]');
1303 const productNames = new Map();
1304
1305 console.log(`Ozon Product Parser: Found ${productLinks.length} product links`);
1306
1307 productLinks.forEach(link => {
1308 const href = link.getAttribute('href');
1309 const skuMatch = href.match(/\/product\/[^/]+-(\d+)/);
1310 if (skuMatch) {
1311 const sku = skuMatch[1];
1312 // Ищем название в родительском элементе
1313 const parent = link.closest('.tile-root') || link.closest('[data-index]');
1314 if (parent) {
1315 const nameElement = parent.querySelector('.tsBody500Medium');
1316 if (nameElement && nameElement.textContent.trim().length > 10) {
1317 productNames.set(sku, nameElement.textContent.trim());
1318 }
1319 }
1320 }
1321 });
1322
1323 console.log(`Ozon Product Parser: Extracted ${productNames.size} product names`);
1324
1325 // Функция для извлечения количества единиц из названия
1326 function extractQuantity(name) {
1327 if (!name) return null;
1328
1329 // Ищем паттерны: "120 капсул", "60 таблеток", "180шт", "90 шт"
1330 const patterns = [
1331 /(\d+)\s*(?:капсул|капс|caps)/i,
1332 /(\d+)\s*(?:таблеток|табл|tablets|tabs)/i,
1333 /(\d+)\s*(?:штук|шт|pcs|pieces)/i,
1334 /(\d+)\s*(?:порций|servings)/i
1335 ];
1336
1337 for (const pattern of patterns) {
1338 const match = name.match(pattern);
1339 if (match) {
1340 const quantity = parseInt(match[1]);
1341 if (quantity > 0 && quantity <= 1000) { // Разумные пределы
1342 return quantity;
1343 }
1344 }
1345 }
1346
1347 return null;
1348 }
1349
1350 for (let i = 0; i < maxProducts; i++) {
1351 const row = rows[i];
1352 const cells = row.querySelectorAll('td');
1353 if (cells.length < 8) continue;
1354
1355 const position = cells[0]?.textContent.trim() || '';
1356 const sku = cells[2]?.textContent.trim() || '';
1357 const brand = cells[3]?.textContent.trim() || '';
1358 const priceText = cells[4]?.textContent.trim() || '';
1359 const revenueText = cells[6]?.textContent.trim() || '';
1360 const ordersText = cells[7]?.textContent.trim() || '';
1361
1362 // Получаем название товара из карточки
1363 const name = productNames.get(sku) || '';
1364
1365 // Извлекаем количество единиц
1366 const quantity = extractQuantity(name);
1367
1368 // Проверяем, есть ли товар от GLS или Skinphoria
1369 const isTargetBrand = brand.includes('GLS Pharmaceuticals') || brand.includes('Skinphoria');
1370
1371 // Парсим числовые значения
1372 const price = parseFloat(priceText.replace(/[^\d]/g, '')) || 0;
1373 const revenue = parseFloat(revenueText.replace(/[^\d]/g, '')) || 0;
1374 const orders = parseInt(ordersText.replace(/[^\d]/g, '')) || 0;
1375
1376 // Рассчитываем цену за единицу
1377 const pricePerUnit = quantity && price > 0 ? price / quantity : null;
1378
1379 products.push({
1380 position: parseInt(position) || (i + 1),
1381 sku,
1382 name,
1383 brand,
1384 price,
1385 revenue,
1386 orders,
1387 isTargetBrand,
1388 quantity,
1389 pricePerUnit
1390 });
1391 }
1392
1393 // Сортируем по убыванию выручки
1394 products.sort((a, b) => b.revenue - a.revenue);
1395 console.log(`Ozon Product Parser: Parsed ${products.length} products, ${products.filter(p => p.isTargetBrand).length} from target brands`);
1396 return products;
1397 }
1398
1399 // Анализируем данные для запроса
1400 async function analyzeProducts(products) {
1401 console.log('Ozon Product Parser: Starting analysis for', products.length, 'products');
1402
1403 if (!products || products.length === 0) {
1404 console.error('Ozon Product Parser: No products to analyze');
1405 return null;
1406 }
1407
1408 try {
1409 // Получаем данные о расходах и скидке Ozon
1410 const costsJson = await GM.getValue('ozon_parser_costs', '{}');
1411 const costs = JSON.parse(costsJson);
1412 const ozonDiscount = await GM.getValue('ozon_parser_discount', 50);
1413 console.log(`Ozon Product Parser: Loaded costs for ${Object.keys(costs).length} SKUs, Ozon discount: ${ozonDiscount}%`);
1414
1415 // Общая выручка и заказы
1416 const totalRevenue = products.reduce((sum, p) => sum + p.revenue, 0);
1417 const totalOrders = products.reduce((sum, p) => sum + p.orders, 0);
1418
1419 // Топ-5 товаров по выручке
1420 const topProducts = products.slice(0, 5).map(p => ({
1421 name: p.name,
1422 brand: p.brand,
1423 price: p.price,
1424 revenue: p.revenue,
1425 orders: p.orders,
1426 position: p.position,
1427 revenueShare: ((p.revenue / totalRevenue) * 100).toFixed(1)
1428 }));
1429
1430 // Ценовые сегменты
1431 const prices = products.map(p => p.price).filter(p => p > 0);
1432 const minPrice = Math.min(...prices);
1433 const maxPrice = Math.max(...prices);
1434 const priceRange = maxPrice - minPrice;
1435 const segmentSize = priceRange / 4;
1436
1437 const priceSegments = [
1438 { min: minPrice, max: minPrice + segmentSize, name: 'Низкий' },
1439 { min: minPrice + segmentSize, max: minPrice + segmentSize * 2, name: 'Средний-' },
1440 { min: minPrice + segmentSize * 2, max: minPrice + segmentSize * 3, name: 'Средний+' },
1441 { min: minPrice + segmentSize * 3, max: maxPrice, name: 'Высокий' }
1442 ];
1443
1444 const segments = priceSegments.map(segment => {
1445 const segmentProducts = products.filter(p =>
1446 p.price >= segment.min && p.price <= segment.max
1447 );
1448 const segmentRevenue = segmentProducts.reduce((sum, p) => sum + p.revenue, 0);
1449 const segmentOrders = segmentProducts.reduce((sum, p) => sum + p.orders, 0);
1450
1451 return {
1452 name: segment.name,
1453 priceRange: `${Math.round(segment.min)} - ${Math.round(segment.max)} ₽`,
1454 count: segmentProducts.length,
1455 revenue: segmentRevenue,
1456 orders: segmentOrders,
1457 revenueShare: ((segmentRevenue / totalRevenue) * 100).toFixed(1),
1458 avgPrice: segmentProducts.length > 0
1459 ? Math.round(segmentProducts.reduce((sum, p) => sum + p.price, 0) / segmentProducts.length)
1460 : 0
1461 };
1462 }).filter(s => s.count > 0);
1463
1464 // Расчет эластичности запроса
1465 const competitors = products.filter(p => !p.isTargetBrand);
1466 let elasticity = null;
1467 let elasticityInterpretation = '';
1468
1469 if (competitors.length >= 5) {
1470 const logPrices = competitors.map(p => Math.log(p.price + 1));
1471 const logOrders = competitors.map(p => Math.log(p.orders + 1));
1472
1473 function simpleRegression(x, y) {
1474 const n = x.length;
1475 const sumX = x.reduce((a, b) => a + b, 0);
1476 const sumY = y.reduce((a, b) => a + b, 0);
1477 const sumXY = x.reduce((a, b, i) => a + b * y[i], 0);
1478 const sumX2 = x.reduce((a, b) => a + b * b, 0);
1479 const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
1480 return slope;
1481 }
1482
1483 const rawElasticity = simpleRegression(logPrices, logOrders);
1484 const priorElasticity = -1.2;
1485 const priorWeight = 0.2;
1486 elasticity = rawElasticity * (1 - priorWeight) + priorElasticity * priorWeight;
1487
1488 console.log(`Ozon Product Parser: Raw elasticity: ${rawElasticity.toFixed(2)}, Bayesian adjusted: ${elasticity.toFixed(2)}`);
1489
1490 if (elasticity < -1.5) {
1491 elasticityInterpretation = 'Высокая эластичность - спрос очень чувствителен к цене. Снижение цены сильно увеличит продажи.';
1492 } else if (elasticity < -0.8) {
1493 elasticityInterpretation = 'Средняя эластичность - спрос умеренно реагирует на изменение цены.';
1494 } else if (elasticity < 0) {
1495 elasticityInterpretation = 'Низкая эластичность - спрос слабо зависит от цены. Можно повышать цену.';
1496 } else {
1497 elasticityInterpretation = 'Аномальная эластичность - возможно недостаточно данных для анализа.';
1498 }
1499 }
1500
1501 // Функция для расчета прогноза
1502 function calculateForecast(newPrice, currentPrice, currentOrders, currentRevenue, productCosts) {
1503 const priceChange = (newPrice - currentPrice) / currentPrice;
1504 const usedElasticity = elasticity !== null ? elasticity : -1.2;
1505 const ordersChange = usedElasticity * priceChange;
1506
1507 const forecastOrders = Math.round(currentOrders * (1 + ordersChange));
1508 const forecastRevenue = Math.round(forecastOrders * newPrice);
1509
1510 let forecastProfit = null;
1511 let currentProfit = null;
1512 let profitChange = null;
1513
1514 if (productCosts) {
1515 const basePrice = newPrice / (1 - ozonDiscount / 100);
1516 const sellerRevenue = basePrice * (1 - productCosts.commission) * forecastOrders;
1517 const expenses = (productCosts.cost + productCosts.delivery) * forecastOrders;
1518 forecastProfit = sellerRevenue - expenses;
1519
1520 const currentBasePrice = currentPrice / (1 - ozonDiscount / 100);
1521 const currentSellerRevenue = currentBasePrice * (1 - productCosts.commission) * currentOrders;
1522 const currentExpenses = (productCosts.cost + productCosts.delivery) * currentOrders;
1523 currentProfit = currentSellerRevenue - currentExpenses;
1524
1525 profitChange = currentProfit > 0 ? Math.round(((forecastProfit - currentProfit) / currentProfit) * 100) : 0;
1526 }
1527
1528 return {
1529 orders: forecastOrders,
1530 ordersChange: Math.round(ordersChange * 100),
1531 revenue: forecastRevenue,
1532 revenueChange: Math.round(((forecastRevenue - currentRevenue) / currentRevenue) * 100),
1533 profit: forecastProfit,
1534 profitChange: profitChange
1535 };
1536 }
1537
1538 // Функция для расчета рекомендованной цены для конкретного товара
1539 function calculatePriceForProduct(targetProduct, allProducts) {
1540 console.log(`Calculating price for SKU ${targetProduct.sku}`);
1541
1542 const productCosts = costs[targetProduct.sku];
1543
1544 // Находим конкурентов
1545 const positionRange = 5;
1546 const nearbyProducts = allProducts.filter(p =>
1547 !p.isTargetBrand &&
1548 Math.abs(p.position - targetProduct.position) <= positionRange &&
1549 p.price > 0 && p.orders > 0
1550 );
1551
1552 const referenceProducts = nearbyProducts.length >= 3
1553 ? nearbyProducts
1554 : allProducts.filter(p => !p.isTargetBrand && p.price > 0 && p.orders > 0).slice(0, 10);
1555
1556 if (referenceProducts.length === 0) {
1557 return null;
1558 }
1559
1560 // Рассчитываем базовую оптимальную цену на основе конкурентов
1561 let weightedSum = 0;
1562 let weightSum = 0;
1563 referenceProducts.forEach(p => {
1564 const weight = p.revenue;
1565 weightedSum += p.price * weight;
1566 weightSum += weight;
1567 });
1568 const weightedPrice = weightSum > 0 ? weightedSum / weightSum : 0;
1569
1570 const productsWithConversion = referenceProducts.map(p => ({
1571 ...p,
1572 conversion: p.orders / p.price
1573 })).sort((a, b) => b.conversion - a.conversion);
1574 const topConversionPrice = productsWithConversion.length > 0
1575 ? productsWithConversion.slice(0, 3).reduce((sum, p) => sum + p.price, 0) / 3
1576 : 0;
1577
1578 const top10Prices = referenceProducts.slice(0, 10).map(p => p.price).sort((a, b) => a - b);
1579 const medianPrice = top10Prices.length > 0
1580 ? top10Prices[Math.floor(top10Prices.length / 2)]
1581 : 0;
1582
1583 const marketOptimalPrice = (weightedPrice * 0.4 + topConversionPrice * 0.3 + medianPrice * 0.3);
1584
1585 // Рассчитываем RPI
1586 const competitorPrices = allProducts.filter(p => !p.isTargetBrand && p.price > 0);
1587 let avgCompetitorPrice = 0;
1588 if (competitorPrices.length > 0) {
1589 let weightedPriceSum = 0;
1590 let weightSum = 0;
1591 competitorPrices.forEach(comp => {
1592 const weight = comp.revenue;
1593 weightedPriceSum += comp.price * weight;
1594 weightSum += weight;
1595 });
1596 avgCompetitorPrice = weightSum > 0 ? weightedPriceSum / weightSum : targetProduct.price;
1597 }
1598
1599 const rpi = avgCompetitorPrice > 0 ? (targetProduct.price / avgCompetitorPrice) * 100 : 100;
1600
1601 // Используем эластичность для оптимизации прибыли
1602 const usedElasticity = elasticity !== null ? elasticity : -1.2;
1603
1604 // Функция для расчета ожидаемой прибыли при заданной цене
1605 function calculateExpectedProfit(price) {
1606 if (!productCosts) return null;
1607
1608 const priceChange = (price - targetProduct.price) / targetProduct.price;
1609 const ordersChange = usedElasticity * priceChange;
1610 const expectedOrders = Math.max(1, targetProduct.orders * (1 + ordersChange));
1611
1612 const basePrice = price / (1 - ozonDiscount / 100);
1613 const sellerRevenue = basePrice * (1 - productCosts.commission) * expectedOrders;
1614 const expenses = (productCosts.cost + productCosts.delivery) * expectedOrders;
1615
1616 return sellerRevenue - expenses;
1617 }
1618
1619 // Рассчитываем 4 варианта цен с учетом эластичности и максимизации прибыли
1620 // Захват рынка: ищем оптимальную цену ниже текущей для РОСТА прибыли
1621 // Цена ОБЯЗАТЕЛЬНО ниже текущей, но прибыль должна РАСТИ за счет увеличения объема
1622 let marketCapturePrice = targetProduct.price;
1623 if (productCosts) {
1624 const currentProfit = calculateExpectedProfit(targetProduct.price);
1625 let bestPrice = targetProduct.price;
1626 let maxProfit = currentProfit;
1627
1628 // Ищем оптимум между -30% и -1% от текущей цены
1629 for (let multiplier = 0.70; multiplier < 0.99; multiplier += 0.01) {
1630 const testPrice = targetProduct.price * multiplier;
1631 const testProfit = calculateExpectedProfit(testPrice);
1632
1633 // Выбираем цену с максимальной прибылью (больше текущей)
1634 if (testProfit > maxProfit) {
1635 maxProfit = testProfit;
1636 bestPrice = testPrice;
1637 }
1638 }
1639
1640 // Если нашли цену с большей прибылью - используем её
1641 if (maxProfit > currentProfit) {
1642 marketCapturePrice = Math.round(bestPrice);
1643 } else {
1644 // Если не нашли - используем безопасное снижение на 5%
1645 marketCapturePrice = Math.round(targetProduct.price * 0.95);
1646 }
1647 } else {
1648 // Если нет данных о расходах, снижаем на 5%
1649 marketCapturePrice = Math.round(targetProduct.price * 0.95);
1650 }
1651
1652 // Агрессивная: ищем цену для максимизации прибыли с небольшим снижением
1653 // Допускаем падение прибыли до -2%, но стремимся к росту
1654 let aggressivePrice = targetProduct.price;
1655 if (productCosts) {
1656 const currentProfit = calculateExpectedProfit(targetProduct.price);
1657 let maxProfit = currentProfit;
1658 const minAcceptableProfit = currentProfit * 0.98; // Допускаем падение до -2%
1659
1660 // Ищем оптимум между -10% и +5% от текущей цены
1661 for (let multiplier = 0.90; multiplier <= 1.05; multiplier += 0.01) {
1662 const testPrice = targetProduct.price * multiplier;
1663 const testProfit = calculateExpectedProfit(testPrice);
1664
1665 // Выбираем цену с максимальной прибылью, но не ниже минимально допустимой
1666 if (testProfit >= minAcceptableProfit && testProfit > maxProfit) {
1667 maxProfit = testProfit;
1668 aggressivePrice = testPrice;
1669 }
1670 }
1671 aggressivePrice = Math.round(aggressivePrice);
1672 } else {
1673 // Если нет данных о расходах, умеренное снижение
1674 aggressivePrice = Math.round(targetProduct.price * 0.95);
1675 }
1676
1677 // Оптимальная: цена для максимизации прибыли (выше текущей)
1678 // Ищем оптимум между текущей ценой и +30%
1679 let optimalPrice = targetProduct.price;
1680 if (productCosts) {
1681 let maxProfit = calculateExpectedProfit(targetProduct.price);
1682 for (let multiplier = 1.05; multiplier <= 1.30; multiplier += 0.01) {
1683 const testPrice = targetProduct.price * multiplier;
1684 const testProfit = calculateExpectedProfit(testPrice);
1685 if (testProfit > maxProfit) {
1686 maxProfit = testProfit;
1687 optimalPrice = testPrice;
1688 }
1689 }
1690 optimalPrice = Math.round(optimalPrice);
1691 } else {
1692 // Если нет данных о расходах, используем рыночную цену
1693 optimalPrice = Math.round(Math.max(targetProduct.price * 1.10, marketOptimalPrice));
1694 }
1695
1696 // Рассчитываем базовые цены (цены поручения)
1697 const currentBasePrice = targetProduct.price / (1 - ozonDiscount / 100);
1698 const marketCaptureBasePrice = marketCapturePrice / (1 - ozonDiscount / 100);
1699 const aggressiveBasePrice = aggressivePrice / (1 - ozonDiscount / 100);
1700 const optimalBasePrice = optimalPrice / (1 - ozonDiscount / 100);
1701
1702 return {
1703 marketCapture: marketCapturePrice,
1704 aggressive: aggressivePrice,
1705 optimal: optimalPrice,
1706 currentPrice: targetProduct.price,
1707 currentBasePrice: Math.round(currentBasePrice),
1708 marketCaptureBasePrice: Math.round(marketCaptureBasePrice),
1709 aggressiveBasePrice: Math.round(aggressiveBasePrice),
1710 optimalBasePrice: Math.round(optimalBasePrice),
1711 currentPosition: targetProduct.position,
1712 currentProfit: null,
1713 rpi: rpi.toFixed(1),
1714 priceChange: {
1715 marketCapture: Math.round(((marketCapturePrice - targetProduct.price) / targetProduct.price * 100)),
1716 aggressive: Math.round(((aggressivePrice - targetProduct.price) / targetProduct.price * 100)),
1717 optimal: Math.round(((optimalPrice - targetProduct.price) / targetProduct.price * 100))
1718 },
1719 forecast: {
1720 marketCapture: calculateForecast(marketCapturePrice, targetProduct.price, targetProduct.orders, targetProduct.revenue, productCosts),
1721 aggressive: calculateForecast(aggressivePrice, targetProduct.price, targetProduct.orders, targetProduct.revenue, productCosts),
1722 optimal: calculateForecast(optimalPrice, targetProduct.price, targetProduct.orders, targetProduct.revenue, productCosts)
1723 }
1724 };
1725 }
1726
1727 // Общие рекомендации
1728 let weightedSum = 0;
1729 let weightSum = 0;
1730 products.forEach(p => {
1731 const positionBonus = p.position <= 5 ? 2 : 1;
1732 const weight = p.revenue * positionBonus;
1733 weightedSum += p.price * weight;
1734 weightSum += weight;
1735 });
1736 const weightedPrice = weightSum > 0 ? weightedSum / weightSum : 0;
1737
1738 const productsWithConversion = products.map(p => ({
1739 ...p,
1740 conversion: p.orders / p.price
1741 })).sort((a, b) => b.conversion - a.conversion);
1742 const topConversionPrice = productsWithConversion.length > 0
1743 ? productsWithConversion.slice(0, 3).reduce((sum, p) => sum + p.price, 0) / 3
1744 : 0;
1745
1746 const top10Prices = products.slice(0, 10).map(p => p.price).sort((a, b) => a - b);
1747 const medianPrice = top10Prices.length > 0
1748 ? top10Prices[Math.floor(top10Prices.length / 2)]
1749 : 0;
1750
1751 const baseOptimalPrice = (weightedPrice * 0.4 + topConversionPrice * 0.3 + medianPrice * 0.3);
1752
1753 const recommendedPrices = {
1754 marketCapture: {
1755 price: Math.round(baseOptimalPrice * 0.70),
1756 strategy: 'Захват рынка',
1757 description: 'Оптимальная цена ниже текущей для максимизации прибыли'
1758 },
1759 aggressive: {
1760 price: Math.round(baseOptimalPrice * 0.85),
1761 strategy: 'Агрессивная',
1762 description: 'Низкая цена для максимальных продаж и быстрого роста позиций'
1763 },
1764 optimal: {
1765 price: Math.round(baseOptimalPrice),
1766 strategy: 'Оптимальная',
1767 description: 'Баланс между прибылью и объемом продаж'
1768 }
1769 };
1770
1771 // Раздельные рекомендации для целевых брендов
1772 const glsProducts = products.filter(p => p.brand.includes('GLS Pharmaceuticals'));
1773 const skinphoriaProducts = products.filter(p => p.brand.includes('Skinphoria'));
1774
1775 const brandRecommendations = {};
1776
1777 // Рекомендации для GLS Pharmaceuticals
1778 if (glsProducts.length > 0) {
1779 const glsAvgPosition = glsProducts.reduce((sum, p) => sum + p.position, 0) / glsProducts.length;
1780 const glsAvgPrice = glsProducts.reduce((sum, p) => sum + p.price, 0) / glsProducts.length;
1781
1782 brandRecommendations.gls = {
1783 brand: 'GLS Pharmaceuticals',
1784 currentProducts: glsProducts.map(p => {
1785 const priceRec = calculatePriceForProduct(p, products);
1786 return {
1787 sku: p.sku,
1788 name: p.name,
1789 position: p.position,
1790 price: p.price,
1791 revenue: p.revenue,
1792 orders: p.orders,
1793 recommendations: priceRec
1794 };
1795 }),
1796 avgPosition: Math.round(glsAvgPosition),
1797 avgPrice: Math.round(glsAvgPrice)
1798 };
1799 }
1800
1801 // Рекомендации для Skinphoria
1802 if (skinphoriaProducts.length > 0) {
1803 const skinphoriaAvgPosition = skinphoriaProducts.reduce((sum, p) => sum + p.position, 0) / skinphoriaProducts.length;
1804 const skinphoriaAvgPrice = skinphoriaProducts.reduce((sum, p) => sum + p.price, 0) / skinphoriaProducts.length;
1805
1806 brandRecommendations.skinphoria = {
1807 brand: 'Skinphoria',
1808 currentProducts: skinphoriaProducts.map(p => {
1809 const priceRec = calculatePriceForProduct(p, products);
1810 return {
1811 sku: p.sku,
1812 name: p.name,
1813 position: p.position,
1814 price: p.price,
1815 revenue: p.revenue,
1816 orders: p.orders,
1817 recommendations: priceRec
1818 };
1819 }),
1820 avgPosition: Math.round(skinphoriaAvgPosition),
1821 avgPrice: Math.round(skinphoriaAvgPrice)
1822 };
1823 }
1824
1825 console.log('Ozon Product Parser: Analysis completed successfully');
1826
1827 return {
1828 totalRevenue,
1829 totalOrders,
1830 avgPrice: Math.round(totalRevenue / totalOrders),
1831 topProducts,
1832 priceSegments: segments,
1833 elasticity: elasticity !== null ? {
1834 value: elasticity.toFixed(2),
1835 interpretation: elasticityInterpretation
1836 } : null,
1837 recommendedPrices,
1838 brandRecommendations
1839 };
1840 } catch (error) {
1841 console.error('Ozon Product Parser: Error in analyzeProducts:', error);
1842 return null;
1843 }
1844 }
1845
1846 // Показываем результаты
1847 async function showResultsModal() {
1848 console.log('Ozon Product Parser: Opening results modal');
1849
1850 try {
1851 // Получаем результаты по спискам
1852 const allListResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
1853 const allListResults = JSON.parse(allListResultsJson);
1854 const listNames = Object.keys(allListResults);
1855
1856 if (listNames.length === 0) {
1857 alert('Нет сохраненных результатов. Сначала выполните парсинг.');
1858 return;
1859 }
1860
1861 const modal = document.createElement('div');
1862 modal.className = 'ozon-parser-modal';
1863
1864 const content = document.createElement('div');
1865 content.className = 'ozon-parser-modal-content';
1866 content.style.maxWidth = '95vw';
1867 content.style.width = '95vw';
1868
1869 content.innerHTML = `
1870 <div class="ozon-parser-modal-header">Результаты парсинга</div>
1871 <div class="ozon-parser-modal-body">
1872 <div style="margin-bottom: 20px;">
1873 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Выберите список:</label>
1874 <div style="display: flex; gap: 10px; align-items: center;">
1875 <select class="ozon-parser-search" id="list-selector" style="flex: 1; margin-bottom: 0;">
1876 ${listNames.map(listName => {
1877 const list = allListResults[listName];
1878 const date = new Date(list.updatedAt).toLocaleDateString('ru-RU');
1879 const queriesCount = list.queries.length;
1880 return `<option value="${listName}">${listName} (${queriesCount} запросов, обновлен: ${date})</option>`;
1881 }).join('')}
1882 </select>
1883 <button class="ozon-parser-btn" id="delete-list-btn" style="background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);">Удалить список</button>
1884 </div>
1885 </div>
1886 <input type="text" class="ozon-parser-search" id="query-search" placeholder="Поиск по запросам">
1887 <input type="text" class="ozon-parser-search" id="sku-search" placeholder="Поиск по SKU">
1888 <div class="ozon-parser-tabs" id="query-tabs"></div>
1889 <div id="results-container"></div>
1890 </div>
1891 <div class="ozon-parser-modal-footer">
1892 <button class="ozon-parser-btn" id="close-results-btn">Закрыть</button>
1893 </div>
1894 `;
1895
1896 modal.appendChild(content);
1897 document.body.appendChild(modal);
1898
1899 // Текущий выбранный список
1900 let currentListName = listNames[0];
1901 let currentResults = allListResults[currentListName].queries;
1902
1903 // Функция для обновления отображения
1904 async function updateDisplay() {
1905 const queries = Object.keys(currentResults);
1906 queries.sort((a, b) => a.localeCompare(b, 'ru'));
1907
1908 // Создаем вкладки
1909 createTabs(queries, currentResults, content);
1910
1911 // Показываем результаты первого запроса
1912 if (queries.length > 0) {
1913 await displayResults(queries[0], currentResults[queries[0]], content);
1914 }
1915 }
1916
1917 // Обработчик выбора списка
1918 const listSelector = content.querySelector('#list-selector');
1919 listSelector.addEventListener('change', () => {
1920 currentListName = listSelector.value;
1921 currentResults = allListResults[currentListName].queries;
1922 updateDisplay();
1923 });
1924
1925 // Обработчик удаления списка
1926 const deleteListBtn = content.querySelector('#delete-list-btn');
1927 deleteListBtn.addEventListener('click', async () => {
1928 if (!confirm(`Вы уверены, что хотите удалить список "${currentListName}"?`)) {
1929 return;
1930 }
1931
1932 // Удаляем список
1933 delete allListResults[currentListName];
1934 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
1935
1936 // Также удаляем из сохраненных списков
1937 const savedListsJson = await GM.getValue('ozon_parser_saved_lists', '{}');
1938 const savedLists = JSON.parse(savedListsJson);
1939 delete savedLists[currentListName];
1940 await GM.setValue('ozon_parser_saved_lists', JSON.stringify(savedLists));
1941
1942 console.log(`Ozon Product Parser: List "${currentListName}" deleted`);
1943
1944 // Обновляем UI
1945 const remainingLists = Object.keys(allListResults);
1946 if (remainingLists.length === 0) {
1947 alert('Все списки удалены');
1948 modal.remove();
1949 return;
1950 }
1951
1952 // Переключаемся на первый оставшийся список
1953 currentListName = remainingLists[0];
1954 currentResults = allListResults[currentListName].queries;
1955
1956 // Обновляем селектор
1957 listSelector.innerHTML = remainingLists.map(listName => {
1958 const list = allListResults[listName];
1959 const date = new Date(list.updatedAt).toLocaleDateString('ru-RU');
1960 const queriesCount = list.queries.length;
1961 return `<option value="${listName}">${listName} (${queriesCount} запросов, обновлен: ${date})</option>`;
1962 }).join('');
1963
1964 updateDisplay();
1965 });
1966
1967 // Обработчик закрытия модального окна
1968 const closeBtn = content.querySelector('#close-results-btn');
1969 closeBtn.addEventListener('click', () => {
1970 modal.remove();
1971 });
1972
1973 // Обработчик поиска по запросам
1974 const querySearchInput = content.querySelector('#query-search');
1975 querySearchInput.addEventListener('input', debounce(() => {
1976 const searchValue = querySearchInput.value.trim().toLowerCase();
1977 const queries = Object.keys(currentResults);
1978 queries.sort((a, b) => a.localeCompare(b, 'ru'));
1979 filterQueriesByQuery(searchValue, queries, currentResults, content);
1980 }, 300));
1981
1982 // Обработчик поиска по SKU
1983 const skuSearchInput = content.querySelector('#sku-search');
1984 skuSearchInput.addEventListener('input', debounce(() => {
1985 const searchValue = skuSearchInput.value.trim();
1986 const queries = Object.keys(currentResults);
1987 queries.sort((a, b) => a.localeCompare(b, 'ru'));
1988 filterQueriesBySKU(searchValue, queries, currentResults, content);
1989 }, 300));
1990
1991 // Создаем вкладки для запросов
1992 createTabs(Object.keys(currentResults), currentResults, content);
1993
1994 // Показываем результаты первого запроса
1995 await displayResults(Object.keys(currentResults)[0], currentResults[Object.keys(currentResults)[0]], content);
1996
1997 // Закрытие по клику на фон
1998 modal.addEventListener('click', (e) => {
1999 if (e.target === modal) {
2000 modal.remove();
2001 }
2002 });
2003
2004 console.log('Ozon Product Parser: Results modal shown');
2005 } catch (error) {
2006 console.error('Ozon Product Parser: Error in showResultsModal:', error);
2007 alert('Ошибка при отображении результатов: ' + error.message);
2008 }
2009 }
2010
2011 // Фильтруем запросы по названию запроса
2012 async function filterQueriesByQuery(queryText, allQueries, results, modalContent) {
2013 if (!queryText) {
2014 createTabs(allQueries, results, modalContent);
2015 await displayResults(allQueries[0], results[allQueries[0]], modalContent);
2016 return;
2017 }
2018
2019 const filteredQueries = allQueries.filter(q =>
2020 q.toLowerCase().includes(queryText)
2021 );
2022
2023 if (filteredQueries.length === 0) {
2024 const container = modalContent.querySelector('#results-container');
2025 container.innerHTML = '<p>Запросы не найдены</p>';
2026 const tabsContainer = modalContent.querySelector('#query-tabs');
2027 tabsContainer.innerHTML = '';
2028 return;
2029 }
2030
2031 createTabs(filteredQueries, results, modalContent);
2032 await displayResults(filteredQueries[0], results[filteredQueries[0]], modalContent);
2033 }
2034
2035 // Фильтруем запросы по SKU
2036 async function filterQueriesBySKU(sku, allQueries, results, modalContent) {
2037 if (!sku) {
2038 createTabs(allQueries, results, modalContent);
2039 await displayResults(allQueries[0], results[allQueries[0]], modalContent);
2040 return;
2041 }
2042
2043 const filteredQueries = allQueries.filter(query => {
2044 const products = results[query];
2045 return products.some(product => product.sku.includes(sku));
2046 });
2047
2048 if (filteredQueries.length === 0) {
2049 const container = modalContent.querySelector('#results-container');
2050 container.innerHTML = '<p>Товары с таким SKU не найдены ни в одном запросе</p>';
2051 const tabsContainer = modalContent.querySelector('#query-tabs');
2052 tabsContainer.innerHTML = '';
2053 return;
2054 }
2055
2056 createTabs(filteredQueries, results, modalContent);
2057 await displayResults(filteredQueries[0], results[filteredQueries[0]], modalContent);
2058 }
2059
2060 // Создаем вкладки для запросов
2061 function createTabs(queries, results, modalContent) {
2062 const tabsContainer = modalContent.querySelector('#query-tabs');
2063 tabsContainer.innerHTML = '';
2064
2065 queries.forEach((query, index) => {
2066 const tab = document.createElement('button');
2067 tab.className = 'ozon-parser-tab' + (index === 0 ? ' active' : '');
2068 tab.textContent = query;
2069 tab.addEventListener('click', async () => {
2070 tabsContainer.querySelectorAll('.ozon-parser-tab').forEach(t => t.classList.remove('active'));
2071 tab.classList.add('active');
2072 await displayResults(query, results[query], modalContent);
2073 });
2074 tabsContainer.appendChild(tab);
2075 });
2076 }
2077
2078 // Отображаем результаты для конкретного запроса
2079 async function displayResults(query, productsData, modalContent) {
2080 console.log('Ozon Product Parser: Displaying results for query:', query);
2081
2082 const container = modalContent.querySelector('#results-container');
2083
2084 try {
2085 let products;
2086
2087 if (Array.isArray(productsData)) {
2088 products = productsData;
2089 } else {
2090 container.innerHTML = '<p>Запросы не найдены</p>';
2091 return;
2092 }
2093
2094 if (!products || products.length === 0) {
2095 container.innerHTML = '<p>Нет данных для этого запроса</p>';
2096 return;
2097 }
2098
2099 // Получаем аналитику
2100 const analytics = await analyzeProducts(products);
2101
2102 if (!analytics) {
2103 container.innerHTML = '<p>Ошибка при анализе данных</p>';
2104 return;
2105 }
2106
2107 // Получаем скидку Ozon для расчета базовой цены
2108 const ozonDiscount = await GM.getValue('ozon_parser_discount', 50);
2109
2110 let tableHTML = `
2111 <table class="ozon-parser-results-table">
2112 <thead>
2113 <tr>
2114 <th>Позиция</th>
2115 <th>SKU</th>
2116 <th>Название</th>
2117 <th>Бренд</th>
2118 <th>Цена</th>
2119 <th>Выручка</th>
2120 <th>Заказы</th>
2121 </tr>
2122 </thead>
2123 <tbody>
2124 `;
2125
2126 products.forEach(product => {
2127 const rowClass = product.isTargetBrand ? ' class="ozon-parser-highlight"' : '';
2128 const skuLink = `https://www.ozon.ru/product/${product.sku}`;
2129 const basePrice = product.price / (1 - ozonDiscount / 100);
2130
2131 tableHTML += `
2132 <tr${rowClass}>
2133 <td>${product.position}</td>
2134 <td><a href="${skuLink}" target="_blank" class="ozon-parser-sku-link">${product.sku}</a></td>
2135 <td>${product.name || '—'}</td>
2136 <td>${product.brand}</td>
2137 <td>
2138 <div style="font-weight: 600;">${product.price.toLocaleString('ru-RU')} ₽</div>
2139 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${Math.round(basePrice).toLocaleString('ru-RU')} ₽</div>
2140 </td>
2141 <td>${product.revenue.toLocaleString('ru-RU')} ₽</td>
2142 <td>${product.orders.toLocaleString('ru-RU')}</td>
2143 </tr>
2144 `;
2145 });
2146
2147 tableHTML += `
2148 </tbody>
2149 </table>
2150 `;
2151
2152 // Добавляем аналитику
2153 tableHTML += `
2154 <div class="ozon-parser-analytics">
2155 <div class="ozon-parser-analytics-header">📊 Аналитика запроса</div>
2156
2157 ${analytics.elasticity ? `
2158 <div class="ozon-parser-analytics-section">
2159 <div class="ozon-parser-analytics-card">
2160 <div class="ozon-parser-analytics-card-title">Эластичность запроса</div>
2161 <div class="ozon-parser-analytics-card-value">${analytics.elasticity.value}</div>
2162 <div style="font-size: 12px; color: #666; margin-top: 8px;">
2163 ${analytics.elasticity.interpretation}
2164 </div>
2165 </div>
2166 </div>
2167 ` : ''}
2168
2169 <div class="ozon-parser-analytics-section">
2170 <div class="ozon-parser-analytics-section-title">Рекомендованные цены</div>
2171 <div class="ozon-parser-analytics-grid">
2172 <div class="ozon-parser-analytics-card" style="border: 2px solid #6c757d;">
2173 <div class="ozon-parser-analytics-card-title">⚡ Захват рынка</div>
2174 <div class="ozon-parser-analytics-card-value" style="color: #6c757d;">${analytics.recommendedPrices.marketCapture.price.toLocaleString('ru-RU')} ₽</div>
2175 <div style="font-size: 12px; color: #666; margin-top: 8px;">Оптимальная цена ниже текущей для максимизации прибыли</div>
2176 </div>
2177 <div class="ozon-parser-analytics-card" style="border: 2px solid #dc3545;">
2178 <div class="ozon-parser-analytics-card-title">✅ Оптимальная</div>
2179 <div class="ozon-parser-analytics-card-value" style="color: #dc3545;">${analytics.recommendedPrices.aggressive.price.toLocaleString('ru-RU')} ₽</div>
2180 <div style="font-size: 12px; color: #666; margin-top: 8px;">Низкая цена для максимальных продаж и быстрого роста позиций</div>
2181 </div>
2182 <div class="ozon-parser-analytics-card" style="border: 2px solid #28a745;">
2183 <div class="ozon-parser-analytics-card-title">🔥 Агрессивная</div>
2184 <div class="ozon-parser-analytics-card-value" style="color: #28a745;">${analytics.recommendedPrices.optimal.price.toLocaleString('ru-RU')} ₽</div>
2185 <div style="font-size: 12px; color: #666; margin-top: 8px;">Баланс между прибылью и объемом продаж</div>
2186 </div>
2187 </div>
2188 <div class="ozon-parser-info">
2189 💡 Расчет учитывает: средневзвешенную цену по выручке (вес 40%), оптимальную конверсию цена/заказы (вес 30%), медианную цену топ-10 (вес 30%). Товары из топ-5 позиций имеют удвоенный вес.
2190 </div>
2191 </div>
2192
2193 ${analytics.brandRecommendations && Object.keys(analytics.brandRecommendations).length > 0 ? `
2194 <div class="ozon-parser-analytics-section">
2195 <div class="ozon-parser-analytics-section-title">🎯 Рекомендации для ваших брендов</div>
2196 ${analytics.brandRecommendations.gls ? `
2197 <div style="background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 2px solid #ffc107;">
2198 <div style="font-size: 18px; font-weight: 600; margin-bottom: 15px; color: #333;">
2199 ${analytics.brandRecommendations.gls.brand}
2200 </div>
2201 <div style="margin-bottom: 15px;">
2202 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Текущие товары в выдаче: ${analytics.brandRecommendations.gls.currentProducts.length}</div>
2203 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Средняя позиция: ${analytics.brandRecommendations.gls.avgPosition}</div>
2204 <div style="font-size: 13px; color: #666;">Средняя цена: ${analytics.brandRecommendations.gls.avgPrice.toLocaleString('ru-RU')} ₽</div>
2205 </div>
2206 <details style="margin-top: 10px;" open>
2207 <summary style="cursor: pointer; font-size: 13px; color: #005bff; font-weight: 600;">Показать товары</summary>
2208 <table class="ozon-parser-analytics-table" style="margin-top: 10px;">
2209 <thead>
2210 <tr>
2211 <th>SKU</th>
2212 <th>Название</th>
2213 <th>Позиция</th>
2214 <th>Текущая цена</th>
2215 <th>⚡ Захват рынка</th>
2216 <th>✅ Оптимальная</th>
2217 <th>🔥 Агрессивная</th>
2218 </tr>
2219 </thead>
2220 <tbody>
2221 ${analytics.brandRecommendations.gls.currentProducts.map(p => {
2222 const rec = p.recommendations;
2223 if (!rec) return `
2224 <tr>
2225 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
2226 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
2227 <td>${p.position}</td>
2228 <td>${p.price.toLocaleString('ru-RU')} ₽</td>
2229 <td>—</td>
2230 <td>—</td>
2231 <td>—</td>
2232 </tr>
2233 `;
2234 return `
2235 <tr>
2236 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
2237 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
2238 <td>${p.position}</td>
2239 <td>
2240 <div style="font-weight: 600;">${p.price.toLocaleString('ru-RU')} ₽</div>
2241 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.currentBasePrice.toLocaleString('ru-RU')} ₽</div>
2242 <div style="font-size: 11px; color: #666; margin-top: 4px;">Выручка: ${p.revenue.toLocaleString('ru-RU')} ₽</div>
2243 <div style="font-size: 11px; color: #666;">Заказы: ${p.orders.toLocaleString('ru-RU')}</div>
2244 ${rec.skuDiscount ? `
2245 <div style="font-size: 11px; color: #ff6b00; font-weight: 600; margin-top: 4px;">
2246 Скидка Ozon: ${rec.skuDiscount}%
2247 </div>
2248 ` : ''}
2249 ${rec.currentProfit !== null ? `
2250 <div style="font-size: 11px; color: #005bff; font-weight: 600; margin-top: 4px;">
2251 Прибыль: ${rec.currentProfit.toFixed(0)} ₽
2252 </div>
2253 ` : ''}
2254 </td>
2255 <td style="background: #e9ecef;">
2256 <div style="font-weight: 600; color: #6c757d;">${rec.marketCapture.toLocaleString('ru-RU')} ₽</div>
2257 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.marketCaptureBasePrice.toLocaleString('ru-RU')} ₽</div>
2258 <div style="font-size: 11px; color: ${rec.priceChange.marketCapture < 0 ? '#dc3545' : '#28a745'};">
2259 ${rec.priceChange.marketCapture > 0 ? '+' : ''}${rec.priceChange.marketCapture}%
2260 </div>
2261 ${rec.forecast && rec.forecast.marketCapture ? `
2262 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
2263 Выручка: ${rec.forecast.marketCapture.revenueChange > 0 ? '+' : ''}${rec.forecast.marketCapture.revenueChange}%
2264 </div>
2265 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
2266 Заказы: ${rec.forecast.marketCapture.ordersChange > 0 ? '+' : ''}${rec.forecast.marketCapture.ordersChange}%
2267 </div>
2268 ${rec.forecast.marketCapture.profit !== null ? `
2269 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
2270 Прибыль: ${rec.forecast.marketCapture.profitChange > 0 ? '+' : ''}${rec.forecast.marketCapture.profitChange}%
2271 </div>
2272 <div style="font-size: 10px; color: #666;">
2273 ${rec.forecast.marketCapture.profit.toFixed(0)} ₽
2274 </div>
2275 ` : ''}
2276 ` : ''}
2277 </td>
2278 <td style="background: #d4edda;">
2279 <div style="font-weight: 600; color: #28a745;">${rec.aggressive.toLocaleString('ru-RU')} ₽</div>
2280 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.aggressiveBasePrice.toLocaleString('ru-RU')} ₽</div>
2281 <div style="font-size: 11px; color: ${rec.priceChange.aggressive < 0 ? '#dc3545' : '#28a745'};">
2282 ${rec.priceChange.aggressive > 0 ? '+' : ''}${rec.priceChange.aggressive}%
2283 </div>
2284 ${rec.forecast && rec.forecast.aggressive ? `
2285 <div style="font-size: 11px; color: ${rec.forecast.aggressive.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
2286 Выручка: ${rec.forecast.aggressive.revenueChange > 0 ? '+' : ''}${rec.forecast.aggressive.revenueChange}%
2287 </div>
2288 <div style="font-size: 11px; color: ${rec.forecast.aggressive.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
2289 Заказы: ${rec.forecast.aggressive.ordersChange > 0 ? '+' : ''}${rec.forecast.aggressive.ordersChange}%
2290 </div>
2291 ${rec.forecast.aggressive.profit !== null ? `
2292 <div style="font-size: 11px; color: ${rec.forecast.aggressive.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
2293 Прибыль: ${rec.forecast.aggressive.profitChange > 0 ? '+' : ''}${rec.forecast.aggressive.profitChange}%
2294 </div>
2295 <div style="font-size: 10px; color: #666;">
2296 ${rec.forecast.aggressive.profit.toFixed(0)} ₽
2297 </div>
2298 ` : ''}
2299 ` : ''}
2300 </td>
2301 <td style="background: #fff3cd;">
2302 <div style="font-weight: 600; color: #dc3545;">${rec.optimal.toLocaleString('ru-RU')} ₽</div>
2303 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.optimalBasePrice.toLocaleString('ru-RU')} ₽</div>
2304 <div style="font-size: 11px; color: ${rec.priceChange.optimal < 0 ? '#dc3545' : '#28a745'};">
2305 ${rec.priceChange.optimal > 0 ? '+' : ''}${rec.priceChange.optimal}%
2306 </div>
2307 ${rec.forecast && rec.forecast.optimal ? `
2308 <div style="font-size: 11px; color: ${rec.forecast.optimal.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
2309 Выручка: ${rec.forecast.optimal.revenueChange > 0 ? '+' : ''}${rec.forecast.optimal.revenueChange}%
2310 </div>
2311 <div style="font-size: 11px; color: ${rec.forecast.optimal.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
2312 Заказы: ${rec.forecast.optimal.ordersChange > 0 ? '+' : ''}${rec.forecast.optimal.ordersChange}%
2313 </div>
2314 ${rec.forecast.optimal.profit !== null ? `
2315 <div style="font-size: 11px; color: ${rec.forecast.optimal.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
2316 Прибыль: ${rec.forecast.optimal.profitChange > 0 ? '+' : ''}${rec.forecast.optimal.profitChange}%
2317 </div>
2318 <div style="font-size: 10px; color: #666;">
2319 ${rec.forecast.optimal.profit.toFixed(0)} ₽
2320 </div>
2321 ` : ''}
2322 ` : ''}
2323 </td>
2324 </tr>
2325 `;
2326 }).join('')}
2327 </tbody>
2328 </table>
2329 </details>
2330 </div>
2331 ` : ''}
2332 ${analytics.brandRecommendations.skinphoria ? `
2333 <div style="background: white; padding: 20px; border-radius: 8px; border: 2px solid #ffc107;">
2334 <div style="font-size: 18px; font-weight: 600; margin-bottom: 15px; color: #333;">
2335 ${analytics.brandRecommendations.skinphoria.brand}
2336 </div>
2337 <div style="margin-bottom: 15px;">
2338 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Текущие товары в выдаче: ${analytics.brandRecommendations.skinphoria.currentProducts.length}</div>
2339 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Средняя позиция: ${analytics.brandRecommendations.skinphoria.avgPosition}</div>
2340 <div style="font-size: 13px; color: #666;">Средняя цена: ${analytics.brandRecommendations.skinphoria.avgPrice.toLocaleString('ru-RU')} ₽</div>
2341 </div>
2342 <details style="margin-top: 10px;" open>
2343 <summary style="cursor: pointer; font-size: 13px; color: #005bff; font-weight: 600;">Показать товары</summary>
2344 <table class="ozon-parser-analytics-table" style="margin-top: 10px;">
2345 <thead>
2346 <tr>
2347 <th>SKU</th>
2348 <th>Название</th>
2349 <th>Позиция</th>
2350 <th>Текущая цена</th>
2351 <th>⚡ Захват рынка</th>
2352 <th>✅ Оптимальная</th>
2353 <th>🔥 Агрессивная</th>
2354 </tr>
2355 </thead>
2356 <tbody>
2357 ${analytics.brandRecommendations.skinphoria.currentProducts.map(p => {
2358 const rec = p.recommendations;
2359 if (!rec) return `
2360 <tr>
2361 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
2362 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
2363 <td>${p.position}</td>
2364 <td>${p.price.toLocaleString('ru-RU')} ₽</td>
2365 <td>—</td>
2366 <td>—</td>
2367 <td>—</td>
2368 </tr>
2369 `;
2370 return `
2371 <tr>
2372 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
2373 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
2374 <td>${p.position}</td>
2375 <td>
2376 <div style="font-weight: 600;">${p.price.toLocaleString('ru-RU')} ₽</div>
2377 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.currentBasePrice.toLocaleString('ru-RU')} ₽</div>
2378 <div style="font-size: 11px; color: #666; margin-top: 4px;">Выручка: ${p.revenue.toLocaleString('ru-RU')} ₽</div>
2379 <div style="font-size: 11px; color: #666;">Заказы: ${p.orders.toLocaleString('ru-RU')}</div>
2380 ${rec.skuDiscount ? `
2381 <div style="font-size: 11px; color: #ff6b00; font-weight: 600; margin-top: 4px;">
2382 Скидка Ozon: ${rec.skuDiscount}%
2383 </div>
2384 ` : ''}
2385 ${rec.currentProfit !== null ? `
2386 <div style="font-size: 11px; color: #005bff; font-weight: 600; margin-top: 4px;">
2387 Прибыль: ${rec.currentProfit.toFixed(0)} ₽
2388 </div>
2389 ` : ''}
2390 </td>
2391 <td style="background: #e9ecef;">
2392 <div style="font-weight: 600; color: #6c757d;">${rec.marketCapture.toLocaleString('ru-RU')} ₽</div>
2393 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.marketCaptureBasePrice.toLocaleString('ru-RU')} ₽</div>
2394 <div style="font-size: 11px; color: ${rec.priceChange.marketCapture < 0 ? '#dc3545' : '#28a745'};">
2395 ${rec.priceChange.marketCapture > 0 ? '+' : ''}${rec.priceChange.marketCapture}%
2396 </div>
2397 ${rec.forecast && rec.forecast.marketCapture ? `
2398 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
2399 Выручка: ${rec.forecast.marketCapture.revenueChange > 0 ? '+' : ''}${rec.forecast.marketCapture.revenueChange}%
2400 </div>
2401 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
2402 Заказы: ${rec.forecast.marketCapture.ordersChange > 0 ? '+' : ''}${rec.forecast.marketCapture.ordersChange}%
2403 </div>
2404 ${rec.forecast.marketCapture.profit !== null ? `
2405 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
2406 Прибыль: ${rec.forecast.marketCapture.profitChange > 0 ? '+' : ''}${rec.forecast.marketCapture.profitChange}%
2407 </div>
2408 <div style="font-size: 10px; color: #666;">
2409 ${rec.forecast.marketCapture.profit.toFixed(0)} ₽
2410 </div>
2411 ` : ''}
2412 ` : ''}
2413 </td>
2414 <td style="background: #d4edda;">
2415 <div style="font-weight: 600; color: #28a745;">${rec.aggressive.toLocaleString('ru-RU')} ₽</div>
2416 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.aggressiveBasePrice.toLocaleString('ru-RU')} ₽</div>
2417 <div style="font-size: 11px; color: ${rec.priceChange.aggressive < 0 ? '#dc3545' : '#28a745'};">
2418 ${rec.priceChange.aggressive > 0 ? '+' : ''}${rec.priceChange.aggressive}%
2419 </div>
2420 ${rec.forecast && rec.forecast.aggressive ? `
2421 <div style="font-size: 11px; color: ${rec.forecast.aggressive.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
2422 Выручка: ${rec.forecast.aggressive.revenueChange > 0 ? '+' : ''}${rec.forecast.aggressive.revenueChange}%
2423 </div>
2424 <div style="font-size: 11px; color: ${rec.forecast.aggressive.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
2425 Заказы: ${rec.forecast.aggressive.ordersChange > 0 ? '+' : ''}${rec.forecast.aggressive.ordersChange}%
2426 </div>
2427 ${rec.forecast.aggressive.profit !== null ? `
2428 <div style="font-size: 11px; color: ${rec.forecast.aggressive.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
2429 Прибыль: ${rec.forecast.aggressive.profitChange > 0 ? '+' : ''}${rec.forecast.aggressive.profitChange}%
2430 </div>
2431 <div style="font-size: 10px; color: #666;">
2432 ${rec.forecast.aggressive.profit.toFixed(0)} ₽
2433 </div>
2434 ` : ''}
2435 ` : ''}
2436 </td>
2437 <td style="background: #fff3cd;">
2438 <div style="font-weight: 600; color: #dc3545;">${rec.optimal.toLocaleString('ru-RU')} ₽</div>
2439 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.optimalBasePrice.toLocaleString('ru-RU')} ₽</div>
2440 <div style="font-size: 11px; color: ${rec.priceChange.optimal < 0 ? '#dc3545' : '#28a745'};">
2441 ${rec.priceChange.optimal > 0 ? '+' : ''}${rec.priceChange.optimal}%
2442 </div>
2443 ${rec.forecast && rec.forecast.optimal ? `
2444 <div style="font-size: 11px; color: ${rec.forecast.optimal.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
2445 Выручка: ${rec.forecast.optimal.revenueChange > 0 ? '+' : ''}${rec.forecast.optimal.revenueChange}%
2446 </div>
2447 <div style="font-size: 11px; color: ${rec.forecast.optimal.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
2448 Заказы: ${rec.forecast.optimal.ordersChange > 0 ? '+' : ''}${rec.forecast.optimal.ordersChange}%
2449 </div>
2450 ${rec.forecast.optimal.profit !== null ? `
2451 <div style="font-size: 11px; color: ${rec.forecast.optimal.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
2452 Прибыль: ${rec.forecast.optimal.profitChange > 0 ? '+' : ''}${rec.forecast.optimal.profitChange}%
2453 </div>
2454 <div style="font-size: 10px; color: #666;">
2455 ${rec.forecast.optimal.profit.toFixed(0)} ₽
2456 </div>
2457 ` : ''}
2458 ` : ''}
2459 </td>
2460 </tr>
2461 `;
2462 }).join('')}
2463 </tbody>
2464 </table>
2465 </details>
2466 </div>
2467 ` : ''}
2468 </div>
2469 ` : ''}
2470
2471 <div class="ozon-parser-analytics-section">
2472 <div class="ozon-parser-analytics-section-title">Общая статистика</div>
2473 <div class="ozon-parser-analytics-grid">
2474 <div class="ozon-parser-analytics-card">
2475 <div class="ozon-parser-analytics-card-title">Общая выручка</div>
2476 <div class="ozon-parser-analytics-card-value">${analytics.totalRevenue.toLocaleString('ru-RU')} ₽</div>
2477 </div>
2478 <div class="ozon-parser-analytics-card">
2479 <div class="ozon-parser-analytics-card-title">Всего заказов</div>
2480 <div class="ozon-parser-analytics-card-value">${analytics.totalOrders.toLocaleString('ru-RU')}</div>
2481 </div>
2482 <div class="ozon-parser-analytics-card">
2483 <div class="ozon-parser-analytics-card-title">Средний чек</div>
2484 <div class="ozon-parser-analytics-card-value">${analytics.avgPrice.toLocaleString('ru-RU')} ₽</div>
2485 </div>
2486 </div>
2487 </div>
2488
2489 <div class="ozon-parser-analytics-section">
2490 <div class="ozon-parser-analytics-section-title">Топ-5 товаров по выручке</div>
2491 <table class="ozon-parser-analytics-table">
2492 <thead>
2493 <tr>
2494 <th>Товар</th>
2495 <th>Бренд</th>
2496 <th>Цена</th>
2497 <th>Выручка</th>
2498 <th>Доля</th>
2499 </tr>
2500 </thead>
2501 <tbody>
2502 `;
2503
2504 analytics.topProducts.forEach(product => {
2505 tableHTML += `
2506 <tr>
2507 <td style="max-width: 300px; white-space: normal;">${product.name || '—'}</td>
2508 <td>${product.brand}</td>
2509 <td>${product.price.toLocaleString('ru-RU')} ₽</td>
2510 <td>${product.revenue.toLocaleString('ru-RU')} ₽</td>
2511 <td>${product.revenueShare}%</td>
2512 </tr>
2513 `;
2514 });
2515
2516 tableHTML += `
2517 </tbody>
2518 </table>
2519 </div>
2520
2521 <div class="ozon-parser-analytics-section">
2522 <div class="ozon-parser-analytics-section-title">Ценовые сегменты</div>
2523 <table class="ozon-parser-analytics-table">
2524 <thead>
2525 <tr>
2526 <th>Сегмент</th>
2527 <th>Диапазон цен</th>
2528 <th>Товаров</th>
2529 <th>Средняя цена</th>
2530 <th>Выручка</th>
2531 <th>Доля выручки</th>
2532 </tr>
2533 </thead>
2534 <tbody>
2535 `;
2536
2537 analytics.priceSegments.forEach(segment => {
2538 tableHTML += `
2539 <tr>
2540 <td>${segment.name}</td>
2541 <td>${segment.priceRange}</td>
2542 <td>${segment.count}</td>
2543 <td>${segment.avgPrice.toLocaleString('ru-RU')} ₽</td>
2544 <td>${segment.revenue.toLocaleString('ru-RU')} ₽</td>
2545 <td>
2546 <div style="display: flex; align-items: center; gap: 10px;">
2547 <div class="ozon-parser-analytics-bar" style="width: ${segment.revenueShare}%; min-width: 20px;"></div>
2548 <span>${segment.revenueShare}%</span>
2549 </div>
2550 </td>
2551 </tr>
2552 `;
2553 });
2554
2555 tableHTML += `
2556 </tbody>
2557 </table>
2558 </div>
2559 </div>
2560 `;
2561
2562 container.innerHTML = tableHTML;
2563 console.log('Ozon Product Parser: Results displayed successfully');
2564 } catch (error) {
2565 console.error('Ozon Product Parser: Error in displayResults:', error);
2566 container.innerHTML = '<p>Ошибка при отображении результатов: ' + error.message + '</p>';
2567 }
2568 }
2569
2570 // Инициализация
2571 function init() {
2572 console.log('Ozon Product Parser: Initializing...');
2573
2574 if (document.readyState === 'loading') {
2575 document.addEventListener('DOMContentLoaded', () => {
2576 addStyles();
2577 createUI();
2578 continueParsingIfActive();
2579 continueDiscountCalculationIfActive();
2580 });
2581 } else {
2582 addStyles();
2583 createUI();
2584 continueParsingIfActive();
2585 continueDiscountCalculationIfActive();
2586 }
2587 }
2588
2589 init();
2590})();