Size
230.0 KB
Version
1.8.58
Created
Mar 26, 2026
Updated
21 days ago
1// ==UserScript==
2// @name Ozon Product Parser
3// @description Парсер товаров с Ozon для анализа поисковой выдачи
4// @version 1.8.58
5// @match https://ozon.ru/*
6// @match https://www.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 cursor: move;
40 user-select: none;
41 }
42 .ozon-parser-container.collapsed {
43 gap: 0;
44 }
45 .ozon-parser-toggle-btn {
46 background: linear-gradient(135deg, #005bff 0%, #0043c7 100%);
47 color: white;
48 border: none;
49 padding: 12px;
50 border-radius: 8px;
51 cursor: pointer;
52 font-size: 18px;
53 font-weight: 600;
54 box-shadow: 0 4px 12px rgba(0, 91, 255, 0.3);
55 transition: all 0.3s ease;
56 width: 44px;
57 height: 44px;
58 display: flex;
59 align-items: center;
60 justify-content: center;
61 }
62 .ozon-parser-toggle-btn:hover {
63 background: linear-gradient(135deg, #0043c7 0%, #002f8f 100%);
64 box-shadow: 0 6px 16px rgba(0, 91, 255, 0.4);
65 transform: translateY(-2px);
66 }
67 .ozon-parser-buttons-wrapper {
68 display: flex;
69 gap: 10px;
70 transition: all 0.3s ease;
71 }
72 .ozon-parser-buttons-wrapper.hidden {
73 display: none;
74 }
75 .ozon-parser-btn {
76 background: linear-gradient(135deg, #005bff 0%, #0043c7 100%);
77 color: white;
78 border: none;
79 padding: 12px 24px;
80 border-radius: 8px;
81 cursor: pointer;
82 font-size: 14px;
83 font-weight: 600;
84 box-shadow: 0 4px 12px rgba(0, 91, 255, 0.3);
85 transition: all 0.3s ease;
86 }
87 .ozon-parser-btn:hover {
88 background: linear-gradient(135deg, #0043c7 0%, #002f8f 100%);
89 box-shadow: 0 6px 16px rgba(0, 91, 255, 0.4);
90 transform: translateY(-2px);
91 }
92 .ozon-parser-btn:active {
93 transform: translateY(0);
94 }
95 .ozon-parser-btn.secondary {
96 background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
97 box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
98 }
99 .ozon-parser-btn.secondary:hover {
100 background: linear-gradient(135deg, #1e7e34 0%, #155724 100%);
101 box-shadow: 0 6px 16px rgba(40, 167, 69, 0.4);
102 }
103 .ozon-parser-modal {
104 position: fixed;
105 top: 0;
106 left: 0;
107 width: 100%;
108 height: 100%;
109 background: rgba(0, 0, 0, 0.7);
110 display: flex;
111 justify-content: center;
112 align-items: center;
113 z-index: 10001;
114 }
115 .ozon-parser-modal-content {
116 background: white;
117 padding: 30px;
118 border-radius: 12px;
119 max-width: 800px;
120 width: 90%;
121 max-height: 80vh;
122 overflow-y: auto;
123 box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
124 }
125 .ozon-parser-modal-header {
126 font-size: 24px;
127 font-weight: 700;
128 margin-bottom: 20px;
129 color: #333;
130 }
131 .ozon-parser-modal-body {
132 margin-bottom: 20px;
133 }
134 .ozon-parser-textarea {
135 width: 100%;
136 min-height: 200px;
137 padding: 12px;
138 border: 2px solid #e0e0e0;
139 border-radius: 8px;
140 font-size: 14px;
141 font-family: inherit;
142 resize: vertical;
143 box-sizing: border-box;
144 }
145 .ozon-parser-textarea:focus {
146 outline: none;
147 border-color: #005bff;
148 }
149 .ozon-parser-modal-footer {
150 display: flex;
151 gap: 10px;
152 justify-content: flex-end;
153 }
154 .ozon-parser-progress {
155 margin-top: 20px;
156 padding: 15px;
157 background: #f8f9fa;
158 border-radius: 8px;
159 font-size: 14px;
160 color: #333;
161 }
162 .ozon-parser-progress-bar {
163 width: 100%;
164 height: 8px;
165 background: #e0e0e0;
166 border-radius: 4px;
167 margin-top: 10px;
168 overflow: hidden;
169 }
170 .ozon-parser-progress-fill {
171 height: 100%;
172 background: linear-gradient(90deg, #005bff 0%, #0043c7 100%);
173 transition: width 0.3s ease;
174 }
175 .ozon-parser-results-table {
176 width: 100%;
177 border-collapse: collapse;
178 margin-top: 20px;
179 font-size: 13px;
180 }
181 .ozon-parser-results-table th,
182 .ozon-parser-results-table td {
183 padding: 12px;
184 text-align: left;
185 border-bottom: 1px solid #e0e0e0;
186 white-space: nowrap;
187 }
188 .ozon-parser-results-table th {
189 background: #f8f9fa;
190 font-weight: 600;
191 color: #333;
192 position: sticky;
193 top: 0;
194 }
195 .ozon-parser-results-table td:nth-child(3) {
196 max-width: 500px;
197 white-space: normal;
198 word-wrap: break-word;
199 }
200 .ozon-parser-results-table tr:hover {
201 background: #f8f9fa;
202 }
203 .ozon-parser-highlight {
204 background: #fff3cd !important;
205 font-weight: 600;
206 }
207 .ozon-parser-sku-link {
208 color: #005bff;
209 text-decoration: none;
210 font-weight: 600;
211 }
212 .ozon-parser-sku-link:hover {
213 text-decoration: underline;
214 }
215 .ozon-parser-tabs {
216 display: flex;
217 gap: 5px;
218 margin-bottom: 20px;
219 flex-wrap: wrap;
220 }
221 .ozon-parser-tab {
222 padding: 10px 20px;
223 background: #f8f9fa;
224 border: none;
225 border-radius: 6px;
226 cursor: pointer;
227 font-size: 14px;
228 transition: all 0.2s ease;
229 }
230 .ozon-parser-tab:hover {
231 background: #e9ecef;
232 }
233 .ozon-parser-tab.active {
234 background: #005bff;
235 color: white;
236 font-weight: 600;
237 }
238 .ozon-parser-info {
239 padding: 10px;
240 background: #e7f3ff;
241 border-left: 4px solid #005bff;
242 border-radius: 4px;
243 margin-bottom: 15px;
244 font-size: 13px;
245 color: #333;
246 }
247 .ozon-parser-search {
248 width: 100%;
249 padding: 10px 12px;
250 border: 2px solid #e0e0e0;
251 border-radius: 8px;
252 font-size: 14px;
253 margin-bottom: 15px;
254 box-sizing: border-box;
255 }
256 .ozon-parser-search:focus {
257 outline: none;
258 border-color: #005bff;
259 }
260 .ozon-parser-search::placeholder {
261 color: #999;
262 }
263 .ozon-parser-analytics {
264 margin-top: 30px;
265 padding: 20px;
266 background: #f8f9fa;
267 border-radius: 8px;
268 }
269 .ozon-parser-analytics-header {
270 font-size: 20px;
271 font-weight: 700;
272 margin-bottom: 20px;
273 color: #333;
274 }
275 .ozon-parser-analytics-section {
276 margin-bottom: 25px;
277 }
278 .ozon-parser-analytics-section-title {
279 font-size: 16px;
280 font-weight: 600;
281 margin-bottom: 12px;
282 color: #005bff;
283 }
284 .ozon-parser-analytics-grid {
285 display: grid;
286 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
287 gap: 15px;
288 margin-bottom: 20px;
289 }
290 .ozon-parser-analytics-card {
291 background: white;
292 padding: 15px;
293 border-radius: 8px;
294 border: 1px solid #e0e0e0;
295 }
296 .ozon-parser-analytics-card-title {
297 font-size: 13px;
298 color: #666;
299 margin-bottom: 8px;
300 }
301 .ozon-parser-analytics-card-value {
302 font-size: 24px;
303 font-weight: 700;
304 color: #333;
305 }
306 .ozon-parser-analytics-table {
307 width: 100%;
308 border-collapse: collapse;
309 background: white;
310 border-radius: 8px;
311 overflow: hidden;
312 }
313 .ozon-parser-analytics-table th,
314 .ozon-parser-analytics-table td {
315 padding: 12px;
316 text-align: left;
317 border-bottom: 1px solid #e0e0e0;
318 }
319 .ozon-parser-analytics-table th {
320 background: #f8f9fa;
321 font-weight: 600;
322 color: #333;
323 font-size: 13px;
324 }
325 .ozon-parser-analytics-table td {
326 font-size: 13px;
327 }
328 .ozon-parser-analytics-bar {
329 height: 20px;
330 background: linear-gradient(90deg, #005bff 0%, #0043c7 100%);
331 border-radius: 4px;
332 transition: width 0.3s ease;
333 }
334 .ozon-parser-details-panel {
335 position: fixed;
336 top: 0;
337 right: -600px;
338 width: 600px;
339 height: 100vh;
340 background: white;
341 box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
342 z-index: 10002;
343 overflow-y: auto;
344 transition: right 0.3s ease;
345 }
346 .ozon-parser-details-panel.open {
347 right: 0;
348 }
349 .ozon-parser-details-panel-header {
350 position: sticky;
351 top: 0;
352 background: white;
353 padding: 20px;
354 border-bottom: 2px solid #e0e0e0;
355 display: flex;
356 justify-content: space-between;
357 align-items: center;
358 z-index: 1;
359 }
360 .ozon-parser-details-panel-title {
361 font-size: 18px;
362 font-weight: 700;
363 color: #333;
364 }
365 .ozon-parser-details-panel-close {
366 background: #dc3545;
367 color: white;
368 border: none;
369 padding: 8px 16px;
370 border-radius: 6px;
371 cursor: pointer;
372 font-size: 14px;
373 font-weight: 600;
374 transition: all 0.2s ease;
375 }
376 .ozon-parser-details-panel-close:hover {
377 background: #c82333;
378 }
379 .ozon-parser-details-panel-body {
380 padding: 20px;
381 }
382 .ozon-parser-clickable-row {
383 cursor: pointer;
384 transition: background 0.2s ease;
385 }
386 .ozon-parser-clickable-row:hover {
387 background: #e7f3ff !important;
388 }
389 `;
390 const styleElement = document.createElement('style');
391 styleElement.textContent = styles;
392 document.head.appendChild(styleElement);
393 console.log('Ozon Product Parser: Styles added');
394 }
395
396 // Обновляем счетчик высоких цен
397 async function updateHighPriceCounter() {
398 const highPriceBtn = document.querySelector('#high-price-btn');
399 if (!highPriceBtn) return;
400
401 const highPriceDataJson = await GM.getValue('ozon_parser_high_price', '[]');
402 const highPriceData = JSON.parse(highPriceDataJson);
403
404 highPriceBtn.textContent = `Высокая цена (${highPriceData.length})`;
405 }
406
407 // Показываем модальное окно с ценами
408 async function showPricesModal() {
409 console.log('Ozon Product Parser: Opening prices modal');
410
411 try {
412 // Получаем результаты парсинга
413 const allListResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
414 const allListResults = JSON.parse(allListResultsJson);
415
416 if (Object.keys(allListResults).length === 0) {
417 alert('Нет данных для анализа цен. Сначала выполните парсинг.');
418 return;
419 }
420
421 // Загружаем сохраненные привязки SKU к запросам
422 const skuQueryBindingsJson = await GM.getValue('ozon_parser_sku_query_bindings', '{}');
423 const skuQueryBindings = JSON.parse(skuQueryBindingsJson);
424
425 // Собираем все товары наших брендов из всех запросов
426 const ourProductsData = [];
427
428 for (const listName in allListResults) {
429 const listData = allListResults[listName];
430 for (const query in listData.queries) {
431 const products = listData.queries[query];
432
433 // Находим наши товары
434 const ourProducts = products.filter(p => p.isTargetBrand);
435
436 ourProducts.forEach(ourProduct => {
437 // Проверяем, есть ли привязка для этого SKU
438 const boundQuery = skuQueryBindings[ourProduct.sku];
439
440 // Если есть привязка и это не тот запрос - пропускаем
441 if (boundQuery && boundQuery !== query) {
442 return;
443 }
444
445 // Находим топ-3 конкурентов по позиции в выдаче (а не по выручке)
446 const competitors = products.filter(p => !p.isTargetBrand && p.price > 0);
447
448 // Сортируем конкурентов по позиции в выдаче
449 const sortedCompetitors = [...competitors].sort((a, b) => a.position - b.position);
450
451 // Берем первых 3 конкурентов по позиции
452 const top3Competitors = sortedCompetitors.slice(0, 3);
453
454 if (top3Competitors.length > 0) {
455 // Рассчитываем цены конкурентов пятью способами
456 const competitorPrices = top3Competitors.map(c => c.price);
457 const competitorRevenues = top3Competitors.map(c => c.revenue);
458
459 // 1. Максимум по топ-3
460 const competitorMaxPrice = Math.max(...competitorPrices);
461
462 // 2. Среднее по топ-3
463 const competitorAvgPrice = competitorPrices.reduce((sum, p) => sum + p, 0) / competitorPrices.length;
464
465 // 3. Средневзвешенное по выручке
466 const totalRevenue = competitorRevenues.reduce((sum, r) => sum + r, 0);
467 const competitorWeightedPrice = top3Competitors.reduce((sum, c) => sum + (c.price * c.revenue), 0) / totalRevenue;
468
469 // 4. Минимальная по топ-3
470 const competitorMinPrice = Math.min(...competitorPrices);
471
472 // 5. Медиана по топ-3
473 const sortedPrices = [...competitorPrices].sort((a, b) => a - b);
474 const competitorMedianPrice = sortedPrices[Math.floor(sortedPrices.length / 2)];
475
476 // Рассчитываем среднюю цену (выручка / заказы)
477 const ourAvgPrice = ourProduct.orders > 0 ? ourProduct.revenue / ourProduct.orders : 0;
478 const competitorAvgPrices = top3Competitors.map(c => c.orders > 0 ? c.revenue / c.orders : 0);
479 const competitorAvgPriceValue = competitorAvgPrices.reduce((sum, p) => sum + p, 0) / competitorAvgPrices.length;
480
481 // Рассчитываем выручку конкурентов (среднее)
482 const competitorRevenue = competitorRevenues.reduce((sum, r) => sum + r, 0) / competitorRevenues.length;
483
484 ourProductsData.push({
485 sku: ourProduct.sku,
486 name: ourProduct.name,
487 query: query,
488 listName: listName,
489
490 // Текущая цена
491 ourCurrentPrice: ourProduct.price,
492 competitorMaxPrice: competitorMaxPrice,
493 competitorAvgPrice: competitorAvgPrice,
494 competitorWeightedPrice: competitorWeightedPrice,
495 competitorMinPrice: competitorMinPrice,
496 competitorMedianPrice: competitorMedianPrice,
497
498 // Средняя цена
499 ourAvgPrice: ourAvgPrice,
500 competitorAvgPriceValue: competitorAvgPriceValue,
501
502 // Выручка
503 ourRevenue: ourProduct.revenue,
504 competitorRevenue: competitorRevenue,
505
506 // Для расчета дельт
507 top3Competitors: top3Competitors,
508
509 // Флаг привязки
510 isBound: !!boundQuery
511 });
512 }
513 });
514 }
515 }
516
517 if (ourProductsData.length === 0) {
518 alert('Нет товаров наших брендов в результатах парсинга.');
519 return;
520 }
521
522 const modal = document.createElement('div');
523 modal.className = 'ozon-parser-modal';
524
525 const content = document.createElement('div');
526 content.className = 'ozon-parser-modal-content';
527 content.style.maxWidth = '98vw';
528 content.style.width = '98vw';
529
530 // Состояние сортировки
531 let sortColumn = null;
532 let sortDirection = 'asc';
533
534 // Функция для экспорта в CSV
535 function exportToCSV(data, method, filters) {
536 // Фильтруем данные так же, как в таблице
537 let filteredData = [...data];
538
539 // Применяем фильтр по запросу
540 if (filters.querySearch) {
541 filteredData = filteredData.filter(item =>
542 item.query.toLowerCase().includes(filters.querySearch.toLowerCase())
543 );
544 }
545
546 // Применяем фильтр по SKU
547 if (filters.skuSearch) {
548 filteredData = filteredData.filter(item =>
549 item.sku.includes(filters.skuSearch)
550 );
551 }
552
553 // Применяем фильтры по выручке
554 if (filters.revenue > 0) {
555 filteredData = filteredData.filter(item => {
556 const revenueDelta = ((item.ourRevenue - item.competitorRevenue) / item.competitorRevenue) * 100;
557 if (filters.revenueDirection === 'less') {
558 return revenueDelta <= -filters.revenue;
559 } else {
560 return revenueDelta >= filters.revenue;
561 }
562 });
563 }
564
565 // Применяем фильтры по текущей цене
566 if (filters.currentPrice > 0) {
567 filteredData = filteredData.filter(item => {
568 let competitorPrice;
569 if (method === 'max') competitorPrice = item.competitorMaxPrice;
570 else if (method === 'avg') competitorPrice = item.competitorAvgPrice;
571 else if (method === 'min') competitorPrice = item.competitorMinPrice;
572 else if (method === 'median') competitorPrice = item.competitorMedianPrice;
573 else competitorPrice = item.competitorWeightedPrice;
574
575 const priceDelta = ((item.ourCurrentPrice - competitorPrice) / competitorPrice) * 100;
576 if (filters.currentPriceDirection === 'less') {
577 return priceDelta <= -filters.currentPrice;
578 } else {
579 return priceDelta >= filters.currentPrice;
580 }
581 });
582 }
583
584 // Применяем фильтры по средней цене
585 if (filters.avgPrice > 0) {
586 filteredData = filteredData.filter(item => {
587 const avgPriceDelta = ((item.ourAvgPrice - item.competitorAvgPriceValue) / item.competitorAvgPriceValue) * 100;
588 if (filters.avgPriceDirection === 'less') {
589 return avgPriceDelta <= -filters.avgPrice;
590 } else {
591 return avgPriceDelta >= filters.avgPrice;
592 }
593 });
594 }
595
596 // Формируем CSV
597 const methodNames = {
598 'max': 'Максимум',
599 'avg': 'Среднее',
600 'weighted': 'Средневзвешенное',
601 'min': 'Минимум',
602 'median': 'Медиана'
603 };
604
605 let csv = '\uFEFF'; // BOM для корректного отображения кириллицы в Excel
606 csv += 'SKU;Название;Запрос;Список;';
607 csv += `Наша текущая цена;Конкуренты текущая (${methodNames[method]});Дельта текущей цены (%);`;
608 csv += 'Наша средняя цена;Конкуренты средняя;Дельта средней цены (%);';
609 csv += 'Наша выручка;Конкуренты выручка;Дельта выручки (%)\n';
610
611 filteredData.forEach(item => {
612 // Выбираем цену конкурентов в зависимости от метода
613 let competitorPrice;
614 if (method === 'max') competitorPrice = item.competitorMaxPrice;
615 else if (method === 'avg') competitorPrice = item.competitorAvgPrice;
616 else if (method === 'min') competitorPrice = item.competitorMinPrice;
617 else if (method === 'median') competitorPrice = item.competitorMedianPrice;
618 else competitorPrice = item.competitorWeightedPrice;
619
620 // Рассчитываем дельты
621 const currentPriceDelta = ((item.ourCurrentPrice - competitorPrice) / competitorPrice) * 100;
622 const avgPriceDelta = ((item.ourAvgPrice - item.competitorAvgPriceValue) / item.competitorAvgPriceValue) * 100;
623 const revenueDelta = ((item.ourRevenue - item.competitorRevenue) / item.competitorRevenue) * 100;
624
625 // Экранируем кавычки и переносы строк в названии
626 const safeName = (item.name || '—').replace(/"/g, '""').replace(/\n/g, ' ');
627
628 // Форматируем дельты: добавляем знак = в начале, чтобы Excel интерпретировал как формулу, возвращающую текст
629 const currentPriceDeltaStr = `="${currentPriceDelta.toFixed(1)}"`;
630 const avgPriceDeltaStr = `="${avgPriceDelta.toFixed(1)}"`;
631 const revenueDeltaStr = `="${revenueDelta.toFixed(1)}"`;
632
633 csv += `${item.sku};"${safeName}";${item.query};${item.listName};`;
634 csv += `${Math.round(item.ourCurrentPrice)};${Math.round(competitorPrice)};${currentPriceDeltaStr};`;
635 csv += `${Math.round(item.ourAvgPrice)};${Math.round(item.competitorAvgPriceValue)};${avgPriceDeltaStr};`;
636 csv += `${Math.round(item.ourRevenue)};${Math.round(item.competitorRevenue)};${revenueDeltaStr}\n`;
637 });
638
639 // Создаем и скачиваем файл
640 const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
641 const link = document.createElement('a');
642 const url = URL.createObjectURL(blob);
643 link.setAttribute('href', url);
644 link.setAttribute('download', `ozon_prices_${new Date().toISOString().slice(0, 10)}.csv`);
645 link.style.visibility = 'hidden';
646 document.body.appendChild(link);
647 link.click();
648 document.body.removeChild(link);
649
650 console.log(`Ozon Product Parser: Exported ${filteredData.length} products to CSV`);
651 }
652
653 // Функция для отображения таблицы с учетом выбранного метода и фильтров
654 function displayPricesTable(method, filters) {
655 let filteredData = [...ourProductsData];
656
657 // Применяем фильтр по запросу
658 if (filters.querySearch) {
659 filteredData = filteredData.filter(item =>
660 item.query.toLowerCase().includes(filters.querySearch.toLowerCase())
661 );
662 }
663
664 // Применяем фильтр по SKU
665 if (filters.skuSearch) {
666 filteredData = filteredData.filter(item =>
667 item.sku.includes(filters.skuSearch)
668 );
669 }
670
671 // Применяем фильтры по выручке
672 if (filters.revenue > 0) {
673 filteredData = filteredData.filter(item => {
674 const revenueDelta = ((item.ourRevenue - item.competitorRevenue) / item.competitorRevenue) * 100;
675 if (filters.revenueDirection === 'less') {
676 return revenueDelta <= -filters.revenue;
677 } else {
678 return revenueDelta >= filters.revenue;
679 }
680 });
681 }
682
683 // Применяем фильтры по текущей цене
684 if (filters.currentPrice > 0) {
685 filteredData = filteredData.filter(item => {
686 let competitorPrice;
687 if (method === 'max') competitorPrice = item.competitorMaxPrice;
688 else if (method === 'avg') competitorPrice = item.competitorAvgPrice;
689 else if (method === 'min') competitorPrice = item.competitorMinPrice;
690 else if (method === 'median') competitorPrice = item.competitorMedianPrice;
691 else competitorPrice = item.competitorWeightedPrice;
692
693 const priceDelta = ((item.ourCurrentPrice - competitorPrice) / competitorPrice) * 100;
694 if (filters.currentPriceDirection === 'less') {
695 return priceDelta <= -filters.currentPrice;
696 } else {
697 return priceDelta >= filters.currentPrice;
698 }
699 });
700 }
701
702 // Применяем фильтры по средней цене
703 if (filters.avgPrice > 0) {
704 filteredData = filteredData.filter(item => {
705 const avgPriceDelta = ((item.ourAvgPrice - item.competitorAvgPriceValue) / item.competitorAvgPriceValue) * 100;
706 if (filters.avgPriceDirection === 'less') {
707 return avgPriceDelta <= -filters.avgPrice;
708 } else {
709 return avgPriceDelta >= filters.avgPrice;
710 }
711 });
712 }
713
714 // Применяем сортировку
715 if (sortColumn) {
716 filteredData.sort((a, b) => {
717 let aVal, bVal;
718
719 switch(sortColumn) {
720 case 'sku':
721 aVal = a.sku;
722 bVal = b.sku;
723 break;
724 case 'name':
725 aVal = (a.name || '').toLowerCase();
726 bVal = (b.name || '').toLowerCase();
727 break;
728 case 'query':
729 aVal = a.query.toLowerCase();
730 bVal = b.query.toLowerCase();
731 break;
732 case 'ourCurrentPrice':
733 aVal = a.ourCurrentPrice;
734 bVal = b.ourCurrentPrice;
735 break;
736 case 'competitorPrice':
737 if (method === 'max') {
738 aVal = a.competitorMaxPrice;
739 bVal = b.competitorMaxPrice;
740 } else if (method === 'avg') {
741 aVal = a.competitorAvgPrice;
742 bVal = b.competitorAvgPrice;
743 } else {
744 aVal = a.competitorWeightedPrice;
745 bVal = b.competitorWeightedPrice;
746 }
747 break;
748 case 'currentPriceDelta':
749 let compPriceA = method === 'max' ? a.competitorMaxPrice : (method === 'avg' ? a.competitorAvgPrice : a.competitorWeightedPrice);
750 let compPriceB = method === 'max' ? b.competitorMaxPrice : (method === 'avg' ? b.competitorAvgPrice : b.competitorWeightedPrice);
751 aVal = ((a.ourCurrentPrice - compPriceA) / compPriceA) * 100;
752 bVal = ((b.ourCurrentPrice - compPriceB) / compPriceB) * 100;
753 break;
754 case 'ourAvgPrice':
755 aVal = a.ourAvgPrice;
756 bVal = b.ourAvgPrice;
757 break;
758 case 'competitorAvgPrice':
759 aVal = a.competitorAvgPriceValue;
760 bVal = b.competitorAvgPriceValue;
761 break;
762 case 'avgPriceDelta':
763 aVal = ((a.ourAvgPrice - a.competitorAvgPriceValue) / a.competitorAvgPriceValue) * 100;
764 bVal = ((b.ourAvgPrice - b.competitorAvgPriceValue) / b.competitorAvgPriceValue) * 100;
765 break;
766 case 'ourRevenue':
767 aVal = a.ourRevenue;
768 bVal = b.ourRevenue;
769 break;
770 case 'competitorRevenue':
771 aVal = a.competitorRevenue;
772 bVal = b.competitorRevenue;
773 break;
774 case 'revenueDelta':
775 aVal = ((a.ourRevenue - a.competitorRevenue) / a.competitorRevenue) * 100;
776 bVal = ((b.ourRevenue - b.competitorRevenue) / b.competitorRevenue) * 100;
777 break;
778 default:
779 return 0;
780 }
781
782 if (typeof aVal === 'string') {
783 return sortDirection === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
784 } else {
785 return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
786 }
787 });
788 }
789
790 let tableHTML = `
791 <div class="ozon-parser-info">
792 Найдено товаров: ${filteredData.length} из ${ourProductsData.length}
793 </div>
794 <table class="ozon-parser-results-table">
795 <thead>
796 <tr>
797 <th rowspan="2" data-sort="sku" style="cursor: pointer; user-select: none;">SKU ${sortColumn === 'sku' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
798 <th rowspan="2" data-sort="name" style="cursor: pointer; user-select: none;">Название ${sortColumn === 'name' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
799 <th rowspan="2" data-sort="query" style="cursor: pointer; user-select: none;">Запрос ${sortColumn === 'query' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
800 <th colspan="3">Текущая цена</th>
801 <th colspan="3">Средняя цена</th>
802 <th colspan="3">Выручка</th>
803 </tr>
804 <tr>
805 <th data-sort="ourCurrentPrice" style="cursor: pointer; user-select: none;">Наша ${sortColumn === 'ourCurrentPrice' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
806 <th data-sort="competitorPrice" style="cursor: pointer; user-select: none;">Конкуренты ${sortColumn === 'competitorPrice' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
807 <th data-sort="currentPriceDelta" style="cursor: pointer; user-select: none;">Дельта ${sortColumn === 'currentPriceDelta' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
808 <th data-sort="ourAvgPrice" style="cursor: pointer; user-select: none;">Наша ${sortColumn === 'ourAvgPrice' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
809 <th data-sort="competitorAvgPrice" style="cursor: pointer; user-select: none;">Конкуренты ${sortColumn === 'competitorAvgPrice' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
810 <th data-sort="avgPriceDelta" style="cursor: pointer; user-select: none;">Дельта ${sortColumn === 'avgPriceDelta' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
811 <th data-sort="ourRevenue" style="cursor: pointer; user-select: none;">Наша ${sortColumn === 'ourRevenue' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
812 <th data-sort="competitorRevenue" style="cursor: pointer; user-select: none;">Конкуренты ${sortColumn === 'competitorRevenue' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
813 <th data-sort="revenueDelta" style="cursor: pointer; user-select: none;">Дельта ${sortColumn === 'revenueDelta' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}</th>
814 </tr>
815 </thead>
816 <tbody>
817 `;
818
819 filteredData.forEach(item => {
820 // Выбираем цену конкурентов в зависимости от метода
821 let competitorPrice;
822 if (method === 'max') competitorPrice = item.competitorMaxPrice;
823 else if (method === 'avg') competitorPrice = item.competitorAvgPrice;
824 else if (method === 'min') competitorPrice = item.competitorMinPrice;
825 else if (method === 'median') competitorPrice = item.competitorMedianPrice;
826 else competitorPrice = item.competitorWeightedPrice;
827
828 // Рассчитываем дельты
829 const currentPriceDelta = ((item.ourCurrentPrice - competitorPrice) / competitorPrice) * 100;
830 const avgPriceDelta = ((item.ourAvgPrice - item.competitorAvgPriceValue) / item.competitorAvgPriceValue) * 100;
831 const revenueDelta = ((item.ourRevenue - item.competitorRevenue) / item.competitorRevenue) * 100;
832
833 // Добавляем индикатор привязки к запросу
834 const boundIndicator = item.isBound ? '<span style="color: #28a745; font-weight: 600; margin-left: 5px;" title="Запрос привязан к товару">🔒</span>' : '';
835
836 tableHTML += `
837 <tr>
838 <td><a href="https://www.ozon.ru/product/${item.sku}" target="_blank" class="ozon-parser-sku-link">${item.sku}</a></td>
839 <td style="max-width: 300px; white-space: normal;">${item.name || '—'}</td>
840 <td><span class="ozon-parser-query-clickable" data-sku="${item.sku}" style="color: #005bff; cursor: pointer; text-decoration: underline;">${item.query}${boundIndicator}</span></td>
841
842 <td style="font-weight: 600;">${Math.round(item.ourCurrentPrice).toLocaleString('ru-RU')} ₽</td>
843 <td>${Math.round(competitorPrice).toLocaleString('ru-RU')} ₽</td>
844 <td style="color: ${currentPriceDelta >= 0 ? '#dc3545' : '#28a745'}; font-weight: 600;">
845 ${currentPriceDelta > 0 ? '+' : ''}${currentPriceDelta.toFixed(1)}%
846 </td>
847
848 <td style="font-weight: 600;">${Math.round(item.ourAvgPrice).toLocaleString('ru-RU')} ₽</td>
849 <td>${Math.round(item.competitorAvgPriceValue).toLocaleString('ru-RU')} ₽</td>
850 <td style="color: ${avgPriceDelta >= 0 ? '#dc3545' : '#28a745'}; font-weight: 600;">
851 ${avgPriceDelta > 0 ? '+' : ''}${avgPriceDelta.toFixed(1)}%
852 </td>
853
854 <td style="font-weight: 600;">${Math.round(item.ourRevenue).toLocaleString('ru-RU')} ₽</td>
855 <td>${Math.round(item.competitorRevenue).toLocaleString('ru-RU')} ₽</td>
856 <td style="color: ${revenueDelta >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600;">
857 ${revenueDelta > 0 ? '+' : ''}${revenueDelta.toFixed(1)}%
858 </td>
859 </tr>
860 `;
861 });
862
863 tableHTML += `
864 </tbody>
865 </table>
866 `;
867
868 const tableContainer = content.querySelector('#prices-table-container');
869 tableContainer.innerHTML = tableHTML;
870
871 // Добавляем обработчики сортировки
872 tableContainer.querySelectorAll('th[data-sort]').forEach(th => {
873 th.addEventListener('click', () => {
874 const column = th.getAttribute('data-sort');
875 if (sortColumn === column) {
876 sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
877 } else {
878 sortColumn = column;
879 sortDirection = 'asc';
880 }
881 displayPricesTable(currentMethod, currentFilters);
882 });
883 });
884
885 // Добавляем обработчики кликов на запросы для выбора привязки
886 tableContainer.querySelectorAll('.ozon-parser-query-clickable').forEach(querySpan => {
887 querySpan.addEventListener('click', async (e) => {
888 e.preventDefault();
889 e.stopPropagation();
890 const sku = querySpan.getAttribute('data-sku');
891 await showQuerySelectionModal(sku, allListResults, skuQueryBindings);
892 });
893 });
894
895 // Добавляем обработчики кликов на строки для открытия панели с деталями
896 tableContainer.querySelectorAll('tbody tr').forEach(row => {
897 row.classList.add('ozon-parser-clickable-row');
898 row.addEventListener('click', async (e) => {
899 // Не открываем панель, если кликнули на ссылку или запрос
900 if (e.target.tagName === 'A' || e.target.classList.contains('ozon-parser-query-clickable')) return;
901
902 const skuLink = row.querySelector('.ozon-parser-sku-link');
903 if (!skuLink) return;
904
905 const sku = skuLink.textContent.trim();
906 const productData = filteredData.find(item => item.sku === sku);
907
908 if (productData) {
909 await showProductDetailsPanel(productData, allListResults);
910 }
911 });
912 });
913 }
914
915 content.innerHTML = `
916 <div class="ozon-parser-modal-header">💰 Анализ цен</div>
917 <div class="ozon-parser-modal-body">
918 <div class="ozon-parser-info">
919 Сравнение цен наших товаров с топ-3 конкурентами по позиции в выдаче
920 </div>
921
922 <div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
923 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Метод расчета цены конкурентов:</label>
924 <select class="ozon-parser-search" id="price-method-selector" style="margin-bottom: 0;">
925 <option value="max">Максимум по топ-3</option>
926 <option value="avg">Среднее по топ-3</option>
927 <option value="weighted">Средневзвешенное по выручке</option>
928 <option value="min">Минимальная по топ-3</option>
929 <option value="median">Медиана по топ-3</option>
930 </select>
931 </div>
932
933 <div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
934 <label style="font-size: 14px; font-weight: 600; margin-bottom: 12px; display: block;">Фильтры (AND логика):</label>
935
936 <div id="selected-query-info" style="display: none; padding: 10px; background: #e7f3ff; border-left: 4px solid #005bff; border-radius: 4px; margin-bottom: 15px;">
937 <div style="display: flex; justify-content: space-between; align-items: center;">
938 <span style="font-size: 13px; color: #333;">Фильтр по запросу: <strong id="selected-query-text"></strong></span>
939 <button id="clear-query-filter" style="background: #dc3545; color: white; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">Очистить</button>
940 </div>
941 </div>
942
943 <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; margin-bottom: 15px;">
944 <div>
945 <label style="font-size: 12px; color: #666; margin-bottom: 5px; display: block;">Поиск по запросу</label>
946 <input type="text" class="ozon-parser-search" id="filter-query-search" placeholder="Введите запрос" style="margin-bottom: 0;">
947 </div>
948 <div>
949 <label style="font-size: 12px; color: #666; margin-bottom: 5px; display: block;">Поиск по SKU</label>
950 <input type="text" class="ozon-parser-search" id="filter-sku-search" placeholder="Введите SKU" style="margin-bottom: 0;">
951 </div>
952 </div>
953
954 <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px;">
955 <div>
956 <label style="font-size: 12px; color: #666; margin-bottom: 5px; display: block;">Выручка</label>
957 <div style="display: flex; gap: 5px;">
958 <select class="ozon-parser-search" id="filter-revenue-direction" style="margin-bottom: 0; flex: 0 0 auto; width: 100px;">
959 <option value="less">Меньше</option>
960 <option value="more">Больше</option>
961 </select>
962 <input type="number" class="ozon-parser-search" id="filter-revenue" placeholder="0" min="0" step="1" value="0" style="margin-bottom: 0; flex: 1;">
963 </div>
964 <div style="font-size: 10px; color: #999; margin-top: 2px;">на % (и более)</div>
965 </div>
966 <div>
967 <label style="font-size: 12px; color: #666; margin-bottom: 5px; display: block;">Текущая цена</label>
968 <div style="display: flex; gap: 5px;">
969 <select class="ozon-parser-search" id="filter-current-price-direction" style="margin-bottom: 0; flex: 0 0 auto; width: 100px;">
970 <option value="more">Больше</option>
971 <option value="less">Меньше</option>
972 </select>
973 <input type="number" class="ozon-parser-search" id="filter-current-price" placeholder="0" min="0" step="1" value="0" style="margin-bottom: 0; flex: 1;">
974 </div>
975 <div style="font-size: 10px; color: #999; margin-top: 2px;">на % (и более)</div>
976 </div>
977 <div>
978 <label style="font-size: 12px; color: #666; margin-bottom: 5px; display: block;">Средняя цена</label>
979 <div style="display: flex; gap: 5px;">
980 <select class="ozon-parser-search" id="filter-avg-price-direction" style="margin-bottom: 0; flex: 0 0 auto; width: 100px;">
981 <option value="more">Больше</option>
982 <option value="less">Меньше</option>
983 </select>
984 <input type="number" class="ozon-parser-search" id="filter-avg-price" placeholder="0" min="0" step="1" value="0" style="margin-bottom: 0; flex: 1;">
985 </div>
986 <div style="font-size: 10px; color: #999; margin-top: 2px;">на % (и более)</div>
987 </div>
988 </div>
989 </div>
990
991 <div id="prices-table-container"></div>
992 </div>
993 <div class="ozon-parser-modal-footer">
994 <button class="ozon-parser-btn secondary" id="export-csv-btn">Экспорт в CSV</button>
995 <button class="ozon-parser-btn" id="close-prices-modal">Закрыть</button>
996 </div>
997 `;
998
999 modal.appendChild(content);
1000 document.body.appendChild(modal);
1001
1002 // Начальное отображение
1003 let currentMethod = 'max';
1004 let currentFilters = {
1005 revenue: 0,
1006 revenueDirection: 'less',
1007 currentPrice: 0,
1008 currentPriceDirection: 'more',
1009 avgPrice: 0,
1010 avgPriceDirection: 'more',
1011 querySearch: '',
1012 skuSearch: '',
1013 selectedQuery: ''
1014 };
1015
1016 // Загружаем сохраненный запрос
1017 const savedQuery = await GM.getValue('ozon_parser_selected_query', '');
1018 if (savedQuery) {
1019 currentFilters.selectedQuery = savedQuery;
1020 }
1021
1022 displayPricesTable(currentMethod, currentFilters);
1023
1024 // Обновляем информационную панель после первого рендера
1025 if (savedQuery) {
1026 const selectedQueryInfo = content.querySelector('#selected-query-info');
1027 const selectedQueryText = content.querySelector('#selected-query-text');
1028 if (selectedQueryInfo && selectedQueryText) {
1029 selectedQueryInfo.style.display = 'block';
1030 selectedQueryText.textContent = savedQuery;
1031 }
1032 }
1033
1034 // Функция для обновления фильтров из полей ввода
1035 function updateFiltersFromInputs() {
1036 currentFilters = {
1037 revenue: parseFloat(content.querySelector('#filter-revenue').value) || 0,
1038 revenueDirection: content.querySelector('#filter-revenue-direction').value,
1039 currentPrice: parseFloat(content.querySelector('#filter-current-price').value) || 0,
1040 currentPriceDirection: content.querySelector('#filter-current-price-direction').value,
1041 avgPrice: parseFloat(content.querySelector('#filter-avg-price').value) || 0,
1042 avgPriceDirection: content.querySelector('#filter-avg-price-direction').value,
1043 querySearch: content.querySelector('#filter-query-search').value.trim(),
1044 skuSearch: content.querySelector('#filter-sku-search').value.trim(),
1045 selectedQuery: currentFilters.selectedQuery
1046 };
1047 displayPricesTable(currentMethod, currentFilters);
1048 }
1049
1050 // Обработчик очистки фильтра по запросу
1051 const clearQueryFilterBtn = content.querySelector('#clear-query-filter');
1052 if (clearQueryFilterBtn) {
1053 clearQueryFilterBtn.addEventListener('click', async () => {
1054 currentFilters.selectedQuery = '';
1055 await GM.setValue('ozon_parser_selected_query', '');
1056 const selectedQueryInfo = content.querySelector('#selected-query-info');
1057 if (selectedQueryInfo) {
1058 selectedQueryInfo.style.display = 'none';
1059 }
1060 displayPricesTable(currentMethod, currentFilters);
1061 });
1062 }
1063
1064 // Обработчик изменения метода расчета
1065 const methodSelector = content.querySelector('#price-method-selector');
1066 methodSelector.addEventListener('change', () => {
1067 currentMethod = methodSelector.value;
1068 displayPricesTable(currentMethod, currentFilters);
1069 });
1070
1071 // Обработчики для всех фильтров - применяем автоматически
1072 content.querySelector('#filter-query-search').addEventListener('input', debounce(updateFiltersFromInputs, 300));
1073 content.querySelector('#filter-sku-search').addEventListener('input', debounce(updateFiltersFromInputs, 300));
1074
1075 content.querySelector('#filter-revenue').addEventListener('input', debounce(updateFiltersFromInputs, 500));
1076 content.querySelector('#filter-revenue-direction').addEventListener('change', updateFiltersFromInputs);
1077
1078 content.querySelector('#filter-current-price').addEventListener('input', debounce(updateFiltersFromInputs, 500));
1079 content.querySelector('#filter-current-price-direction').addEventListener('change', updateFiltersFromInputs);
1080
1081 content.querySelector('#filter-avg-price').addEventListener('input', debounce(updateFiltersFromInputs, 500));
1082 content.querySelector('#filter-avg-price-direction').addEventListener('change', updateFiltersFromInputs);
1083
1084 // Обработчик экспорта в CSV
1085 content.querySelector('#export-csv-btn').addEventListener('click', () => {
1086 exportToCSV(ourProductsData, currentMethod, currentFilters);
1087 });
1088
1089 // Обработчик закрытия
1090 content.querySelector('#close-prices-modal').addEventListener('click', () => {
1091 modal.remove();
1092 });
1093
1094 modal.addEventListener('click', (e) => {
1095 if (e.target === modal) {
1096 modal.remove();
1097 }
1098 });
1099
1100 console.log('Ozon Product Parser: Prices modal shown');
1101 } catch (error) {
1102 console.error('Ozon Product Parser: Error in showPricesModal:', error);
1103 alert('Ошибка при отображении цен: ' + error.message);
1104 }
1105 }
1106
1107 // Показываем модальное окно выбора запроса для товара
1108 async function showQuerySelectionModal(sku, allListResults, skuQueryBindings) {
1109 console.log('Ozon Product Parser: Opening query selection modal for SKU:', sku);
1110
1111 // Собираем все запросы, где встречается этот товар
1112 const queriesWithProduct = [];
1113 for (const listName in allListResults) {
1114 const listData = allListResults[listName];
1115 for (const query in listData.queries) {
1116 const products = listData.queries[query];
1117 const product = products.find(p => p.sku === sku);
1118 if (product) {
1119 // Находим конкурентов для этого запроса
1120 const competitors = products.filter(p => !p.isTargetBrand && p.price > 0);
1121 const sortedCompetitors = [...competitors].sort((a, b) => a.position - b.position);
1122 const top3Competitors = sortedCompetitors.slice(0, 3);
1123
1124 if (top3Competitors.length > 0) {
1125 const competitorPrices = top3Competitors.map(c => c.price);
1126 const competitorRevenues = top3Competitors.map(c => c.revenue);
1127
1128 const competitorMaxPrice = Math.max(...competitorPrices);
1129 const competitorAvgPrice = competitorPrices.reduce((sum, p) => sum + p, 0) / competitorPrices.length;
1130 const competitorRevenue = competitorRevenues.reduce((sum, r) => sum + r, 0) / competitorRevenues.length;
1131
1132 queriesWithProduct.push({
1133 listName: listName,
1134 query: query,
1135 product: product,
1136 competitorMaxPrice: competitorMaxPrice,
1137 competitorAvgPrice: competitorAvgPrice,
1138 competitorRevenue: competitorRevenue
1139 });
1140 }
1141 }
1142 }
1143 }
1144
1145 if (queriesWithProduct.length === 0) {
1146 alert('Товар не найден ни в одном запросе');
1147 return;
1148 }
1149
1150 const modal = document.createElement('div');
1151 modal.className = 'ozon-parser-modal';
1152 modal.style.zIndex = '10003';
1153
1154 const content = document.createElement('div');
1155 content.className = 'ozon-parser-modal-content';
1156 content.style.maxWidth = '900px';
1157
1158 const currentBoundQuery = skuQueryBindings[sku];
1159
1160 let modalHTML = `
1161 <div class="ozon-parser-modal-header">Выбор запроса для SKU ${sku}</div>
1162 <div class="ozon-parser-modal-body">
1163 <div class="ozon-parser-info">
1164 Нажмите на значок замка, чтобы закрепить запрос для этого товара.
1165 ${currentBoundQuery ? `<br><strong>Текущая привязка:</strong> ${currentBoundQuery}` : ''}
1166 </div>
1167 <table class="ozon-parser-results-table">
1168 <thead>
1169 <tr>
1170 <th style="width: 40px;"></th>
1171 <th>Запрос</th>
1172 <th>Список</th>
1173 <th>Позиция</th>
1174 <th>Выручка товара</th>
1175 <th>Макс. цена конкурентов</th>
1176 <th>Средняя цена конкурентов</th>
1177 <th>Средняя выручка конкурентов</th>
1178 </tr>
1179 </thead>
1180 <tbody>
1181 `;
1182
1183 queriesWithProduct.forEach(item => {
1184 const isBound = currentBoundQuery === item.query;
1185 const rowStyle = isBound ? 'background: #d4edda;' : '';
1186 const lockIcon = isBound ? '🔒' : '🔓';
1187 const lockColor = isBound ? '#28a745' : '#999';
1188
1189 modalHTML += `
1190 <tr style="${rowStyle}">
1191 <td style="text-align: center;">
1192 <span class="query-lock-icon" data-query="${item.query}" style="cursor: pointer; font-size: 20px; color: ${lockColor}; user-select: none;" title="${isBound ? 'Отвязать запрос' : 'Закрепить запрос'}">${lockIcon}</span>
1193 </td>
1194 <td style="font-weight: ${isBound ? '600' : 'normal'};">${item.query}</td>
1195 <td>${item.listName}</td>
1196 <td>${item.product.position}</td>
1197 <td>${Math.round(item.product.revenue).toLocaleString('ru-RU')} ₽</td>
1198 <td>${Math.round(item.competitorMaxPrice).toLocaleString('ru-RU')} ₽</td>
1199 <td>${Math.round(item.competitorAvgPrice).toLocaleString('ru-RU')} ₽</td>
1200 <td>${Math.round(item.competitorRevenue).toLocaleString('ru-RU')} ₽</td>
1201 </tr>
1202 `;
1203 });
1204
1205 modalHTML += `
1206 </tbody>
1207 </table>
1208 </div>
1209 <div class="ozon-parser-modal-footer">
1210 <button class="ozon-parser-btn" id="close-query-selection-modal">Закрыть</button>
1211 </div>
1212 `;
1213
1214 content.innerHTML = modalHTML;
1215 modal.appendChild(content);
1216 document.body.appendChild(modal);
1217
1218 // Обработчики кликов на замки
1219 content.querySelectorAll('.query-lock-icon').forEach(lockIcon => {
1220 lockIcon.addEventListener('click', async () => {
1221 const selectedQuery = lockIcon.getAttribute('data-query');
1222 const isBound = currentBoundQuery === selectedQuery;
1223
1224 if (isBound) {
1225 // Отвязываем запрос
1226 if (confirm(`Отвязать запрос "${selectedQuery}" от товара ${sku}?`)) {
1227 delete skuQueryBindings[sku];
1228 await GM.setValue('ozon_parser_sku_query_bindings', JSON.stringify(skuQueryBindings));
1229
1230 console.log(`Ozon Product Parser: Unbound SKU ${sku} from query "${selectedQuery}"`);
1231
1232 modal.remove();
1233
1234 // Перезагружаем таблицу цен
1235 await showPricesModal();
1236 }
1237 } else {
1238 // Привязываем запрос
1239 skuQueryBindings[sku] = selectedQuery;
1240 await GM.setValue('ozon_parser_sku_query_bindings', JSON.stringify(skuQueryBindings));
1241
1242 console.log(`Ozon Product Parser: Bound SKU ${sku} to query "${selectedQuery}"`);
1243
1244 modal.remove();
1245
1246 // Перезагружаем таблицу цен
1247 await showPricesModal();
1248 }
1249 });
1250 });
1251
1252 // Закрытие
1253 content.querySelector('#close-query-selection-modal').addEventListener('click', () => {
1254 modal.remove();
1255 });
1256
1257 modal.addEventListener('click', (e) => {
1258 if (e.target === modal) {
1259 modal.remove();
1260 }
1261 });
1262 }
1263
1264 // Показываем панель с деталями товара
1265 async function showProductDetailsPanel(productData, allListResults) {
1266 console.log('Ozon Product Parser: Opening product details panel for SKU:', productData.sku);
1267
1268 // Удаляем существующую панель, если она есть
1269 const existingPanel = document.querySelector('.ozon-parser-details-panel');
1270 if (existingPanel) {
1271 existingPanel.remove();
1272 }
1273
1274 // Создаем панель
1275 const panel = document.createElement('div');
1276 panel.className = 'ozon-parser-details-panel';
1277
1278 // Загружаем привязки SKU к запросам
1279 const skuQueryBindingsJson = await GM.getValue('ozon_parser_sku_query_bindings', '{}');
1280 const skuQueryBindings = JSON.parse(skuQueryBindingsJson);
1281 const boundQuery = skuQueryBindings[productData.sku];
1282
1283 // Собираем все запросы, где встречается этот товар
1284 const queriesWithProduct = [];
1285 for (const listName in allListResults) {
1286 const listData = allListResults[listName];
1287 for (const query in listData.queries) {
1288 const products = listData.queries[query];
1289 const product = products.find(p => p.sku === productData.sku);
1290 if (product) {
1291 // Если есть привязка и это не тот запрос - пропускаем
1292 if (boundQuery && boundQuery !== query) {
1293 continue;
1294 }
1295
1296 queriesWithProduct.push({
1297 listName: listName,
1298 query: query,
1299 product: product,
1300 allProducts: products
1301 });
1302 }
1303 }
1304 }
1305
1306 // Функция для генерации HTML панели с аналитикой
1307 async function generatePanelHTML(queryData) {
1308 const analytics = await analyzeProducts(queryData.allProducts);
1309
1310 return `
1311 <div class="ozon-parser-details-panel-header">
1312 <div class="ozon-parser-details-panel-title">SKU: ${productData.sku}</div>
1313 <button class="ozon-parser-details-panel-close">Закрыть</button>
1314 </div>
1315 <div class="ozon-parser-details-panel-body">
1316 <div style="margin-bottom: 20px;">
1317 <div style="font-size: 16px; font-weight: 600; margin-bottom: 10px;">${productData.name || 'Название не найдено'}</div>
1318 <div style="font-size: 14px; color: #666; margin-bottom: 5px;">Текущая цена: <strong>${Math.round(productData.ourCurrentPrice).toLocaleString('ru-RU')} ₽</strong></div>
1319 <div style="font-size: 14px; color: #666; margin-bottom: 5px;">Средняя цена: <strong>${Math.round(productData.ourAvgPrice).toLocaleString('ru-RU')} ₽</strong></div>
1320 <div style="font-size: 14px; color: #666;">Выручка: <strong>${Math.round(productData.ourRevenue).toLocaleString('ru-RU')} ₽</strong></div>
1321 </div>
1322
1323 ${analytics && analytics.elasticity ? `
1324 <div style="margin-bottom: 20px; padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #005bff;">
1325 <div style="font-size: 15px; font-weight: 600; margin-bottom: 10px; color: #005bff;">📊 Эластичность запроса</div>
1326 <div style="font-size: 24px; font-weight: 700; color: #333; margin-bottom: 8px;">${analytics.elasticity.value}</div>
1327 <div style="font-size: 13px; color: #666; line-height: 1.5;">
1328 ${analytics.elasticity.interpretation}
1329 </div>
1330 </div>
1331 ` : ''}
1332
1333 ${analytics && analytics.recommendedPrices ? `
1334 <div style="margin-bottom: 20px;">
1335 <div style="font-size: 15px; font-weight: 600; margin-bottom: 12px; color: #005bff;">💰 Стратегии ценообразования</div>
1336
1337 <div style="background: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 10px; border-left: 4px solid #6c757d;">
1338 <div style="font-size: 14px; font-weight: 600; color: #6c757d; margin-bottom: 5px;">⚡ Захват рынка</div>
1339 <div style="font-size: 20px; font-weight: 700; color: #333; margin-bottom: 5px;">${analytics.recommendedPrices.marketCapture.price.toLocaleString('ru-RU')} ₽</div>
1340 <div style="font-size: 12px; color: #666; margin-bottom: 8px;">${analytics.recommendedPrices.marketCapture.description}</div>
1341 ${analytics.brandRecommendations && (analytics.brandRecommendations.gls || analytics.brandRecommendations.skinphoria) ? `
1342 <div style="font-size: 11px; color: #666; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e0e0e0;">
1343 ${(() => {
1344 const ourProduct = (analytics.brandRecommendations.gls?.currentProducts || [])
1345 .concat(analytics.brandRecommendations.skinphoria?.currentProducts || [])
1346 .find(p => p.sku === productData.sku);
1347 if (!ourProduct || !ourProduct.recommendations || !ourProduct.recommendations.forecast || !ourProduct.recommendations.forecast.marketCapture) return '';
1348 const forecast = ourProduct.recommendations.forecast.marketCapture;
1349 return `
1350 <div style="color: ${forecast.revenueChange >= 0 ? '#28a745' : '#dc3545'};">Выручка: ${forecast.revenueChange > 0 ? '+' : ''}${forecast.revenueChange}%</div>
1351 <div style="color: ${forecast.ordersChange >= 0 ? '#28a745' : '#dc3545'};">Заказы: ${forecast.ordersChange > 0 ? '+' : ''}${forecast.ordersChange}%</div>
1352 ${forecast.profit !== null ? `<div style="color: ${forecast.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600;">Прибыль: ${forecast.profitChange > 0 ? '+' : ''}${forecast.profitChange}% (${forecast.profit.toFixed(0)} ₽)</div>` : ''}
1353 `;
1354 })()}
1355 </div>
1356 ` : ''}
1357 </div>
1358
1359 <div style="background: #fff3cd; padding: 12px; border-radius: 8px; margin-bottom: 10px; border-left: 4px solid #dc3545;">
1360 <div style="font-size: 14px; font-weight: 600; color: #dc3545; margin-bottom: 5px;">✅ Оптимальная</div>
1361 <div style="font-size: 20px; font-weight: 700; color: #333; margin-bottom: 5px;">${analytics.recommendedPrices.aggressive.price.toLocaleString('ru-RU')} ₽</div>
1362 <div style="font-size: 12px; color: #666; margin-bottom: 8px;">${analytics.recommendedPrices.aggressive.description}</div>
1363 ${analytics.brandRecommendations && (analytics.brandRecommendations.gls || analytics.brandRecommendations.skinphoria) ? `
1364 <div style="font-size: 11px; color: #666; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e0e0e0;">
1365 ${(() => {
1366 const ourProduct = (analytics.brandRecommendations.gls?.currentProducts || [])
1367 .concat(analytics.brandRecommendations.skinphoria?.currentProducts || [])
1368 .find(p => p.sku === productData.sku);
1369 if (!ourProduct || !ourProduct.recommendations || !ourProduct.recommendations.forecast || !ourProduct.recommendations.forecast.aggressive) return '';
1370 const forecast = ourProduct.recommendations.forecast.aggressive;
1371 return `
1372 <div style="color: ${forecast.revenueChange >= 0 ? '#28a745' : '#dc3545'};">Выручка: ${forecast.revenueChange > 0 ? '+' : ''}${forecast.revenueChange}%</div>
1373 <div style="color: ${forecast.ordersChange >= 0 ? '#28a745' : '#dc3545'};">Заказы: ${forecast.ordersChange > 0 ? '+' : ''}${forecast.ordersChange}%</div>
1374 ${forecast.profit !== null ? `<div style="color: ${forecast.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600;">Прибыль: ${forecast.profitChange > 0 ? '+' : ''}${forecast.profitChange}% (${forecast.profit.toFixed(0)} ₽)</div>` : ''}
1375 `;
1376 })()}
1377 </div>
1378 ` : ''}
1379 </div>
1380
1381 <div style="background: #d4edda; padding: 12px; border-radius: 8px; border-left: 4px solid #28a745;">
1382 <div style="font-size: 14px; font-weight: 600; color: #28a745; margin-bottom: 5px;">🔥 Агрессивная</div>
1383 <div style="font-size: 20px; font-weight: 700; color: #333; margin-bottom: 5px;">${analytics.recommendedPrices.optimal.price.toLocaleString('ru-RU')} ₽</div>
1384 <div style="font-size: 12px; color: #666; margin-bottom: 8px;">${analytics.recommendedPrices.optimal.description}</div>
1385 ${analytics.brandRecommendations && (analytics.brandRecommendations.gls || analytics.brandRecommendations.skinphoria) ? `
1386 <div style="font-size: 11px; color: #666; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e0e0e0;">
1387 ${(() => {
1388 const ourProduct = (analytics.brandRecommendations.gls?.currentProducts || [])
1389 .concat(analytics.brandRecommendations.skinphoria?.currentProducts || [])
1390 .find(p => p.sku === productData.sku);
1391 if (!ourProduct || !ourProduct.recommendations || !ourProduct.recommendations.forecast || !ourProduct.recommendations.forecast.optimal) return '';
1392 const forecast = ourProduct.recommendations.forecast.optimal;
1393 return `
1394 <div style="color: ${forecast.revenueChange >= 0 ? '#28a745' : '#dc3545'};">Выручка: ${forecast.revenueChange > 0 ? '+' : ''}${forecast.revenueChange}%</div>
1395 <div style="color: ${forecast.ordersChange >= 0 ? '#28a745' : '#dc3545'};">Заказы: ${forecast.ordersChange > 0 ? '+' : ''}${forecast.ordersChange}%</div>
1396 ${forecast.profit !== null ? `<div style="color: ${forecast.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600;">Прибыль: ${forecast.profitChange > 0 ? '+' : ''}${forecast.profitChange}% (${forecast.profit.toFixed(0)} ₽)</div>` : ''}
1397 `;
1398 })()}
1399 </div>
1400 ` : ''}
1401 </div>
1402 </div>
1403 ` : ''}
1404
1405 <div style="margin-bottom: 20px;">
1406 <div style="font-size: 15px; font-weight: 600; margin-bottom: 10px; color: #005bff;">Запросы с этим товаром (${queriesWithProduct.length})</div>
1407 <select class="ozon-parser-search" id="query-selector-panel" style="margin-bottom: 15px;">
1408 ${queriesWithProduct.map((item, index) => `
1409 <option value="${index}">${item.query} (${item.listName})</option>
1410 `).join('')}
1411 </select>
1412 <div id="query-details-container"></div>
1413 </div>
1414 </div>
1415 `;
1416 }
1417
1418 // Получаем аналитику для первого запроса и формируем HTML
1419 const firstQueryData = queriesWithProduct[0];
1420 const initialHTML = await generatePanelHTML(firstQueryData);
1421
1422 panel.innerHTML = initialHTML;
1423 document.body.appendChild(panel);
1424
1425 // Функция для отображения деталей выбранного запроса
1426 function displayQueryDetails(queryIndex) {
1427 const queryData = queriesWithProduct[queryIndex];
1428 const product = queryData.product;
1429 const allProducts = queryData.allProducts;
1430 const query = queryData.query;
1431
1432 // Находим топ-3 конкурентов по выручке
1433 const competitors = allProducts.filter(p => !p.isTargetBrand && p.price > 0 && p.revenue > 0);
1434 const sortedCompetitors = [...competitors].sort((a, b) => b.revenue - a.revenue);
1435 const top3Competitors = sortedCompetitors.slice(0, 3);
1436
1437 // Позиция товара по выручке
1438 const sortedByRevenue = [...allProducts].sort((a, b) => b.revenue - a.revenue);
1439 const revenuePosition = sortedByRevenue.findIndex(p => p.sku === product.sku) + 1;
1440
1441 // URL для поиска на Ozon
1442 const searchUrl = `https://www.ozon.ru/search/?text=${encodeURIComponent(query)}&from_global=true`;
1443
1444 let detailsHTML = `
1445 <div style="padding: 15px; background: #f8f9fa; border-radius: 8px; margin-bottom: 15px;">
1446 <div style="font-size: 14px; font-weight: 600; margin-bottom: 10px;">Позиции в выдаче</div>
1447 <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;">
1448 <div>
1449 <div style="font-size: 12px; color: #666;">По позиции</div>
1450 <div style="font-size: 18px; font-weight: 700; color: #005bff;">${product.position} место</div>
1451 </div>
1452 <div>
1453 <div style="font-size: 12px; color: #666;">По выручке</div>
1454 <div style="font-size: 18px; font-weight: 700; color: #005bff;">${revenuePosition} место</div>
1455 </div>
1456 </div>
1457 <div style="margin-top: 10px;">
1458 <a href="${searchUrl}" target="_blank" class="ozon-parser-sku-link" style="font-size: 13px;">🔍 Открыть запрос на Ozon</a>
1459 </div>
1460 </div>
1461
1462 <div style="padding: 15px; background: #f8f9fa; border-radius: 8px; margin-bottom: 15px;">
1463 <div style="font-size: 14px; font-weight: 600; margin-bottom: 10px;">Метрики товара</div>
1464 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Цена: <strong>${product.price.toLocaleString('ru-RU')} ₽</strong></div>
1465 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Выручка: <strong>${product.revenue.toLocaleString('ru-RU')} ₽</strong></div>
1466 <div style="font-size: 13px; color: #666;">Заказы: <strong>${product.orders.toLocaleString('ru-RU')}</strong></div>
1467 </div>
1468 `;
1469
1470 if (top3Competitors.length > 0) {
1471 detailsHTML += `
1472 <div style="padding: 15px; background: #fff3cd; border-radius: 8px;">
1473 <div style="font-size: 14px; font-weight: 600; margin-bottom: 10px;">Топ-3 конкурента по выручке</div>
1474 <table class="ozon-parser-results-table" style="margin-top: 0;">
1475 <thead>
1476 <tr>
1477 <th>Позиция</th>
1478 <th>Бренд</th>
1479 <th>Текущая цена</th>
1480 <th>Средняя цена</th>
1481 <th>Выручка</th>
1482 <th>Заказы</th>
1483 </tr>
1484 </thead>
1485 <tbody>
1486 `;
1487
1488 top3Competitors.forEach(comp => {
1489 const avgPrice = comp.orders > 0 ? comp.revenue / comp.orders : 0;
1490 detailsHTML += `
1491 <tr>
1492 <td>${comp.position}</td>
1493 <td><a href="https://www.ozon.ru/product/${comp.sku}" target="_blank" class="ozon-parser-sku-link">${comp.brand || '—'}</a></td>
1494 <td>${comp.price.toLocaleString('ru-RU')} ₽</td>
1495 <td>${Math.round(avgPrice).toLocaleString('ru-RU')} ₽</td>
1496 <td>${comp.revenue.toLocaleString('ru-RU')} ₽</td>
1497 <td>${comp.orders.toLocaleString('ru-RU')}</td>
1498 </tr>
1499 `;
1500 });
1501
1502 detailsHTML += `
1503 </tbody>
1504 </table>
1505 </div>
1506 `;
1507 }
1508
1509 const container = panel.querySelector('#query-details-container');
1510 container.innerHTML = detailsHTML;
1511 }
1512
1513 // Функция для обновления панели при смене запроса
1514 async function updatePanelForQuery(queryIndex) {
1515 const selectedQueryData = queriesWithProduct[queryIndex];
1516
1517 // Пересчитываем аналитику для выбранного запроса
1518 const newHTML = await generatePanelHTML(selectedQueryData);
1519 panel.innerHTML = newHTML;
1520
1521 // Переподключаем обработчики после обновления HTML
1522 const newQuerySelector = panel.querySelector('#query-selector-panel');
1523 newQuerySelector.value = queryIndex;
1524
1525 // Переподключаем обработчик изменения запроса
1526 newQuerySelector.addEventListener('change', async () => {
1527 const newSelectedIndex = parseInt(newQuerySelector.value);
1528 await updatePanelForQuery(newSelectedIndex);
1529 });
1530
1531 // Переподключаем обработчик закрытия
1532 const newCloseBtn = panel.querySelector('.ozon-parser-details-panel-close');
1533 newCloseBtn.addEventListener('click', () => {
1534 panel.classList.remove('open');
1535 setTimeout(() => panel.remove(), 300);
1536 });
1537
1538 // Отображаем детали выбранного запроса
1539 displayQueryDetails(queryIndex);
1540 }
1541
1542 // Отображаем детали первого запроса
1543 displayQueryDetails(0);
1544
1545 // Обработчик изменения запроса
1546 const querySelector = panel.querySelector('#query-selector-panel');
1547 querySelector.addEventListener('change', async () => {
1548 const selectedIndex = parseInt(querySelector.value);
1549 await updatePanelForQuery(selectedIndex);
1550 });
1551
1552 // Обработчик закрытия панели
1553 const closeBtn = panel.querySelector('.ozon-parser-details-panel-close');
1554 closeBtn.addEventListener('click', () => {
1555 panel.classList.remove('open');
1556 setTimeout(() => panel.remove(), 300);
1557 });
1558
1559 // Открываем панель с анимацией
1560 setTimeout(() => {
1561 panel.classList.add('open');
1562 }, 10);
1563
1564 console.log('Ozon Product Parser: Product details panel opened');
1565 }
1566
1567 // Создаем UI кнопок
1568 function createUI() {
1569 const container = document.createElement('div');
1570 container.className = 'ozon-parser-container';
1571
1572 // Загружаем сохраненную позицию
1573 GM.getValue('ozon_parser_position', JSON.stringify({ top: 20, right: 20 })).then(posJson => {
1574 const pos = JSON.parse(posJson);
1575 container.style.top = pos.top + 'px';
1576 container.style.right = pos.right + 'px';
1577 });
1578
1579 // Загружаем состояние сворачивания
1580 GM.getValue('ozon_parser_collapsed', 'false').then(collapsed => {
1581 if (collapsed === 'true') {
1582 container.classList.add('collapsed');
1583 buttonsWrapper.classList.add('hidden');
1584 }
1585 });
1586
1587 // Кнопка сворачивания/разворачивания
1588 const toggleBtn = document.createElement('button');
1589 toggleBtn.className = 'ozon-parser-toggle-btn';
1590 toggleBtn.innerHTML = '📊';
1591 toggleBtn.title = 'Свернуть/Развернуть панель';
1592
1593 // Обертка для кнопок
1594 const buttonsWrapper = document.createElement('div');
1595 buttonsWrapper.className = 'ozon-parser-buttons-wrapper';
1596
1597 const parseBtn = document.createElement('button');
1598 parseBtn.className = 'ozon-parser-btn';
1599 parseBtn.textContent = 'Парсинг';
1600 parseBtn.addEventListener('click', (e) => {
1601 e.stopPropagation();
1602 showParseModal();
1603 });
1604
1605 const pricesBtn = document.createElement('button');
1606 pricesBtn.className = 'ozon-parser-btn';
1607 pricesBtn.id = 'prices-btn';
1608 pricesBtn.style.background = 'linear-gradient(135deg, #dc3545 0%, #c82333 100%)';
1609 pricesBtn.style.boxShadow = '0 4px 12px rgba(220, 53, 69, 0.3)';
1610 pricesBtn.textContent = 'Цены';
1611 pricesBtn.addEventListener('click', (e) => {
1612 e.stopPropagation();
1613 showPricesModal();
1614 });
1615
1616 const resultsBtn = document.createElement('button');
1617 resultsBtn.className = 'ozon-parser-btn';
1618 resultsBtn.id = 'results-btn';
1619 resultsBtn.style.background = 'linear-gradient(135deg, #28a745 0%, #1e7e34 100%)';
1620 resultsBtn.style.boxShadow = '0 4px 12px rgba(40, 167, 69, 0.3)';
1621 resultsBtn.textContent = 'Результаты';
1622 resultsBtn.addEventListener('click', (e) => {
1623 e.stopPropagation();
1624 showResultsModal();
1625 });
1626
1627 buttonsWrapper.appendChild(parseBtn);
1628 buttonsWrapper.appendChild(pricesBtn);
1629 buttonsWrapper.appendChild(resultsBtn);
1630
1631 container.appendChild(toggleBtn);
1632 container.appendChild(buttonsWrapper);
1633
1634 // Обработчик сворачивания/разворачивания
1635 toggleBtn.addEventListener('click', async (e) => {
1636 e.stopPropagation();
1637 const isCollapsed = container.classList.toggle('collapsed');
1638 buttonsWrapper.classList.toggle('hidden');
1639 await GM.setValue('ozon_parser_collapsed', isCollapsed ? 'true' : 'false');
1640 });
1641
1642 // Перетаскивание панели
1643 let isDragging = false;
1644 let currentX;
1645 let currentY;
1646 let initialX;
1647 let initialY;
1648
1649 container.addEventListener('mousedown', (e) => {
1650 // Не начинаем перетаскивание, если кликнули на кнопку
1651 if (e.target.tagName === 'BUTTON') return;
1652
1653 isDragging = true;
1654 initialX = e.clientX - container.offsetLeft;
1655 initialY = e.clientY - container.offsetTop;
1656 container.style.cursor = 'grabbing';
1657 });
1658
1659 document.addEventListener('mousemove', (e) => {
1660 if (!isDragging) return;
1661
1662 e.preventDefault();
1663 currentX = e.clientX - initialX;
1664 currentY = e.clientY - initialY;
1665
1666 // Ограничиваем перемещение в пределах окна
1667 const maxX = window.innerWidth - container.offsetWidth;
1668 const maxY = window.innerHeight - container.offsetHeight;
1669
1670 currentX = Math.max(0, Math.min(currentX, maxX));
1671 currentY = Math.max(0, Math.min(currentY, maxY));
1672
1673 container.style.left = currentX + 'px';
1674 container.style.top = currentY + 'px';
1675 container.style.right = 'auto';
1676 });
1677
1678 document.addEventListener('mouseup', async () => {
1679 if (isDragging) {
1680 isDragging = false;
1681 container.style.cursor = 'move';
1682
1683 // Сохраняем позицию
1684 const rect = container.getBoundingClientRect();
1685 await GM.setValue('ozon_parser_position', JSON.stringify({
1686 top: rect.top,
1687 right: window.innerWidth - rect.right
1688 }));
1689 }
1690 });
1691
1692 document.body.appendChild(container);
1693 console.log('Ozon Product Parser: UI created');
1694 }
1695
1696 // Показываем модальное окно для ввода запросов
1697 function showParseModal() {
1698 const modal = document.createElement('div');
1699 modal.className = 'ozon-parser-modal';
1700
1701 const content = document.createElement('div');
1702 content.className = 'ozon-parser-modal-content';
1703 content.innerHTML = `
1704 <div class="ozon-parser-modal-header">Парсинг товаров Ozon</div>
1705 <div class="ozon-parser-modal-body">
1706 <div class="ozon-parser-info">
1707 Введите поисковые запросы (каждый с новой строки). Парсер извлечет топ-16 товаров для каждого запроса.
1708 </div>
1709 <div style="margin-bottom: 15px;">
1710 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Скидка Ozon (%):</label>
1711 <div style="display: flex; gap: 10px; align-items: center;">
1712 <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;">
1713 <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>
1714 </div>
1715 <div style="font-size: 12px; color: #666; margin-top: 5px;">
1716 Укажите среднюю скидку Ozon для расчета базовой цены (цены поручения). Например, если товар продается по 500₽ со скидкой 50%, то базовая цена = 1000₽.
1717 </div>
1718 </div>
1719 <div style="margin-bottom: 15px;">
1720 <button class="ozon-parser-btn" id="load-list-btn" style="width: 100%; margin-bottom: 10px;">Загрузить сохраненный список</button>
1721 </div>
1722 <textarea class="ozon-parser-textarea" placeholder="Например: гинкго билоба аргинин витамин д"></textarea>
1723 <div style="margin-top: 15px; display: flex; gap: 10px; align-items: center;">
1724 <input type="text" class="ozon-parser-search" id="list-name-input" placeholder="Название списка (например: БАДы март 2024)" style="flex: 1; margin-bottom: 0;">
1725 <button class="ozon-parser-btn secondary" id="save-list-btn">Сохранить список</button>
1726 </div>
1727 <div style="margin-top: 15px;">
1728 <button class="ozon-parser-btn secondary" id="manage-costs-btn" style="width: 100%;">Управление расходами (себестоимость, комиссия, доставка)</button>
1729 </div>
1730 <div class="ozon-parser-progress" style="display: none;">
1731 <div class="ozon-parser-progress-text">Обработка запросов...</div>
1732 <div class="ozon-parser-progress-bar">
1733 <div class="ozon-parser-progress-fill" style="width: 0%"></div>
1734 </div>
1735 </div>
1736 </div>
1737 <div class="ozon-parser-modal-footer">
1738 <button class="ozon-parser-btn" id="cancel-parse-btn">Отмена</button>
1739 <button class="ozon-parser-btn" id="start-parsing-btn">Начать парсинг</button>
1740 </div>
1741 `;
1742
1743 modal.appendChild(content);
1744 document.body.appendChild(modal);
1745
1746 // Закрытие по клику на фон
1747 modal.addEventListener('click', (e) => {
1748 if (e.target === modal) {
1749 modal.remove();
1750 }
1751 });
1752
1753 // Обработчик кнопки отмены
1754 const cancelBtn = content.querySelector('#cancel-parse-btn');
1755 cancelBtn.addEventListener('click', () => {
1756 modal.remove();
1757 });
1758
1759 // Обработчик кнопки расчета скидки
1760 const calculateDiscountBtn = content.querySelector('#calculate-discount-btn');
1761 calculateDiscountBtn.addEventListener('click', async () => {
1762 calculateDiscountBtn.disabled = true;
1763 calculateDiscountBtn.textContent = 'Расчет...';
1764
1765 try {
1766 // Сохраняем флаг для автоматического расчета
1767 await GM.setValue('ozon_parser_calculate_discount', 'true');
1768
1769 // Открываем страницу в новой вкладке
1770 await GM.openInTab('https://seller.ozon.ru/app/prices/control', false);
1771
1772 // Ждем результата расчета
1773 let attempts = 0;
1774 const maxAttempts = 60; // 60 секунд максимум
1775
1776 const checkInterval = setInterval(async () => {
1777 attempts++;
1778 const calculatedDiscount = await GM.getValue('ozon_parser_calculated_discount', null);
1779 const calculateFlag = await GM.getValue('ozon_parser_calculate_discount', 'false');
1780
1781 if (calculatedDiscount !== null && calculateFlag === 'false') {
1782 // Расчет завершен
1783 clearInterval(checkInterval);
1784
1785 // Вставляем значение в поле
1786 const discountInput = content.querySelector('#ozon-discount-input');
1787 discountInput.value = parseFloat(calculatedDiscount).toFixed(1);
1788
1789 // Очищаем временное значение
1790 await GM.deleteValue('ozon_parser_calculated_discount');
1791
1792 calculateDiscountBtn.disabled = false;
1793 calculateDiscountBtn.textContent = 'Рассчитать автоматически';
1794
1795 alert(`Скидка Ozon успешно рассчитана: ${parseFloat(calculatedDiscount).toFixed(1)}%`);
1796 } else if (attempts >= maxAttempts) {
1797 // Таймаут
1798 clearInterval(checkInterval);
1799 calculateDiscountBtn.disabled = false;
1800 calculateDiscountBtn.textContent = 'Рассчитать автоматически';
1801 alert('Не удалось рассчитать скидку. Попробуйте еще раз.');
1802 }
1803 }, 1000);
1804 } catch (error) {
1805 console.error('Ozon Product Parser: Error calculating discount:', error);
1806 calculateDiscountBtn.disabled = false;
1807 calculateDiscountBtn.textContent = 'Рассчитать автоматически';
1808 alert('Ошибка при расчете скидки: ' + error.message);
1809 }
1810 });
1811
1812 // Обработчик кнопки сохранения списка
1813 const saveListBtn = content.querySelector('#save-list-btn');
1814 saveListBtn.addEventListener('click', async () => {
1815 const textarea = content.querySelector('.ozon-parser-textarea');
1816 const listNameInput = content.querySelector('#list-name-input');
1817 const queries = textarea.value.split('\n').filter(q => q.trim());
1818 const listName = listNameInput.value.trim();
1819
1820 if (queries.length === 0) {
1821 alert('Пожалуйста, введите хотя бы один запрос');
1822 return;
1823 }
1824
1825 if (!listName) {
1826 alert('Пожалуйста, введите название списка');
1827 return;
1828 }
1829
1830 // Сохраняем список
1831 const savedListsJson = await GM.getValue('ozon_parser_saved_lists', '{}');
1832 const savedLists = JSON.parse(savedListsJson);
1833
1834 savedLists[listName] = {
1835 queries: queries,
1836 createdAt: new Date().toISOString(),
1837 updatedAt: new Date().toISOString()
1838 };
1839
1840 await GM.setValue('ozon_parser_saved_lists', JSON.stringify(savedLists));
1841
1842 alert(`Список "${listName}" успешно сохранен!`);
1843 console.log(`Ozon Product Parser: List "${listName}" saved with ${queries.length} queries`);
1844 });
1845
1846 // Обработчик кнопки загрузки списка
1847 const loadListBtn = content.querySelector('#load-list-btn');
1848 loadListBtn.addEventListener('click', async () => {
1849 await showLoadListModal(content);
1850 });
1851
1852 // Обработчик кнопки управления расходами
1853 const manageCostsBtn = content.querySelector('#manage-costs-btn');
1854 manageCostsBtn.addEventListener('click', async () => {
1855 await showManageCostsModal();
1856 });
1857
1858 // Обработчик кнопки парсинга
1859 const startBtn = content.querySelector('#start-parsing-btn');
1860 startBtn.addEventListener('click', async () => {
1861 const textarea = content.querySelector('.ozon-parser-textarea');
1862 const listNameInput = content.querySelector('#list-name-input');
1863 const ozonDiscountInput = content.querySelector('#ozon-discount-input');
1864 const queries = textarea.value.split('\n').filter(q => q.trim());
1865 const listName = listNameInput.value.trim() || 'Без названия';
1866 const ozonDiscount = parseFloat(ozonDiscountInput.value) || 50;
1867
1868 if (queries.length === 0) {
1869 alert('Пожалуйста, введите хотя бы один запрос');
1870 return;
1871 }
1872
1873 if (ozonDiscount < 0 || ozonDiscount > 100) {
1874 alert('Скидка Ozon должна быть от 0 до 100%');
1875 return;
1876 }
1877
1878 // Сохраняем скидку Ozon
1879 await GM.setValue('ozon_parser_discount', ozonDiscount);
1880
1881 startBtn.disabled = true;
1882 startBtn.textContent = 'Парсинг...';
1883 await startParsing(queries, listName, content);
1884 });
1885
1886 console.log('Ozon Product Parser: Parse modal shown');
1887 }
1888
1889 // Показываем модальное окно для загрузки сохраненного списка
1890 async function showLoadListModal(parentContent) {
1891 const savedListsJson = await GM.getValue('ozon_parser_saved_lists', '{}');
1892 const savedLists = JSON.parse(savedListsJson);
1893 const listNames = Object.keys(savedLists);
1894
1895 if (listNames.length === 0) {
1896 alert('Нет сохраненных списков');
1897 return;
1898 }
1899
1900 const loadModal = document.createElement('div');
1901 loadModal.className = 'ozon-parser-modal';
1902 loadModal.style.zIndex = '10002';
1903
1904 const loadContent = document.createElement('div');
1905 loadContent.className = 'ozon-parser-modal-content';
1906 loadContent.style.maxWidth = '600px';
1907
1908 let listsHTML = '<div class="ozon-parser-modal-header">Выберите список</div><div class="ozon-parser-modal-body">';
1909
1910 listNames.forEach(listName => {
1911 const list = savedLists[listName];
1912 const date = new Date(list.createdAt).toLocaleDateString('ru-RU');
1913 const queriesCount = list.queries.length;
1914 listsHTML += `
1915 <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;"
1916 class="saved-list-item" data-list-name="${listName}">
1917 <div style="flex: 1; cursor: pointer;" class="saved-list-info">
1918 <div style="font-weight: 600; font-size: 16px; margin-bottom: 5px;">${listName}</div>
1919 <div style="font-size: 13px; color: #666;">Запросов: ${queriesCount} | Создан: ${date}</div>
1920 </div>
1921 <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>
1922 </div>
1923 `;
1924 });
1925
1926 listsHTML += '</div><div class="ozon-parser-modal-footer"><button class="ozon-parser-btn" id="close-load-modal">Отмена</button></div>';
1927
1928 loadContent.innerHTML = listsHTML;
1929 loadModal.appendChild(loadContent);
1930 document.body.appendChild(loadModal);
1931
1932 // Обработчик выбора списка (только для info блока)
1933 loadContent.querySelectorAll('.saved-list-info').forEach(info => {
1934 info.addEventListener('click', () => {
1935 const listItem = info.closest('.saved-list-item');
1936 const listName = listItem.getAttribute('data-list-name');
1937 const list = savedLists[listName];
1938
1939 // Заполняем textarea
1940 const textarea = parentContent.querySelector('.ozon-parser-textarea');
1941 const listNameInput = parentContent.querySelector('#list-name-input');
1942 textarea.value = list.queries.join('\n');
1943 listNameInput.value = listName;
1944
1945 loadModal.remove();
1946 });
1947
1948 // Hover эффект
1949 const listItem = info.closest('.saved-list-item');
1950 info.addEventListener('mouseenter', () => {
1951 listItem.style.borderColor = '#005bff';
1952 listItem.style.background = '#f8f9fa';
1953 });
1954 info.addEventListener('mouseleave', () => {
1955 listItem.style.borderColor = '#e0e0e0';
1956 listItem.style.background = 'white';
1957 });
1958 });
1959
1960 // Обработчик удаления списка
1961 loadContent.querySelectorAll('[data-delete-list]').forEach(deleteBtn => {
1962 deleteBtn.addEventListener('click', async (e) => {
1963 e.stopPropagation();
1964 const listName = deleteBtn.getAttribute('data-delete-list');
1965
1966 if (!confirm(`Вы уверены, что хотите удалить список "${listName}"?`)) {
1967 return;
1968 }
1969
1970 // Удаляем список из savedLists
1971 delete savedLists[listName];
1972 await GM.setValue('ozon_parser_saved_lists', JSON.stringify(savedLists));
1973
1974 // Также удаляем результаты парсинга для этого списка
1975 const listResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
1976 const listResults = JSON.parse(listResultsJson);
1977 delete listResults[listName];
1978 await GM.setValue('ozon_parser_list_results', JSON.stringify(listResults));
1979
1980 console.log(`Ozon Product Parser: List "${listName}" deleted from load modal`);
1981
1982 // Удаляем элемент из DOM
1983 const listItem = deleteBtn.closest('.saved-list-item');
1984 listItem.remove();
1985
1986 // Если списков не осталось, закрываем модальное окно
1987 const remainingLists = loadContent.querySelectorAll('.saved-list-item');
1988 if (remainingLists.length === 0) {
1989 alert('Все списки удалены');
1990 loadModal.remove();
1991 }
1992 });
1993 });
1994
1995 // Закрытие
1996 loadContent.querySelector('#close-load-modal').addEventListener('click', () => {
1997 loadModal.remove();
1998 });
1999
2000 loadModal.addEventListener('click', (e) => {
2001 if (e.target === loadModal) {
2002 loadModal.remove();
2003 }
2004 });
2005 }
2006
2007 // Показываем модальное окно управления расходами
2008 async function showManageCostsModal() {
2009 const costsJson = await GM.getValue('ozon_parser_costs', '{}');
2010 const costs = JSON.parse(costsJson);
2011
2012 const modal = document.createElement('div');
2013 modal.className = 'ozon-parser-modal';
2014 modal.style.zIndex = '10002';
2015
2016 const content = document.createElement('div');
2017 content.className = 'ozon-parser-modal-content';
2018 content.style.maxWidth = '800px';
2019
2020 content.innerHTML = `
2021 <div class="ozon-parser-modal-header">Управление расходами</div>
2022 <div class="ozon-parser-modal-body">
2023 <div class="ozon-parser-info">
2024 Укажите расходы для ваших товаров (SKU). Эти данные будут использоваться для расчета прибыли и оптимальной цены.
2025 </div>
2026
2027 <!-- Блок загрузки файла -->
2028 <div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
2029 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Загрузить данные из файла:</label>
2030 <div style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px;">
2031 <input type="file" id="cost-file-input" accept=".csv,.json,.txt" style="flex: 1; padding: 8px; border: 2px solid #e0e0e0; border-radius: 8px;">
2032 <button class="ozon-parser-btn" id="upload-costs-btn">Загрузить</button>
2033 </div>
2034 <details style="margin-top: 10px;">
2035 <summary style="cursor: pointer; font-size: 12px; color: #666;">Формат файлов</summary>
2036 <div style="font-size: 12px; color: #666; margin-top: 8px; line-height: 1.6;">
2037 <strong>CSV/TXT:</strong> SKU,Себестоимость,Комиссия,Доставка<br>
2038 Пример: 320244429,158.4,50,90<br><br>
2039 <strong>JSON:</strong> {"SKU": {"cost": 158.4, "commission": 0.5, "delivery": 90}}<br>
2040 Примечание: Комиссия в CSV указывается в процентах (50), в JSON - как десятичная дробь (0.5)
2041 </div>
2042 </details>
2043 </div>
2044
2045 <div style="margin-bottom: 15px;">
2046 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Добавить новый товар:</label>
2047 <div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr auto; gap: 10px; align-items: end;">
2048 <div>
2049 <label style="font-size: 12px; color: #666;">SKU</label>
2050 <input type="text" class="ozon-parser-search" id="new-sku" placeholder="320244429" style="margin-bottom: 0;">
2051 </div>
2052 <div>
2053 <label style="font-size: 12px; color: #666;">Себестоимость (₽)</label>
2054 <input type="number" class="ozon-parser-search" id="new-cost" placeholder="158.4" step="0.01" style="margin-bottom: 0;">
2055 </div>
2056 <div>
2057 <label style="font-size: 12px; color: #666;">Комиссия (%)</label>
2058 <input type="number" class="ozon-parser-search" id="new-commission" placeholder="50" step="0.1" style="margin-bottom: 0;">
2059 </div>
2060 <div>
2061 <label style="font-size: 12px; color: #666;">Доставка (₽)</label>
2062 <input type="number" class="ozon-parser-search" id="new-delivery" placeholder="90" step="0.01" style="margin-bottom: 0;">
2063 </div>
2064 <button class="ozon-parser-btn" id="add-cost-btn" style="margin-bottom: 0;">Добавить</button>
2065 </div>
2066 </div>
2067 <div id="costs-list" style="max-height: 400px; overflow-y: auto;">
2068 ${Object.keys(costs).length === 0 ? '<p style="text-align: center; color: #999;">Нет добавленных товаров</p>' : ''}
2069 </div>
2070 </div>
2071 <div class="ozon-parser-modal-footer">
2072 <button class="ozon-parser-btn" id="close-costs-modal">Закрыть</button>
2073 </div>
2074 `;
2075
2076 modal.appendChild(content);
2077 document.body.appendChild(modal);
2078
2079 // Функция для парсинга CSV
2080 function parseCSV(text) {
2081 const lines = text.trim().split('\n');
2082 const result = {};
2083
2084 for (let i = 0; i < lines.length; i++) {
2085 const line = lines[i].trim();
2086 if (!line || line.startsWith('SKU')) continue; // Пропускаем заголовок
2087
2088 const parts = line.split(',').map(p => p.trim());
2089 if (parts.length < 4) continue;
2090
2091 // Удаляем кавычки из SKU, если они есть
2092 let sku = parts[0].replace(/^["']|["']$/g, '');
2093 const cost = parseFloat(parts[1]);
2094 const commission = parseFloat(parts[2]);
2095 const delivery = parseFloat(parts[3]);
2096
2097 if (sku && !isNaN(cost) && !isNaN(commission) && !isNaN(delivery)) {
2098 result[sku] = {
2099 cost: cost,
2100 commission: commission / 100, // Конвертируем проценты в десятичную дробь
2101 delivery: delivery
2102 };
2103 }
2104 }
2105
2106 return result;
2107 }
2108
2109 // Функция для парсинга JS-объекта из txt файла
2110 function parseJSObject(text) {
2111 try {
2112 // Удаляем возможные комментарии и лишние пробелы
2113 let cleanText = text.trim();
2114
2115 // Если текст не начинается с {, добавляем открывающую скобку
2116 if (!cleanText.startsWith('{')) {
2117 cleanText = '{' + cleanText;
2118 }
2119
2120 // Если текст не заканчивается на }, добавляем закрывающую скобку
2121 if (!cleanText.endsWith('}')) {
2122 // Удаляем последнюю запятую, если она есть
2123 cleanText = cleanText.replace(/,\s*$/, '');
2124 cleanText = cleanText + '}';
2125 }
2126
2127 console.log('Ozon Product Parser: Attempting to parse JS object, length:', cleanText.length);
2128
2129 // Используем eval для парсинга JS объекта (безопасно, т.к. это локальный файл пользователя)
2130 const result = eval('(' + cleanText + ')');
2131
2132 // Валидация формата
2133 if (typeof result !== 'object' || result === null) {
2134 throw new Error('Неверный формат: ожидается объект');
2135 }
2136
2137 // Проверяем структуру данных
2138 for (const sku in result) {
2139 const item = result[sku];
2140 if (typeof item !== 'object' || item === null) {
2141 throw new Error(`Неверный формат данных для SKU ${sku}. Ожидается объект с полями cost, commission, delivery`);
2142 }
2143 if (typeof item.cost !== 'number' || typeof item.commission !== 'number' || typeof item.delivery !== 'number') {
2144 throw new Error(`Неверный формат данных для SKU ${sku}. Ожидается: { cost: число, commission: число (0-1), delivery: число }. Получено: ${JSON.stringify(item)}`);
2145 }
2146 }
2147
2148 console.log('Ozon Product Parser: Successfully parsed JS object with', Object.keys(result).length, 'items');
2149 return result;
2150 } catch (error) {
2151 console.error('Ozon Product Parser: JS object parse error:', error);
2152 throw new Error(`Ошибка парсинга JS-объекта: ${error.message}`);
2153 }
2154 }
2155
2156 // Обработчик загрузки файла
2157 const uploadBtn = content.querySelector('#upload-costs-btn');
2158 uploadBtn.addEventListener('click', async () => {
2159 const fileInput = content.querySelector('#cost-file-input');
2160 const file = fileInput.files[0];
2161
2162 if (!file) {
2163 alert('Пожалуйста, выберите файл');
2164 return;
2165 }
2166
2167 try {
2168 const text = await file.text();
2169 let newCosts = {};
2170
2171 console.log('Ozon Product Parser: Processing file:', file.name, 'Size:', file.size, 'bytes');
2172
2173 if (file.name.endsWith('.json')) {
2174 // Парсим JSON
2175 try {
2176 newCosts = JSON.parse(text);
2177 console.log('Ozon Product Parser: Parsed JSON successfully');
2178 } catch (jsonError) {
2179 console.error('Ozon Product Parser: JSON parse error:', jsonError);
2180 throw new Error(`Ошибка парсинга JSON: ${jsonError.message}. Проверьте, что файл содержит корректный JSON формат.`);
2181 }
2182
2183 // Валидация JSON формата
2184 for (const sku in newCosts) {
2185 const item = newCosts[sku];
2186 if (typeof item !== 'object' || item === null) {
2187 throw new Error(`Неверный формат данных для SKU ${sku}. Ожидается объект с полями cost, commission, delivery`);
2188 }
2189 if (typeof item.cost !== 'number' || typeof item.commission !== 'number' || typeof item.delivery !== 'number') {
2190 throw new Error(`Неверный формат данных для SKU ${sku}. Ожидается: {"cost": число, "commission": число, "delivery": число}. Получено: ${JSON.stringify(item)}`);
2191 }
2192 }
2193 } else if (file.name.endsWith('.txt')) {
2194 // Пытаемся определить формат: JS-объект или CSV
2195 const trimmedText = text.trim();
2196 console.log('Ozon Product Parser: Processing TXT file, first 100 chars:', trimmedText.substring(0, 100));
2197
2198 if (trimmedText.startsWith('{') || trimmedText.includes('{')) {
2199 // JS-объект формат
2200 console.log('Ozon Product Parser: Detected JS object format');
2201 newCosts = parseJSObject(text);
2202 } else {
2203 // CSV формат
2204 console.log('Ozon Product Parser: Detected CSV format');
2205 newCosts = parseCSV(text);
2206 }
2207 } else {
2208 // CSV формат для других расширений
2209 console.log('Ozon Product Parser: Processing as CSV format');
2210 newCosts = parseCSV(text);
2211 }
2212
2213 console.log('Ozon Product Parser: Extracted', Object.keys(newCosts).length, 'items from file');
2214
2215 if (Object.keys(newCosts).length === 0) {
2216 alert('Не удалось извлечь данные из файла. Проверьте формат.\n\nОжидаемые форматы:\n\nCSV: SKU,Себестоимость,Комиссия,Доставка\nПример: 320244429,158.4,50,90\n\nJSON: {"SKU": {"cost": 158.4, "commission": 0.5, "delivery": 90}}');
2217 return;
2218 }
2219
2220 // Объединяем с существующими данными
2221 const mergedCosts = { ...costs, ...newCosts };
2222 await GM.setValue('ozon_parser_costs', JSON.stringify(mergedCosts));
2223
2224 // Обновляем costs переменную
2225 Object.assign(costs, newCosts);
2226
2227 alert(`Успешно загружено ${Object.keys(newCosts).length} товаров`);
2228 console.log('Ozon Product Parser: Successfully saved costs data');
2229 displayCostsList();
2230
2231 // Очищаем input
2232 fileInput.value = '';
2233 } catch (error) {
2234 console.error('Ozon Product Parser: Error uploading costs file:', error);
2235 console.error('Ozon Product Parser: Error stack:', error.stack);
2236 console.error('Ozon Product Parser: Error name:', error.name);
2237 console.error('Ozon Product Parser: Error message:', error.message);
2238 alert('Ошибка при загрузке файла:\n\n' + error.message + '\n\nПроверьте формат файла и попробуйте снова.');
2239 }
2240 });
2241
2242 // Функция для отображения списка расходов
2243 function displayCostsList() {
2244 const costsList = content.querySelector('#costs-list');
2245
2246 if (Object.keys(costs).length === 0) {
2247 costsList.innerHTML = '<p style="text-align: center; color: #999;">Нет добавленных товаров</p>';
2248 return;
2249 }
2250
2251 let html = '<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">';
2252 html += '<div style="font-size: 14px; font-weight: 600;">Всего товаров: ' + Object.keys(costs).length + '</div>';
2253 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>';
2254 html += '</div>';
2255 html += '<table class="ozon-parser-results-table"><thead><tr><th>SKU</th><th>Себестоимость</th><th>Комиссия</th><th>Доставка</th><th>Действия</th></tr></thead><tbody>';
2256
2257 Object.keys(costs).forEach(sku => {
2258 const cost = costs[sku];
2259 html += `
2260 <tr>
2261 <td><a href="https://www.ozon.ru/product/${sku}" target="_blank" class="ozon-parser-sku-link">${sku}</a></td>
2262 <td>${cost.cost.toFixed(2)} ₽</td>
2263 <td>${(cost.commission * 100).toFixed(2)}%</td>
2264 <td>${cost.delivery.toFixed(2)} ₽</td>
2265 <td>
2266 <button class="ozon-parser-btn" data-edit-sku="${sku}" style="padding: 6px 12px; font-size: 12px; margin-right: 5px;">Изменить</button>
2267 <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>
2268 </td>
2269 </tr>
2270 `;
2271 });
2272
2273 html += '</tbody></table>';
2274 costsList.innerHTML = html;
2275
2276 // Обработчик для кнопки удаления всех расходов
2277 const deleteAllBtn = costsList.querySelector('#delete-all-costs-btn');
2278 if (deleteAllBtn) {
2279 deleteAllBtn.addEventListener('click', async () => {
2280 if (confirm('Вы уверены, что хотите удалить ВСЕ данные о расходах? Это действие нельзя отменить.')) {
2281 // Очищаем все данные
2282 Object.keys(costs).forEach(key => delete costs[key]);
2283 await GM.setValue('ozon_parser_costs', JSON.stringify({}));
2284 displayCostsList();
2285 alert('Все данные о расходах удалены');
2286 }
2287 });
2288 }
2289
2290 // Обработчики для кнопок удаления
2291 costsList.querySelectorAll('[data-delete-sku]').forEach(btn => {
2292 btn.addEventListener('click', async () => {
2293 const sku = btn.getAttribute('data-delete-sku');
2294 if (confirm(`Удалить данные о расходах для SKU ${sku}?`)) {
2295 delete costs[sku];
2296 await GM.setValue('ozon_parser_costs', JSON.stringify(costs));
2297 displayCostsList();
2298 }
2299 });
2300 });
2301
2302 // Обработчики для кнопок изменения
2303 costsList.querySelectorAll('[data-edit-sku]').forEach(btn => {
2304 btn.addEventListener('click', () => {
2305 const sku = btn.getAttribute('data-edit-sku');
2306 const cost = costs[sku];
2307
2308 content.querySelector('#new-sku').value = sku;
2309 content.querySelector('#new-cost').value = cost.cost;
2310 content.querySelector('#new-commission').value = cost.commission * 100;
2311 content.querySelector('#new-delivery').value = cost.delivery;
2312
2313 content.querySelector('#new-sku').scrollIntoView({ behavior: 'smooth', block: 'center' });
2314 });
2315 });
2316 }
2317
2318 displayCostsList();
2319
2320 // Обработчик добавления нового товара
2321 const addBtn = content.querySelector('#add-cost-btn');
2322 addBtn.addEventListener('click', async () => {
2323 const sku = content.querySelector('#new-sku').value.trim();
2324 const cost = parseFloat(content.querySelector('#new-cost').value);
2325 const commission = parseFloat(content.querySelector('#new-commission').value);
2326 const delivery = parseFloat(content.querySelector('#new-delivery').value);
2327
2328 if (!sku) {
2329 alert('Пожалуйста, введите SKU');
2330 return;
2331 }
2332
2333 if (isNaN(cost) || cost < 0) {
2334 alert('Пожалуйста, введите корректную себестоимость');
2335 return;
2336 }
2337
2338 if (isNaN(commission) || commission < 0 || commission > 100) {
2339 alert('Пожалуйста, введите корректную комиссию (0-100%)');
2340 return;
2341 }
2342
2343 if (isNaN(delivery) || delivery < 0) {
2344 alert('Пожалуйста, введите корректную стоимость доставки');
2345 return;
2346 }
2347
2348 costs[sku] = {
2349 cost: cost,
2350 commission: commission / 100, // Сохраняем как десятичную дробь
2351 delivery: delivery
2352 };
2353
2354 await GM.setValue('ozon_parser_costs', JSON.stringify(costs));
2355
2356 // Очищаем поля
2357 content.querySelector('#new-sku').value = '';
2358 content.querySelector('#new-cost').value = '';
2359 content.querySelector('#new-commission').value = '';
2360 content.querySelector('#new-delivery').value = '';
2361
2362 displayCostsList();
2363 });
2364
2365 // Закрытие
2366 content.querySelector('#close-costs-modal').addEventListener('click', () => {
2367 modal.remove();
2368 });
2369
2370 modal.addEventListener('click', (e) => {
2371 if (e.target === modal) {
2372 modal.remove();
2373 }
2374 });
2375 }
2376
2377 // Извлекаем скидку со страницы управления ценами
2378 async function extractDiscountFromPage() {
2379 console.log('Ozon Product Parser: Extracting discount from prices page');
2380
2381 // Ждем загрузки таблицы с ценами
2382 await waitForPricesTable();
2383
2384 // Прокручиваем страницу вниз для загрузки цен (ленивая загрузка)
2385 console.log('Ozon Product Parser: Scrolling to load prices');
2386 for (let i = 0; i < 3; i++) {
2387 window.scrollBy(0, 500);
2388 await new Promise(resolve => setTimeout(resolve, 2000));
2389 }
2390
2391 try {
2392 // Ищем цены по правильным классам
2393 // Цена продажи (с картой Ozon): index_priceByOzonCardCurrency_3DLKf
2394 // Базовая цена (цена поручения): index_priceAmount_3dfpL
2395
2396 const salePriceElements = document.querySelectorAll('.index_priceByOzonCardCurrency_3DLKf');
2397 const basePriceElements = document.querySelectorAll('.index_priceAmount_3dfpL');
2398
2399 console.log(`Ozon Product Parser: Found ${salePriceElements.length} sale prices and ${basePriceElements.length} base prices`);
2400
2401 if (salePriceElements.length === 0 || basePriceElements.length === 0) {
2402 console.error('Ozon Product Parser: Could not find price elements on the page');
2403 alert('Не удалось найти цены на странице. Убедитесь, что у вас есть товары с ценами.');
2404 await GM.setValue('ozon_parser_calculate_discount', 'false');
2405 return;
2406 }
2407
2408 // Собираем пары цен (базовая и продажная)
2409 let validDiscounts = [];
2410
2411 // Ищем строки таблицы с обеими ценами
2412 const rows = document.querySelectorAll('tr');
2413 for (const row of rows) {
2414 const salePrice = row.querySelector('.index_priceByOzonCardCurrency_3DLKf');
2415 const basePrice = row.querySelector('.index_priceAmount_3dfpL');
2416
2417 if (salePrice && basePrice) {
2418 const salePriceText = salePrice.textContent.trim();
2419 const basePriceText = basePrice.textContent.trim();
2420
2421 // Парсим цены (убираем пробелы и символ рубля)
2422 const salePriceValue = parseFloat(salePriceText.replace(/\s/g, '').replace('₽', '').replace(',', '.'));
2423 const basePriceValue = parseFloat(basePriceText.replace(/\s/g, '').replace('₽', '').replace(',', '.'));
2424
2425 if (!isNaN(salePriceValue) && !isNaN(basePriceValue) && basePriceValue > 0 && salePriceValue > 0) {
2426 // Рассчитываем скидку: (базовая цена - цена продажи) / базовая цена * 100
2427 const discountAmount = basePriceValue - salePriceValue;
2428 const discountPercent = (discountAmount / basePriceValue) * 100;
2429
2430 if (discountPercent > 0 && discountPercent < 100) {
2431 validDiscounts.push({
2432 basePrice: basePriceValue,
2433 salePrice: salePriceValue,
2434 discount: discountPercent
2435 });
2436
2437 console.log(`Ozon Product Parser: Base price: ${basePriceValue}₽, Sale price: ${salePriceValue}₽, Discount: ${discountPercent.toFixed(1)}%`);
2438 }
2439 }
2440 }
2441 }
2442
2443 if (validDiscounts.length === 0) {
2444 console.error('Ozon Product Parser: No valid price pairs found');
2445 alert('Не удалось найти валидные пары цен на странице.');
2446 await GM.setValue('ozon_parser_calculate_discount', 'false');
2447 return;
2448 }
2449
2450 // Рассчитываем среднюю скидку
2451 const avgDiscount = validDiscounts.reduce((sum, item) => sum + item.discount, 0) / validDiscounts.length;
2452
2453 console.log(`Ozon Product Parser: Calculated average discount from ${validDiscounts.length} products: ${avgDiscount.toFixed(1)}%`);
2454
2455 // Сохраняем рассчитанную скидку для передачи в основное окно
2456 await GM.setValue('ozon_parser_calculated_discount', avgDiscount);
2457
2458 // Сбрасываем флаг расчета
2459 await GM.setValue('ozon_parser_calculate_discount', 'false');
2460
2461 // Закрываем текущую вкладку
2462 window.close();
2463 } catch (error) {
2464 console.error('Ozon Product Parser: Error extracting discount:', error);
2465 alert('Ошибка при извлечении данных о скидке: ' + error.message);
2466
2467 // Сбрасываем флаг расчета
2468 await GM.setValue('ozon_parser_calculate_discount', 'false');
2469 }
2470 }
2471
2472 // Ждем появления таблицы с ценами
2473 function waitForPricesTable() {
2474 return new Promise((resolve) => {
2475 const checkTable = () => {
2476 const rows = document.querySelectorAll('tr');
2477 if (rows.length > 0) {
2478 console.log('Ozon Product Parser: Prices table found');
2479 // Дополнительная задержка для полной загрузки данных
2480 setTimeout(resolve, 3000);
2481 } else {
2482 setTimeout(checkTable, 1000);
2483 }
2484 };
2485 checkTable();
2486 });
2487 }
2488
2489 // Прокручиваем страницу для подгрузки товаров
2490 async function scrollToLoadProducts(targetCount = 20) {
2491 console.log(`Ozon Product Parser: Scrolling to load ${targetCount} products`);
2492
2493 const table = document.querySelector('#mpstat-ozone-search-result table');
2494 if (!table) {
2495 console.error('Ozon Product Parser: Table not found for scrolling');
2496 return;
2497 }
2498
2499 let previousRowCount = 0;
2500 let attempts = 0;
2501 const maxAttempts = 10;
2502
2503 while (attempts < maxAttempts) {
2504 const rows = table.querySelectorAll('tbody tr');
2505 const currentRowCount = rows.length;
2506
2507 console.log(`Ozon Product Parser: Current row count: ${currentRowCount}, target: ${targetCount}`);
2508
2509 if (currentRowCount >= targetCount) {
2510 console.log(`Ozon Product Parser: Loaded ${currentRowCount} products`);
2511 break;
2512 }
2513
2514 // Если количество строк не изменилось, значит больше товаров нет
2515 if (currentRowCount === previousRowCount && attempts > 2) {
2516 console.log(`Ozon Product Parser: No more products to load (${currentRowCount} total)`);
2517 break;
2518 }
2519
2520 previousRowCount = currentRowCount;
2521
2522 // Прокручиваем к последней строке таблицы
2523 const lastRow = rows[rows.length - 1];
2524 if (lastRow) {
2525 lastRow.scrollIntoView({ behavior: 'smooth', block: 'end' });
2526 }
2527
2528 // Также прокручиваем окно вниз
2529 window.scrollBy(0, 500);
2530
2531 // Ждем подгрузки новых товаров
2532 await new Promise(resolve => setTimeout(resolve, 2000));
2533 attempts++;
2534 }
2535
2536 console.log(`Ozon Product Parser: Scrolling completed after ${attempts} attempts`);
2537 }
2538
2539 // Начинаем парсинг
2540 async function startParsing(queries, listName, modalContent) {
2541 const progressDiv = modalContent.querySelector('.ozon-parser-progress');
2542 progressDiv.style.display = 'block';
2543
2544 // Сохраняем список запросов и название списка
2545 await GM.setValue('ozon_parser_queries', JSON.stringify(queries));
2546 await GM.setValue('ozon_parser_current_list_name', listName);
2547 await GM.setValue('ozon_parser_current_index', 0);
2548 await GM.setValue('ozon_parser_active', 'true');
2549 console.log(`Ozon Product Parser: Starting parsing process for list "${listName}"`);
2550
2551 // Переходим к первому запросу
2552 const firstQuery = queries[0].trim();
2553 const searchUrl = `https://www.ozon.ru/search/?text=${encodeURIComponent(firstQuery)}&from_global=true`;
2554 window.location.href = searchUrl;
2555 }
2556
2557 // Продолжаем парсинг после загрузки страницы
2558 async function continueParsingIfActive() {
2559 const isActive = await GM.getValue('ozon_parser_active', 'false');
2560 if (isActive !== 'true') {
2561 return;
2562 }
2563
2564 console.log('Ozon Product Parser: Continuing parsing process');
2565
2566 // Ждем появления таблицы
2567 await waitForTable();
2568
2569 // Получаем текущее состояние
2570 const queriesJson = await GM.getValue('ozon_parser_queries', '[]');
2571 const queries = JSON.parse(queriesJson);
2572 const currentIndex = await GM.getValue('ozon_parser_current_index', 0);
2573 const currentListName = await GM.getValue('ozon_parser_current_list_name', 'Без названия');
2574
2575 // Получаем результаты для текущего списка
2576 const allListResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
2577 const allListResults = JSON.parse(allListResultsJson);
2578
2579 if (!allListResults[currentListName]) {
2580 allListResults[currentListName] = {
2581 queries: {},
2582 createdAt: new Date().toISOString(),
2583 updatedAt: new Date().toISOString()
2584 };
2585 }
2586
2587 if (currentIndex >= queries.length) {
2588 // Парсинг завершен
2589 await GM.setValue('ozon_parser_active', 'false');
2590
2591 // Обновляем дату последнего обновления списка
2592 allListResults[currentListName].updatedAt = new Date().toISOString();
2593 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
2594
2595 console.log('Ozon Product Parser: Parsing completed');
2596 return;
2597 }
2598
2599 const currentQuery = queries[currentIndex].trim();
2600 console.log(`Ozon Product Parser: Processing query ${currentIndex + 1}/${queries.length}: "${currentQuery}"`);
2601
2602 // Парсим данные текущей страницы
2603 const products = await parseProducts();
2604 allListResults[currentListName].queries[currentQuery] = products;
2605
2606 // Анализируем высокие цены
2607 await analyzeHighPrices(currentQuery, products);
2608
2609 // Сохраняем результаты
2610 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
2611 console.log(`Ozon Product Parser: Parsed ${products.length} products for "${currentQuery}" in list "${currentListName}"`);
2612
2613 // Переходим к следующему запросу
2614 const nextIndex = currentIndex + 1;
2615 await GM.setValue('ozon_parser_current_index', nextIndex);
2616
2617 if (nextIndex < queries.length) {
2618 // Есть еще запросы - переходим к следующему
2619 const nextQuery = queries[nextIndex].trim();
2620 const searchUrl = `https://www.ozon.ru/search/?text=${encodeURIComponent(nextQuery)}&from_global=true`;
2621 setTimeout(() => {
2622 window.location.href = searchUrl;
2623 }, 2000); // Небольшая задержка между запросами
2624 } else {
2625 // Все запросы обработаны
2626 await GM.setValue('ozon_parser_active', 'false');
2627
2628 // Обновляем дату последнего обновления списка
2629 allListResults[currentListName].updatedAt = new Date().toISOString();
2630 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
2631
2632 console.log('Ozon Product Parser: All queries processed');
2633
2634 // Показываем результаты
2635 setTimeout(() => {
2636 showResultsModal();
2637 }, 1000);
2638 }
2639 }
2640
2641 // Продолжаем расчет скидки после загрузки страницы
2642 async function continueDiscountCalculationIfActive() {
2643 const shouldCalculate = await GM.getValue('ozon_parser_calculate_discount', 'false');
2644 if (shouldCalculate === 'true') {
2645 await GM.setValue('ozon_parser_calculate_discount', 'false');
2646 console.log('Ozon Product Parser: Continuing discount calculation');
2647 await extractDiscountFromPage();
2648 }
2649 }
2650
2651 // Ждем появления таблицы
2652 function waitForTable() {
2653 return new Promise((resolve) => {
2654 const checkTable = () => {
2655 const table = document.querySelector('#mpstat-ozone-search-result table tbody');
2656 if (table && table.querySelectorAll('tr').length > 0) {
2657 console.log('Ozon Product Parser: Table found');
2658 // Дополнительная задержка для полной загрузки данных
2659 setTimeout(resolve, 5000);
2660 } else {
2661 setTimeout(checkTable, 1000);
2662 }
2663 };
2664 checkTable();
2665 });
2666 }
2667
2668 // Прокручиваем страницу для подгрузки товаров
2669 async function scrollToLoadProducts(targetCount = 20) {
2670 console.log(`Ozon Product Parser: Scrolling to load ${targetCount} products`);
2671
2672 const table = document.querySelector('#mpstat-ozone-search-result table');
2673 if (!table) {
2674 console.error('Ozon Product Parser: Table not found for scrolling');
2675 return;
2676 }
2677
2678 let previousRowCount = 0;
2679 let attempts = 0;
2680 const maxAttempts = 10;
2681
2682 while (attempts < maxAttempts) {
2683 const rows = table.querySelectorAll('tbody tr');
2684 const currentRowCount = rows.length;
2685
2686 console.log(`Ozon Product Parser: Current row count: ${currentRowCount}, target: ${targetCount}`);
2687
2688 if (currentRowCount >= targetCount) {
2689 console.log(`Ozon Product Parser: Loaded ${currentRowCount} products`);
2690 break;
2691 }
2692
2693 // Если количество строк не изменилось, значит больше товаров нет
2694 if (currentRowCount === previousRowCount && attempts > 2) {
2695 console.log(`Ozon Product Parser: No more products to load (${currentRowCount} total)`);
2696 break;
2697 }
2698
2699 previousRowCount = currentRowCount;
2700
2701 // Прокручиваем к последней строке таблицы
2702 const lastRow = rows[rows.length - 1];
2703 if (lastRow) {
2704 lastRow.scrollIntoView({ behavior: 'smooth', block: 'end' });
2705 }
2706
2707 // Также прокручиваем окно вниз
2708 window.scrollBy(0, 500);
2709
2710 // Ждем подгрузки новых товаров
2711 await new Promise(resolve => setTimeout(resolve, 2000));
2712 attempts++;
2713 }
2714
2715 console.log(`Ozon Product Parser: Scrolling completed after ${attempts} attempts`);
2716 }
2717
2718 // Парсим товары из таблицы
2719 async function parseProducts() {
2720 const table = document.querySelector('#mpstat-ozone-search-result table');
2721 if (!table) {
2722 console.error('Ozon Product Parser: Table not found');
2723 return [];
2724 }
2725
2726 // Прокручиваем страницу для подгрузки товаров
2727 await scrollToLoadProducts(20);
2728
2729 const rows = table.querySelectorAll('tbody tr');
2730 const products = [];
2731 const maxProducts = Math.min(20, rows.length);
2732
2733 // Получаем названия товаров из карточек на странице
2734 const productLinks = document.querySelectorAll('a[href*="/product/"]');
2735 const productNames = new Map();
2736
2737 console.log(`Ozon Product Parser: Found ${productLinks.length} product links`);
2738
2739 productLinks.forEach(link => {
2740 const href = link.getAttribute('href');
2741 const skuMatch = href.match(/\/product\/[^/]+-(\d+)/);
2742 if (skuMatch) {
2743 const sku = skuMatch[1];
2744 // Ищем название в родительском элементе
2745 const parent = link.closest('.tile-root') || link.closest('[data-index]');
2746 if (parent) {
2747 const nameElement = parent.querySelector('.tsBody500Medium');
2748 if (nameElement && nameElement.textContent.trim().length > 10) {
2749 productNames.set(sku, nameElement.textContent.trim());
2750 }
2751 }
2752 }
2753 });
2754
2755 console.log(`Ozon Product Parser: Extracted ${productNames.size} product names`);
2756
2757 // Функция для извлечения количества единиц из названия
2758 function extractQuantity(name) {
2759 if (!name) return null;
2760
2761 // Ищем паттерны: "120 капсул", "60 таблеток", "180шт", "90 шт"
2762 const patterns = [
2763 /(\d+)\s*(?:капсул|капс|caps)/i,
2764 /(\d+)\s*(?:таблеток|табл|tablets|tabs)/i,
2765 /(\d+)\s*(?:штук|шт|pcs|pieces)/i,
2766 /(\d+)\s*(?:порций|servings)/i
2767 ];
2768
2769 for (const pattern of patterns) {
2770 const match = name.match(pattern);
2771 if (match) {
2772 const quantity = parseInt(match[1]);
2773 if (quantity > 0 && quantity <= 1000) { // Разумные пределы
2774 return quantity;
2775 }
2776 }
2777 }
2778
2779 return null;
2780 }
2781
2782 for (let i = 0; i < maxProducts; i++) {
2783 const row = rows[i];
2784 const cells = row.querySelectorAll('td');
2785 if (cells.length < 8) continue;
2786
2787 const position = cells[0]?.textContent.trim() || '';
2788 const sku = cells[2]?.textContent.trim() || '';
2789 const brand = cells[3]?.textContent.trim() || '';
2790 const priceText = cells[4]?.textContent.trim() || '';
2791 const revenueText = cells[6]?.textContent.trim() || '';
2792 const ordersText = cells[7]?.textContent.trim() || '';
2793
2794 // Получаем название товара из карточки
2795 const name = productNames.get(sku) || '';
2796
2797 // Извлекаем количество единиц
2798 const quantity = extractQuantity(name);
2799
2800 // Проверяем, есть ли товар от GLS или Skinphoria
2801 const isTargetBrand = brand.includes('GLS Pharmaceuticals') || brand.includes('Skinphoria');
2802
2803 // Парсим числовые значения
2804 const price = parseFloat(priceText.replace(/[^\d]/g, '')) || 0;
2805 const revenue = parseFloat(revenueText.replace(/[^\d]/g, '')) || 0;
2806 const orders = parseInt(ordersText.replace(/[^\d]/g, '')) || 0;
2807
2808 // Рассчитываем цену за единицу
2809 const pricePerUnit = quantity && price > 0 ? price / quantity : null;
2810
2811 products.push({
2812 position: parseInt(position) || (i + 1),
2813 sku,
2814 name,
2815 brand,
2816 price,
2817 revenue,
2818 orders,
2819 isTargetBrand,
2820 quantity,
2821 pricePerUnit
2822 });
2823 }
2824
2825 // Сортируем по убыванию выручки
2826 products.sort((a, b) => b.revenue - a.revenue);
2827 console.log(`Ozon Product Parser: Parsed ${products.length} products, ${products.filter(p => p.isTargetBrand).length} from target brands`);
2828 return products;
2829 }
2830
2831 // Анализируем высокие цены после парсинга
2832 async function analyzeHighPrices(query, products) {
2833 console.log(`Ozon Product Parser: Analyzing high prices for query "${query}"`);
2834
2835 // Получаем топ-5 конкурентов по выручке
2836 const competitors = products.filter(p => !p.isTargetBrand && p.price > 0);
2837 const top5Competitors = competitors.slice(0, 5);
2838
2839 if (top5Competitors.length === 0) {
2840 console.log('Ozon Product Parser: No competitors found for high price analysis');
2841 return;
2842 }
2843
2844 // Находим минимальную цену среди топ-5 конкурентов
2845 const minCompetitorPrice = Math.min(...top5Competitors.map(p => p.price));
2846 console.log(`Ozon Product Parser: Min competitor price in top-5: ${minCompetitorPrice}₽`);
2847
2848 // Проверяем наши товары
2849 const ourProducts = products.filter(p => p.isTargetBrand);
2850 const highPriceProducts = [];
2851
2852 for (const product of ourProducts) {
2853 if (product.price > 0) {
2854 const priceDiff = ((product.price - minCompetitorPrice) / minCompetitorPrice) * 100;
2855
2856 if (priceDiff > 10) {
2857 console.log(`Ozon Product Parser: High price detected - SKU ${product.sku}: ${product.price}₽ vs ${minCompetitorPrice}₽ (+${priceDiff.toFixed(1)}%)`);
2858
2859 highPriceProducts.push({
2860 sku: product.sku,
2861 name: product.name,
2862 ourPrice: product.price,
2863 competitorPrice: minCompetitorPrice,
2864 query: query
2865 });
2866 }
2867 }
2868 }
2869
2870 if (highPriceProducts.length > 0) {
2871 // Добавляем в общий список высоких цен
2872 const existingDataJson = await GM.getValue('ozon_parser_high_price', '[]');
2873 const existingData = JSON.parse(existingDataJson);
2874
2875 // Удаляем дубликаты по SKU + query
2876 const existingKeys = new Set(existingData.map(item => `${item.sku}_${item.query}`));
2877 const newProducts = highPriceProducts.filter(item => !existingKeys.has(`${item.sku}_${item.query}`));
2878
2879 if (newProducts.length > 0) {
2880 const updatedData = [...existingData, ...newProducts];
2881 await GM.setValue('ozon_parser_high_price', JSON.stringify(updatedData));
2882 console.log(`Ozon Product Parser: Added ${newProducts.length} products to high price list`);
2883
2884 // Обновляем счетчик
2885 await updateHighPriceCounter();
2886 }
2887 }
2888 }
2889
2890 // Анализируем данные для запроса
2891 async function analyzeProducts(products) {
2892 console.log('Ozon Product Parser: Starting analysis for', products.length, 'products');
2893
2894 if (!products || products.length === 0) {
2895 console.error('Ozon Product Parser: No products to analyze');
2896 return null;
2897 }
2898
2899 try {
2900 // Получаем данные о расходах и скидке Ozon
2901 const costsJson = await GM.getValue('ozon_parser_costs', '{}');
2902 const costs = JSON.parse(costsJson);
2903 const ozonDiscount = await GM.getValue('ozon_parser_discount', 50);
2904 console.log(`Ozon Product Parser: Loaded costs for ${Object.keys(costs).length} SKUs, Ozon discount: ${ozonDiscount}%`);
2905
2906 // Общая выручка и заказы
2907 const totalRevenue = products.reduce((sum, p) => sum + p.revenue, 0);
2908 const totalOrders = products.reduce((sum, p) => sum + p.orders, 0);
2909
2910 // Топ-5 товаров по выручке
2911 const topProducts = products.slice(0, 5).map(p => ({
2912 name: p.name,
2913 brand: p.brand,
2914 price: p.price,
2915 revenue: p.revenue,
2916 orders: p.orders,
2917 position: p.position,
2918 revenueShare: ((p.revenue / totalRevenue) * 100).toFixed(1)
2919 }));
2920
2921 // Ценовые сегменты
2922 const prices = products.map(p => p.price).filter(p => p > 0);
2923 const minPrice = Math.min(...prices);
2924 const maxPrice = Math.max(...prices);
2925 const priceRange = maxPrice - minPrice;
2926 const segmentSize = priceRange / 4;
2927
2928 const priceSegments = [
2929 { min: minPrice, max: minPrice + segmentSize, name: 'Низкий' },
2930 { min: minPrice + segmentSize, max: minPrice + segmentSize * 2, name: 'Средний-' },
2931 { min: minPrice + segmentSize * 2, max: minPrice + segmentSize * 3, name: 'Средний+' },
2932 { min: minPrice + segmentSize * 3, max: maxPrice, name: 'Высокий' }
2933 ];
2934
2935 const segments = priceSegments.map(segment => {
2936 const segmentProducts = products.filter(p =>
2937 p.price >= segment.min && p.price <= segment.max
2938 );
2939 const segmentRevenue = segmentProducts.reduce((sum, p) => sum + p.revenue, 0);
2940 const segmentOrders = segmentProducts.reduce((sum, p) => sum + p.orders, 0);
2941
2942 return {
2943 name: segment.name,
2944 priceRange: `${Math.round(segment.min)} - ${Math.round(segment.max)} ₽`,
2945 count: segmentProducts.length,
2946 revenue: segmentRevenue,
2947 orders: segmentOrders,
2948 revenueShare: ((segmentRevenue / totalRevenue) * 100).toFixed(1),
2949 avgPrice: segmentProducts.length > 0
2950 ? Math.round(segmentProducts.reduce((sum, p) => sum + p.price, 0) / segmentProducts.length)
2951 : 0
2952 };
2953 }).filter(s => s.count > 0);
2954
2955 // Расчет эластичности запроса
2956 const competitors = products.filter(p => !p.isTargetBrand);
2957 let elasticity = null;
2958 let elasticityInterpretation = '';
2959
2960 if (competitors.length >= 5) {
2961 const logPrices = competitors.map(p => Math.log(p.price + 1));
2962 const logOrders = competitors.map(p => Math.log(p.orders + 1));
2963
2964 function simpleRegression(x, y) {
2965 const n = x.length;
2966 const sumX = x.reduce((a, b) => a + b, 0);
2967 const sumY = y.reduce((a, b) => a + b, 0);
2968 const sumXY = x.reduce((a, b, i) => a + b * y[i], 0);
2969 const sumX2 = x.reduce((a, b) => a + b * b, 0);
2970 const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
2971 return slope;
2972 }
2973
2974 const rawElasticity = simpleRegression(logPrices, logOrders);
2975 const priorElasticity = -1.2;
2976 const priorWeight = 0.2;
2977 elasticity = rawElasticity * (1 - priorWeight) + priorElasticity * priorWeight;
2978
2979 console.log(`Ozon Product Parser: Raw elasticity: ${rawElasticity.toFixed(2)}, Bayesian adjusted: ${elasticity.toFixed(2)}`);
2980
2981 if (elasticity < -1.5) {
2982 elasticityInterpretation = 'Высокая эластичность - спрос очень чувствителен к цене. Снижение цены сильно увеличит продажи.';
2983 } else if (elasticity < -0.8) {
2984 elasticityInterpretation = 'Средняя эластичность - спрос умеренно реагирует на изменение цены.';
2985 } else if (elasticity < 0) {
2986 elasticityInterpretation = 'Низкая эластичность - спрос слабо зависит от цены. Можно повышать цену.';
2987 } else {
2988 elasticityInterpretation = 'Аномальная эластичность - возможно недостаточно данных для анализа.';
2989 }
2990 }
2991
2992 // Функция для расчета прогноза
2993 function calculateForecast(newPrice, currentPrice, currentOrders, currentRevenue, productCosts) {
2994 const priceChange = (newPrice - currentPrice) / currentPrice;
2995 const usedElasticity = elasticity !== null ? elasticity : -1.2;
2996 const ordersChange = usedElasticity * priceChange;
2997
2998 const forecastOrders = Math.round(currentOrders * (1 + ordersChange));
2999 const forecastRevenue = Math.round(forecastOrders * newPrice);
3000
3001 let forecastProfit = null;
3002 let currentProfit = null;
3003 let profitChange = null;
3004
3005 if (productCosts) {
3006 const basePrice = newPrice / (1 - ozonDiscount / 100);
3007 const sellerRevenue = basePrice * (1 - productCosts.commission) * forecastOrders;
3008 const expenses = (productCosts.cost + productCosts.delivery) * forecastOrders;
3009 forecastProfit = sellerRevenue - expenses;
3010
3011 const currentBasePrice = currentPrice / (1 - ozonDiscount / 100);
3012 const currentSellerRevenue = currentBasePrice * (1 - productCosts.commission) * currentOrders;
3013 const currentExpenses = (productCosts.cost + productCosts.delivery) * currentOrders;
3014 currentProfit = currentSellerRevenue - currentExpenses;
3015
3016 profitChange = currentProfit > 0 ? Math.round(((forecastProfit - currentProfit) / currentProfit) * 100) : 0;
3017 }
3018
3019 return {
3020 orders: forecastOrders,
3021 ordersChange: Math.round(ordersChange * 100),
3022 revenue: forecastRevenue,
3023 revenueChange: Math.round(((forecastRevenue - currentRevenue) / currentRevenue) * 100),
3024 profit: forecastProfit,
3025 profitChange: profitChange
3026 };
3027 }
3028
3029 // Функция для расчета рекомендованной цены для конкретного товара
3030 function calculatePriceForProduct(targetProduct, allProducts) {
3031 console.log(`Calculating price for SKU ${targetProduct.sku}`);
3032
3033 const productCosts = costs[targetProduct.sku];
3034
3035 // Находим конкурентов
3036 const positionRange = 5;
3037 const nearbyProducts = allProducts.filter(p =>
3038 !p.isTargetBrand &&
3039 Math.abs(p.position - targetProduct.position) <= positionRange &&
3040 p.price > 0 && p.orders > 0
3041 );
3042
3043 const referenceProducts = nearbyProducts.length >= 3
3044 ? nearbyProducts
3045 : allProducts.filter(p => !p.isTargetBrand && p.price > 0 && p.orders > 0).slice(0, 10);
3046
3047 if (referenceProducts.length === 0) {
3048 return null;
3049 }
3050
3051 // Рассчитываем базовую оптимальную цену на основе конкурентов
3052 let weightedSum = 0;
3053 let weightSum = 0;
3054 referenceProducts.forEach(p => {
3055 const weight = p.revenue;
3056 weightedSum += p.price * weight;
3057 weightSum += weight;
3058 });
3059 const weightedPrice = weightSum > 0 ? weightedSum / weightSum : 0;
3060
3061 const productsWithConversion = referenceProducts.map(p => ({
3062 ...p,
3063 conversion: p.orders / p.price
3064 })).sort((a, b) => b.conversion - a.conversion);
3065 const topConversionPrice = productsWithConversion.length > 0
3066 ? productsWithConversion.slice(0, 3).reduce((sum, p) => sum + p.price, 0) / 3
3067 : 0;
3068
3069 const top10Prices = referenceProducts.slice(0, 10).map(p => p.price).sort((a, b) => a - b);
3070 const medianPrice = top10Prices.length > 0
3071 ? top10Prices[Math.floor(top10Prices.length / 2)]
3072 : 0;
3073
3074 const marketOptimalPrice = (weightedPrice * 0.4 + topConversionPrice * 0.3 + medianPrice * 0.3);
3075
3076 // Рассчитываем RPI
3077 const competitorPrices = allProducts.filter(p => !p.isTargetBrand && p.price > 0);
3078 let avgCompetitorPrice = 0;
3079 if (competitorPrices.length > 0) {
3080 let weightedPriceSum = 0;
3081 let weightSum = 0;
3082 competitorPrices.forEach(comp => {
3083 const weight = comp.revenue;
3084 weightedPriceSum += comp.price * weight;
3085 weightSum += weight;
3086 });
3087 avgCompetitorPrice = weightSum > 0 ? weightedPriceSum / weightSum : targetProduct.price;
3088 }
3089
3090 const rpi = avgCompetitorPrice > 0 ? (targetProduct.price / avgCompetitorPrice) * 100 : 100;
3091
3092 // Используем эластичность для оптимизации прибыли
3093 const usedElasticity = elasticity !== null ? elasticity : -1.2;
3094
3095 // Функция для расчета ожидаемой прибыли при заданной цене
3096 function calculateExpectedProfit(price) {
3097 if (!productCosts) return null;
3098
3099 const priceChange = (price - targetProduct.price) / targetProduct.price;
3100 const ordersChange = usedElasticity * priceChange;
3101 const expectedOrders = Math.max(1, targetProduct.orders * (1 + ordersChange));
3102
3103 const basePrice = price / (1 - ozonDiscount / 100);
3104 const sellerRevenue = basePrice * (1 - productCosts.commission) * expectedOrders;
3105 const expenses = (productCosts.cost + productCosts.delivery) * expectedOrders;
3106
3107 return sellerRevenue - expenses;
3108 }
3109
3110 // Рассчитываем 4 варианта цен с учетом эластичности и максимизации прибыли
3111 // Захват рынка: ищем оптимальную цену ниже текущей для РОСТА прибыли
3112 // Цена ОБЯЗАТЕЛЬНО ниже текущей, но прибыль должна РАСТИ за счет увеличения объема
3113 let marketCapturePrice = targetProduct.price;
3114 if (productCosts) {
3115 const currentProfit = calculateExpectedProfit(targetProduct.price);
3116 let bestPrice = targetProduct.price;
3117 let maxProfit = currentProfit;
3118
3119 // Ищем оптимум между -30% и -1% от текущей цены
3120 for (let multiplier = 0.70; multiplier < 0.99; multiplier += 0.01) {
3121 const testPrice = targetProduct.price * multiplier;
3122 const testProfit = calculateExpectedProfit(testPrice);
3123
3124 // Выбираем цену с максимальной прибылью (больше текущей)
3125 if (testProfit > maxProfit) {
3126 maxProfit = testProfit;
3127 bestPrice = testPrice;
3128 }
3129 }
3130
3131 // Если нашли цену с большей прибылью - используем её
3132 if (maxProfit > currentProfit) {
3133 marketCapturePrice = Math.round(bestPrice);
3134 } else {
3135 // Если не нашли - используем безопасное снижение на 5%
3136 marketCapturePrice = Math.round(targetProduct.price * 0.95);
3137 }
3138 } else {
3139 // Если нет данных о расходах, снижаем на 5%
3140 marketCapturePrice = Math.round(targetProduct.price * 0.95);
3141 }
3142
3143 // Агрессивная: ищем цену для максимизации прибыли с небольшим снижением
3144 // Допускаем падение прибыли до -2%, но стремимся к росту
3145 let aggressivePrice = targetProduct.price;
3146 if (productCosts) {
3147 const currentProfit = calculateExpectedProfit(targetProduct.price);
3148 let maxProfit = currentProfit;
3149 const minAcceptableProfit = currentProfit * 0.98; // Допускаем падение до -2%
3150
3151 // Ищем оптимум между -10% и +5% от текущей цены
3152 for (let multiplier = 0.90; multiplier <= 1.05; multiplier += 0.01) {
3153 const testPrice = targetProduct.price * multiplier;
3154 const testProfit = calculateExpectedProfit(testPrice);
3155
3156 // Выбираем цену с максимальной прибылью, но не ниже минимально допустимой
3157 if (testProfit >= minAcceptableProfit && testProfit > maxProfit) {
3158 maxProfit = testProfit;
3159 aggressivePrice = testPrice;
3160 }
3161 }
3162 aggressivePrice = Math.round(aggressivePrice);
3163 } else {
3164 // Если нет данных о расходах, умеренное снижение
3165 aggressivePrice = Math.round(targetProduct.price * 0.95);
3166 }
3167
3168 // Оптимальная: цена для максимизации прибыли (выше текущей)
3169 // Ищем оптимум между текущей ценой и +30%
3170 let optimalPrice = targetProduct.price;
3171 if (productCosts) {
3172 let maxProfit = calculateExpectedProfit(targetProduct.price);
3173 for (let multiplier = 1.05; multiplier <= 1.30; multiplier += 0.01) {
3174 const testPrice = targetProduct.price * multiplier;
3175 const testProfit = calculateExpectedProfit(testPrice);
3176 if (testProfit > maxProfit) {
3177 maxProfit = testProfit;
3178 optimalPrice = testPrice;
3179 }
3180 }
3181 optimalPrice = Math.round(optimalPrice);
3182 } else {
3183 // Если нет данных о расходах, используем рыночную цену
3184 optimalPrice = Math.round(Math.max(targetProduct.price * 1.10, marketOptimalPrice));
3185 }
3186
3187 // Рассчитываем базовые цены (цены поручения)
3188 const currentBasePrice = targetProduct.price / (1 - ozonDiscount / 100);
3189 const marketCaptureBasePrice = marketCapturePrice / (1 - ozonDiscount / 100);
3190 const aggressiveBasePrice = aggressivePrice / (1 - ozonDiscount / 100);
3191 const optimalBasePrice = optimalPrice / (1 - ozonDiscount / 100);
3192
3193 return {
3194 marketCapture: marketCapturePrice,
3195 aggressive: aggressivePrice,
3196 optimal: optimalPrice,
3197 currentPrice: targetProduct.price,
3198 currentBasePrice: Math.round(currentBasePrice),
3199 marketCaptureBasePrice: Math.round(marketCaptureBasePrice),
3200 aggressiveBasePrice: Math.round(aggressiveBasePrice),
3201 optimalBasePrice: Math.round(optimalBasePrice),
3202 currentPosition: targetProduct.position,
3203 currentProfit: null,
3204 rpi: rpi.toFixed(1),
3205 priceChange: {
3206 marketCapture: Math.round(((marketCapturePrice - targetProduct.price) / targetProduct.price * 100)),
3207 aggressive: Math.round(((aggressivePrice - targetProduct.price) / targetProduct.price * 100)),
3208 optimal: Math.round(((optimalPrice - targetProduct.price) / targetProduct.price * 100))
3209 },
3210 forecast: {
3211 marketCapture: calculateForecast(marketCapturePrice, targetProduct.price, targetProduct.orders, targetProduct.revenue, productCosts),
3212 aggressive: calculateForecast(aggressivePrice, targetProduct.price, targetProduct.orders, targetProduct.revenue, productCosts),
3213 optimal: calculateForecast(optimalPrice, targetProduct.price, targetProduct.orders, targetProduct.revenue, productCosts)
3214 }
3215 };
3216 }
3217
3218 // Общие рекомендации
3219 let weightedSum = 0;
3220 let weightSum = 0;
3221 products.forEach(p => {
3222 const positionBonus = p.position <= 5 ? 2 : 1;
3223 const weight = p.revenue * positionBonus;
3224 weightedSum += p.price * weight;
3225 weightSum += weight;
3226 });
3227 const weightedPrice = weightSum > 0 ? weightedSum / weightSum : 0;
3228
3229 const productsWithConversion = products.map(p => ({
3230 ...p,
3231 conversion: p.orders / p.price
3232 })).sort((a, b) => b.conversion - a.conversion);
3233 const topConversionPrice = productsWithConversion.length > 0
3234 ? productsWithConversion.slice(0, 3).reduce((sum, p) => sum + p.price, 0) / 3
3235 : 0;
3236
3237 const top10Prices = products.slice(0, 10).map(p => p.price).sort((a, b) => a - b);
3238 const medianPrice = top10Prices.length > 0
3239 ? top10Prices[Math.floor(top10Prices.length / 2)]
3240 : 0;
3241
3242 const baseOptimalPrice = (weightedPrice * 0.4 + topConversionPrice * 0.3 + medianPrice * 0.3);
3243
3244 const recommendedPrices = {
3245 marketCapture: {
3246 price: Math.round(baseOptimalPrice * 0.70),
3247 strategy: 'Захват рынка',
3248 description: 'Оптимальная цена ниже текущей для максимизации прибыли'
3249 },
3250 aggressive: {
3251 price: Math.round(baseOptimalPrice * 0.85),
3252 strategy: 'Агрессивная',
3253 description: 'Низкая цена для максимальных продаж и быстрого роста позиций'
3254 },
3255 optimal: {
3256 price: Math.round(baseOptimalPrice),
3257 strategy: 'Оптимальная',
3258 description: 'Баланс между прибылью и объемом продаж'
3259 }
3260 };
3261
3262 // Раздельные рекомендации для целевых брендов
3263 const glsProducts = products.filter(p => p.brand.includes('GLS Pharmaceuticals'));
3264 const skinphoriaProducts = products.filter(p => p.brand.includes('Skinphoria'));
3265
3266 const brandRecommendations = {};
3267
3268 // Рекомендации для GLS Pharmaceuticals
3269 if (glsProducts.length > 0) {
3270 const glsAvgPosition = glsProducts.reduce((sum, p) => sum + p.position, 0) / glsProducts.length;
3271 const glsAvgPrice = glsProducts.reduce((sum, p) => sum + p.price, 0) / glsProducts.length;
3272
3273 brandRecommendations.gls = {
3274 brand: 'GLS Pharmaceuticals',
3275 currentProducts: glsProducts.map(p => {
3276 const priceRec = calculatePriceForProduct(p, products);
3277 return {
3278 sku: p.sku,
3279 name: p.name,
3280 position: p.position,
3281 price: p.price,
3282 revenue: p.revenue,
3283 orders: p.orders,
3284 recommendations: priceRec
3285 };
3286 }),
3287 avgPosition: Math.round(glsAvgPosition),
3288 avgPrice: Math.round(glsAvgPrice)
3289 };
3290 }
3291
3292 // Рекомендации для Skinphoria
3293 if (skinphoriaProducts.length > 0) {
3294 const skinphoriaAvgPosition = skinphoriaProducts.reduce((sum, p) => sum + p.position, 0) / skinphoriaProducts.length;
3295 const skinphoriaAvgPrice = skinphoriaProducts.reduce((sum, p) => sum + p.price, 0) / skinphoriaProducts.length;
3296
3297 brandRecommendations.skinphoria = {
3298 brand: 'Skinphoria',
3299 currentProducts: skinphoriaProducts.map(p => {
3300 const priceRec = calculatePriceForProduct(p, products);
3301 return {
3302 sku: p.sku,
3303 name: p.name,
3304 position: p.position,
3305 price: p.price,
3306 revenue: p.revenue,
3307 orders: p.orders,
3308 recommendations: priceRec
3309 };
3310 }),
3311 avgPosition: Math.round(skinphoriaAvgPosition),
3312 avgPrice: Math.round(skinphoriaAvgPrice)
3313 };
3314 }
3315
3316 console.log('Ozon Product Parser: Analysis completed successfully');
3317
3318 return {
3319 totalRevenue,
3320 totalOrders,
3321 avgPrice: Math.round(totalRevenue / totalOrders),
3322 topProducts,
3323 priceSegments: segments,
3324 elasticity: elasticity !== null ? {
3325 value: elasticity.toFixed(2),
3326 interpretation: elasticityInterpretation
3327 } : null,
3328 recommendedPrices,
3329 brandRecommendations
3330 };
3331 } catch (error) {
3332 console.error('Ozon Product Parser: Error in analyzeProducts:', error);
3333 return null;
3334 }
3335 }
3336
3337 // Показываем результаты
3338 async function showResultsModal() {
3339 console.log('Ozon Product Parser: Opening results modal');
3340
3341 try {
3342 // Получаем результаты по спискам
3343 const allListResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
3344 const allListResults = JSON.parse(allListResultsJson);
3345 const listNames = Object.keys(allListResults);
3346
3347 if (listNames.length === 0) {
3348 alert('Нет сохраненных результатов. Сначала выполните парсинг.');
3349 return;
3350 }
3351
3352 const modal = document.createElement('div');
3353 modal.className = 'ozon-parser-modal';
3354
3355 const content = document.createElement('div');
3356 content.className = 'ozon-parser-modal-content';
3357 content.style.maxWidth = '95vw';
3358 content.style.width = '95vw';
3359
3360 content.innerHTML = `
3361 <div class="ozon-parser-modal-header">Результаты парсинга</div>
3362 <div class="ozon-parser-modal-body">
3363 <div style="margin-bottom: 20px;">
3364 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Выберите список:</label>
3365 <div style="display: flex; gap: 10px; align-items: center;">
3366 <select class="ozon-parser-search" id="list-selector" style="flex: 1; margin-bottom: 0;">
3367 ${listNames.map(listName => {
3368 const list = allListResults[listName];
3369 const date = new Date(list.updatedAt).toLocaleDateString('ru-RU');
3370 const queriesCount = Object.keys(list.queries).length;
3371 return `<option value="${listName}">${listName} (${queriesCount} запросов, обновлен: ${date})</option>`;
3372 }).join('')}
3373 </select>
3374 <button class="ozon-parser-btn" id="delete-list-btn" style="background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);">Удалить список</button>
3375 </div>
3376 </div>
3377 <div style="margin-bottom: 15px;">
3378 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Сортировка запросов:</label>
3379 <select class="ozon-parser-search" id="query-sort-selector" style="margin-bottom: 0;">
3380 <option value="revenue">По нашей выручке (убывание)</option>
3381 <option value="alphabet">По алфавиту (А-Я)</option>
3382 </select>
3383 </div>
3384 <input type="text" class="ozon-parser-search" id="query-search" placeholder="Поиск по запросам">
3385 <input type="text" class="ozon-parser-search" id="sku-search" placeholder="Поиск по SKU">
3386 <div class="ozon-parser-tabs" id="query-tabs"></div>
3387 <div id="results-container"></div>
3388 </div>
3389 <div class="ozon-parser-modal-footer">
3390 <button class="ozon-parser-btn" id="close-results-btn">Закрыть</button>
3391 </div>
3392 `;
3393
3394 modal.appendChild(content);
3395 document.body.appendChild(modal);
3396
3397 // Текущий выбранный список
3398 let currentListName = listNames[0];
3399 let currentResults = allListResults[currentListName].queries;
3400 let currentSortMode = 'revenue';
3401
3402 // Функция для сортировки запросов
3403 function sortQueries(queries, results, sortMode) {
3404 if (sortMode === 'alphabet') {
3405 return queries.sort((a, b) => a.localeCompare(b, 'ru'));
3406 } else {
3407 // Сортировка по нашей выручке
3408 return queries.sort((a, b) => {
3409 const productsA = results[a];
3410 const productsB = results[b];
3411
3412 // Находим наши товары и их выручку
3413 const ourProductsA = productsA.filter(p => p.isTargetBrand);
3414 const ourProductsB = productsB.filter(p => p.isTargetBrand);
3415
3416 const revenueA = ourProductsA.reduce((sum, p) => sum + p.revenue, 0);
3417 const revenueB = ourProductsB.reduce((sum, p) => sum + p.revenue, 0);
3418
3419 return revenueB - revenueA; // По убыванию
3420 });
3421 }
3422 }
3423
3424 // Функция для обновления отображения
3425 async function updateDisplay() {
3426 const queries = Object.keys(currentResults);
3427 const sortedQueries = sortQueries(queries, currentResults, currentSortMode);
3428
3429 // Создаем вкладки
3430 createTabs(sortedQueries, currentResults, content);
3431
3432 // Показываем результаты первого запроса
3433 if (sortedQueries.length > 0) {
3434 await displayResults(sortedQueries[0], currentResults[sortedQueries[0]], content);
3435 }
3436 }
3437
3438 // Обработчик выбора списка
3439 const listSelector = content.querySelector('#list-selector');
3440 listSelector.addEventListener('change', () => {
3441 currentListName = listSelector.value;
3442 currentResults = allListResults[currentListName].queries;
3443 updateDisplay();
3444 });
3445
3446 // Обработчик изменения сортировки
3447 const querySortSelector = content.querySelector('#query-sort-selector');
3448 querySortSelector.addEventListener('change', () => {
3449 currentSortMode = querySortSelector.value;
3450 updateDisplay();
3451 });
3452
3453 // Обработчик удаления списка
3454 const deleteListBtn = content.querySelector('#delete-list-btn');
3455 deleteListBtn.addEventListener('click', async () => {
3456 if (!confirm(`Вы уверены, что хотите удалить список "${currentListName}"?`)) {
3457 return;
3458 }
3459
3460 // Удаляем список
3461 delete allListResults[currentListName];
3462 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
3463
3464 // Также удаляем из сохраненных списков
3465 const savedListsJson = await GM.getValue('ozon_parser_saved_lists', '{}');
3466 const savedLists = JSON.parse(savedListsJson);
3467 delete savedLists[currentListName];
3468 await GM.setValue('ozon_parser_saved_lists', JSON.stringify(savedLists));
3469
3470 console.log(`Ozon Product Parser: List "${currentListName}" deleted`);
3471
3472 // Обновляем UI
3473 const remainingLists = Object.keys(allListResults);
3474 if (remainingLists.length === 0) {
3475 alert('Все списки удалены');
3476 modal.remove();
3477 return;
3478 }
3479
3480 // Переключаемся на первый оставшийся список
3481 currentListName = remainingLists[0];
3482 currentResults = allListResults[currentListName].queries;
3483
3484 // Обновляем селектор
3485 listSelector.innerHTML = remainingLists.map(listName => {
3486 const list = allListResults[listName];
3487 const date = new Date(list.updatedAt).toLocaleDateString('ru-RU');
3488 const queriesCount = Object.keys(list.queries).length;
3489 return `<option value="${listName}">${listName} (${queriesCount} запросов, обновлен: ${date})</option>`;
3490 }).join('');
3491
3492 updateDisplay();
3493 });
3494
3495 // Обработчик закрытия модального окна
3496 const closeBtn = content.querySelector('#close-results-btn');
3497 closeBtn.addEventListener('click', () => {
3498 modal.remove();
3499 });
3500
3501 // Обработчик поиска по запросам
3502 const querySearchInput = content.querySelector('#query-search');
3503 querySearchInput.addEventListener('input', debounce(() => {
3504 const searchValue = querySearchInput.value.trim().toLowerCase();
3505 const queries = Object.keys(currentResults);
3506 const sortedQueries = sortQueries(queries, currentResults, currentSortMode);
3507 filterQueriesByQuery(searchValue, sortedQueries, currentResults, content);
3508 }, 300));
3509
3510 // Обработчик поиска по SKU
3511 const skuSearchInput = content.querySelector('#sku-search');
3512 skuSearchInput.addEventListener('input', debounce(() => {
3513 const searchValue = skuSearchInput.value.trim();
3514 const queries = Object.keys(currentResults);
3515 const sortedQueries = sortQueries(queries, currentResults, currentSortMode);
3516 filterQueriesBySKU(searchValue, sortedQueries, currentResults, content);
3517 }, 300));
3518
3519 // Создаем вкладки для запросов
3520 const initialQueries = Object.keys(currentResults);
3521 const sortedInitialQueries = sortQueries(initialQueries, currentResults, currentSortMode);
3522 createTabs(sortedInitialQueries, currentResults, content);
3523
3524 // Показываем результаты первого запроса
3525 await displayResults(sortedInitialQueries[0], currentResults[sortedInitialQueries[0]], content);
3526
3527 // Закрытие по клику на фон
3528 modal.addEventListener('click', (e) => {
3529 if (e.target === modal) {
3530 modal.remove();
3531 }
3532 });
3533
3534 console.log('Ozon Product Parser: Results modal shown');
3535 } catch (error) {
3536 console.error('Ozon Product Parser: Error in showResultsModal:', error);
3537 alert('Ошибка при отображении результатов: ' + error.message);
3538 }
3539 }
3540
3541 // Фильтруем запросы по названию запроса
3542 async function filterQueriesByQuery(queryText, allQueries, results, modalContent) {
3543 if (!queryText) {
3544 createTabs(allQueries, results, modalContent);
3545 await displayResults(allQueries[0], results[allQueries[0]], modalContent);
3546 return;
3547 }
3548
3549 const filteredQueries = allQueries.filter(q =>
3550 q.toLowerCase().includes(queryText)
3551 );
3552
3553 if (filteredQueries.length === 0) {
3554 const container = modalContent.querySelector('#results-container');
3555 container.innerHTML = '<p>Запросы не найдены</p>';
3556 const tabsContainer = modalContent.querySelector('#query-tabs');
3557 tabsContainer.innerHTML = '';
3558 return;
3559 }
3560
3561 createTabs(filteredQueries, results, modalContent);
3562 await displayResults(filteredQueries[0], results[filteredQueries[0]], modalContent);
3563 }
3564
3565 // Фильтруем запросы по SKU
3566 async function filterQueriesBySKU(sku, allQueries, results, modalContent) {
3567 if (!sku) {
3568 createTabs(allQueries, results, modalContent);
3569 await displayResults(allQueries[0], results[allQueries[0]], modalContent);
3570 return;
3571 }
3572
3573 const filteredQueries = allQueries.filter(query => {
3574 const products = results[query];
3575 return products.some(product => product.sku.includes(sku));
3576 });
3577
3578 if (filteredQueries.length === 0) {
3579 const container = modalContent.querySelector('#results-container');
3580 container.innerHTML = '<p>Товары с таким SKU не найдены ни в одном запросе</p>';
3581 const tabsContainer = modalContent.querySelector('#query-tabs');
3582 tabsContainer.innerHTML = '';
3583 return;
3584 }
3585
3586 createTabs(filteredQueries, results, modalContent);
3587 await displayResults(filteredQueries[0], results[filteredQueries[0]], modalContent);
3588 }
3589
3590 // Создаем вкладки для запросов
3591 function createTabs(queries, results, modalContent) {
3592 const tabsContainer = modalContent.querySelector('#query-tabs');
3593 tabsContainer.innerHTML = '';
3594
3595 queries.forEach((query, index) => {
3596 const tab = document.createElement('button');
3597 tab.className = 'ozon-parser-tab' + (index === 0 ? ' active' : '');
3598
3599 // Получаем позицию по выручке
3600 const products = results[query];
3601 const position = getOurRevenuePosition(query, products);
3602
3603 // Формируем текст вкладки
3604 if (position !== null) {
3605 tab.textContent = `${query} (${position} место)`;
3606 } else {
3607 tab.textContent = `${query} (нет наших)`;
3608 }
3609
3610 tab.addEventListener('click', async () => {
3611 tabsContainer.querySelectorAll('.ozon-parser-tab').forEach(t => t.classList.remove('active'));
3612 tab.classList.add('active');
3613 await displayResults(query, results[query], modalContent);
3614 });
3615 tabsContainer.appendChild(tab);
3616 });
3617
3618 // Функция для получения нашей позиции по выручке в запросе
3619 function getOurRevenuePosition(query, products) {
3620 // Сортируем все товары по выручке
3621 const sortedByRevenue = [...products].sort((a, b) => b.revenue - a.revenue);
3622
3623 // Находим первый наш товар
3624 const ourProductIndex = sortedByRevenue.findIndex(p => p.isTargetBrand);
3625
3626 if (ourProductIndex === -1) {
3627 return null; // Наших товаров нет
3628 }
3629
3630 return ourProductIndex + 1; // Позиция (1-based)
3631 }
3632 }
3633
3634 // Отображаем результаты для конкретного запроса
3635 async function displayResults(query, productsData, modalContent) {
3636 console.log('Ozon Product Parser: Displaying results for query:', query);
3637
3638 const container = modalContent.querySelector('#results-container');
3639
3640 try {
3641 let products;
3642
3643 if (Array.isArray(productsData)) {
3644 products = productsData;
3645 } else {
3646 container.innerHTML = '<p>Запросы не найдены</p>';
3647 return;
3648 }
3649
3650 if (!products || products.length === 0) {
3651 container.innerHTML = '<p>Нет данных для этого запроса</p>';
3652 return;
3653 }
3654
3655 // Получаем аналитику
3656 const analytics = await analyzeProducts(products);
3657
3658 if (!analytics) {
3659 container.innerHTML = '<p>Ошибка при анализе данных</p>';
3660 return;
3661 }
3662
3663 // Получаем скидку Ozon для расчета базовой цены
3664 const ozonDiscount = await GM.getValue('ozon_parser_discount', 50);
3665
3666 let tableHTML = `
3667 <table class="ozon-parser-results-table">
3668 <thead>
3669 <tr>
3670 <th>Позиция</th>
3671 <th>SKU</th>
3672 <th>Название</th>
3673 <th>Бренд</th>
3674 <th>Текущая цена</th>
3675 <th>Средняя цена</th>
3676 <th>Выручка</th>
3677 <th>Заказы</th>
3678 </tr>
3679 </thead>
3680 <tbody>
3681 `;
3682
3683 products.forEach(product => {
3684 const rowClass = product.isTargetBrand ? ' class="ozon-parser-highlight"' : '';
3685 const skuLink = `https://www.ozon.ru/product/${product.sku}`;
3686 const basePrice = product.price / (1 - ozonDiscount / 100);
3687 const avgPrice = product.orders > 0 ? product.revenue / product.orders : 0;
3688
3689 tableHTML += `
3690 <tr${rowClass}>
3691 <td>${product.position}</td>
3692 <td><a href="${skuLink}" target="_blank" class="ozon-parser-sku-link">${product.sku}</a></td>
3693 <td>${product.name || '—'}</td>
3694 <td>${product.brand}</td>
3695 <td>
3696 <div style="font-weight: 600;">${product.price.toLocaleString('ru-RU')} ₽</div>
3697 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${Math.round(basePrice).toLocaleString('ru-RU')} ₽</div>
3698 </td>
3699 <td>
3700 <div style="font-weight: 600;">${Math.round(avgPrice).toLocaleString('ru-RU')} ₽</div>
3701 </td>
3702 <td>${product.revenue.toLocaleString('ru-RU')} ₽</td>
3703 <td>${product.orders.toLocaleString('ru-RU')}</td>
3704 </tr>
3705 `;
3706 });
3707
3708 tableHTML += `
3709 </tbody>
3710 </table>
3711 `;
3712
3713 // Добавляем аналитику
3714 tableHTML += `
3715 <div class="ozon-parser-analytics">
3716 <div class="ozon-parser-analytics-header">📊 Аналитика запроса</div>
3717
3718 ${analytics.elasticity ? `
3719 <div class="ozon-parser-analytics-section">
3720 <div class="ozon-parser-analytics-card">
3721 <div class="ozon-parser-analytics-card-title">Эластичность запроса</div>
3722 <div class="ozon-parser-analytics-card-value">${analytics.elasticity.value}</div>
3723 <div style="font-size: 12px; color: #666; margin-top: 8px;">
3724 ${analytics.elasticity.interpretation}
3725 </div>
3726 </div>
3727 </div>
3728 ` : ''}
3729
3730 <div class="ozon-parser-analytics-section">
3731 <div class="ozon-parser-analytics-section-title">Рекомендованные цены</div>
3732 <div class="ozon-parser-analytics-grid">
3733 <div class="ozon-parser-analytics-card" style="border: 2px solid #6c757d;">
3734 <div class="ozon-parser-analytics-card-title">⚡ Захват рынка</div>
3735 <div class="ozon-parser-analytics-card-value" style="color: #6c757d;">${analytics.recommendedPrices.marketCapture.price.toLocaleString('ru-RU')} ₽</div>
3736 <div style="font-size: 12px; color: #666; margin-top: 8px;">Оптимальная цена ниже текущей для максимизации прибыли</div>
3737 </div>
3738 <div class="ozon-parser-analytics-card" style="border: 2px solid #dc3545;">
3739 <div class="ozon-parser-analytics-card-title">✅ Оптимальная</div>
3740 <div class="ozon-parser-analytics-card-value" style="color: #dc3545;">${analytics.recommendedPrices.aggressive.price.toLocaleString('ru-RU')} ₽</div>
3741 <div style="font-size: 12px; color: #666; margin-top: 8px;">Низкая цена для максимальных продаж и быстрого роста позиций</div>
3742 </div>
3743 <div class="ozon-parser-analytics-card" style="border: 2px solid #28a745;">
3744 <div class="ozon-parser-analytics-card-title">🔥 Агрессивная</div>
3745 <div class="ozon-parser-analytics-card-value" style="color: #28a745;">${analytics.recommendedPrices.optimal.price.toLocaleString('ru-RU')} ₽</div>
3746 <div style="font-size: 12px; color: #666; margin-top: 8px;">Баланс между прибылью и объемом продаж</div>
3747 </div>
3748 </div>
3749 <div class="ozon-parser-info">
3750 💡 Расчет учитывает: средневзвешенную цену по выручке (вес 40%), оптимальную конверсию цена/заказы (вес 30%), медианную цену топ-10 (вес 30%). Товары из топ-5 позиций имеют удвоенный вес.
3751 </div>
3752 </div>
3753
3754 ${analytics.brandRecommendations && Object.keys(analytics.brandRecommendations).length > 0 ? `
3755 <div class="ozon-parser-analytics-section">
3756 <div class="ozon-parser-analytics-section-title">🎯 Рекомендации для ваших брендов</div>
3757 ${analytics.brandRecommendations.gls ? `
3758 <div style="background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 2px solid #ffc107;">
3759 <div style="font-size: 18px; font-weight: 600; margin-bottom: 15px; color: #333;">
3760 ${analytics.brandRecommendations.gls.brand}
3761 </div>
3762 <div style="margin-bottom: 15px;">
3763 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Текущие товары в выдаче: ${analytics.brandRecommendations.gls.currentProducts.length}</div>
3764 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Средняя позиция: ${analytics.brandRecommendations.gls.avgPosition}</div>
3765 <div style="font-size: 13px; color: #666;">Средняя цена: ${analytics.brandRecommendations.gls.avgPrice.toLocaleString('ru-RU')} ₽</div>
3766 </div>
3767 <details style="margin-top: 10px;" open>
3768 <summary style="cursor: pointer; font-size: 13px; color: #005bff; font-weight: 600;">Показать товары</summary>
3769 <table class="ozon-parser-analytics-table" style="margin-top: 10px;">
3770 <thead>
3771 <tr>
3772 <th>SKU</th>
3773 <th>Название</th>
3774 <th>Позиция</th>
3775 <th>Текущая цена</th>
3776 <th>⚡ Захват рынка</th>
3777 <th>✅ Оптимальная</th>
3778 <th>🔥 Агрессивная</th>
3779 </tr>
3780 </thead>
3781 <tbody>
3782 ${analytics.brandRecommendations.gls.currentProducts.map(p => {
3783 const rec = p.recommendations;
3784 if (!rec) return `
3785 <tr>
3786 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
3787 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
3788 <td>${p.position}</td>
3789 <td>${p.price.toLocaleString('ru-RU')} ₽</td>
3790 <td>—</td>
3791 <td>—</td>
3792 <td>—</td>
3793 </tr>
3794 `;
3795 return `
3796 <tr>
3797 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
3798 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
3799 <td>${p.position}</td>
3800 <td>
3801 <div style="font-weight: 600;">${p.price.toLocaleString('ru-RU')} ₽</div>
3802 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.currentBasePrice.toLocaleString('ru-RU')} ₽</div>
3803 <div style="font-size: 11px; color: #666; margin-top: 4px;">Выручка: ${p.revenue.toLocaleString('ru-RU')} ₽</div>
3804 <div style="font-size: 11px; color: #666;">Заказы: ${p.orders.toLocaleString('ru-RU')}</div>
3805 ${rec.skuDiscount ? `
3806 <div style="font-size: 11px; color: #ff6b00; font-weight: 600; margin-top: 4px;">
3807 Скидка Ozon: ${rec.skuDiscount}%
3808 </div>
3809 ` : ''}
3810 ${rec.currentProfit !== null ? `
3811 <div style="font-size: 11px; color: #005bff; font-weight: 600; margin-top: 4px;">
3812 Прибыль: ${rec.currentProfit.toFixed(0)} ₽
3813 </div>
3814 ` : ''}
3815 </td>
3816 <td style="background: #e9ecef;">
3817 <div style="font-weight: 600; color: #6c757d;">${rec.marketCapture.toLocaleString('ru-RU')} ₽</div>
3818 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.marketCaptureBasePrice.toLocaleString('ru-RU')} ₽</div>
3819 <div style="font-size: 11px; color: ${rec.priceChange.marketCapture < 0 ? '#dc3545' : '#28a745'};">
3820 ${rec.priceChange.marketCapture > 0 ? '+' : ''}${rec.priceChange.marketCapture}%
3821 </div>
3822 ${rec.forecast && rec.forecast.marketCapture ? `
3823 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
3824 Выручка: ${rec.forecast.marketCapture.revenueChange > 0 ? '+' : ''}${rec.forecast.marketCapture.revenueChange}%
3825 </div>
3826 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
3827 Заказы: ${rec.forecast.marketCapture.ordersChange > 0 ? '+' : ''}${rec.forecast.marketCapture.ordersChange}%
3828 </div>
3829 ${rec.forecast.marketCapture.profit !== null ? `
3830 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
3831 Прибыль: ${rec.forecast.marketCapture.profitChange > 0 ? '+' : ''}${rec.forecast.marketCapture.profitChange}%
3832 </div>
3833 <div style="font-size: 10px; color: #666;">
3834 ${rec.forecast.marketCapture.profit.toFixed(0)} ₽
3835 </div>
3836 ` : ''}
3837 ` : ''}
3838 </td>
3839 <td style="background: #d4edda;">
3840 <div style="font-weight: 600; color: #28a745;">${rec.aggressive.toLocaleString('ru-RU')} ₽</div>
3841 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.aggressiveBasePrice.toLocaleString('ru-RU')} ₽</div>
3842 <div style="font-size: 11px; color: ${rec.priceChange.aggressive < 0 ? '#dc3545' : '#28a745'};">
3843 ${rec.priceChange.aggressive > 0 ? '+' : ''}${rec.priceChange.aggressive}%
3844 </div>
3845 ${rec.forecast && rec.forecast.aggressive ? `
3846 <div style="font-size: 11px; color: ${rec.forecast.aggressive.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
3847 Выручка: ${rec.forecast.aggressive.revenueChange > 0 ? '+' : ''}${rec.forecast.aggressive.revenueChange}%
3848 </div>
3849 <div style="font-size: 11px; color: ${rec.forecast.aggressive.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
3850 Заказы: ${rec.forecast.aggressive.ordersChange > 0 ? '+' : ''}${rec.forecast.aggressive.ordersChange}%
3851 </div>
3852 ${rec.forecast.aggressive.profit !== null ? `
3853 <div style="font-size: 11px; color: ${rec.forecast.aggressive.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
3854 Прибыль: ${rec.forecast.aggressive.profitChange > 0 ? '+' : ''}${rec.forecast.aggressive.profitChange}%
3855 </div>
3856 <div style="font-size: 10px; color: #666;">
3857 ${rec.forecast.aggressive.profit.toFixed(0)} ₽
3858 </div>
3859 ` : ''}
3860 ` : ''}
3861 </td>
3862 <td style="background: #fff3cd;">
3863 <div style="font-weight: 600; color: #dc3545;">${rec.optimal.toLocaleString('ru-RU')} ₽</div>
3864 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.optimalBasePrice.toLocaleString('ru-RU')} ₽</div>
3865 <div style="font-size: 11px; color: ${rec.priceChange.optimal < 0 ? '#dc3545' : '#28a745'};">
3866 ${rec.priceChange.optimal > 0 ? '+' : ''}${rec.priceChange.optimal}%
3867 </div>
3868 ${rec.forecast && rec.forecast.optimal ? `
3869 <div style="font-size: 11px; color: ${rec.forecast.optimal.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
3870 Выручка: ${rec.forecast.optimal.revenueChange > 0 ? '+' : ''}${rec.forecast.optimal.revenueChange}%
3871 </div>
3872 <div style="font-size: 11px; color: ${rec.forecast.optimal.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
3873 Заказы: ${rec.forecast.optimal.ordersChange > 0 ? '+' : ''}${rec.forecast.optimal.ordersChange}%
3874 </div>
3875 ${rec.forecast.optimal.profit !== null ? `
3876 <div style="font-size: 11px; color: ${rec.forecast.optimal.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
3877 Прибыль: ${rec.forecast.optimal.profitChange > 0 ? '+' : ''}${rec.forecast.optimal.profitChange}%
3878 </div>
3879 <div style="font-size: 10px; color: #666;">
3880 ${rec.forecast.optimal.profit.toFixed(0)} ₽
3881 </div>
3882 ` : ''}
3883 ` : ''}
3884 </td>
3885 </tr>
3886 `;
3887 }).join('')}
3888 </tbody>
3889 </table>
3890 </details>
3891 </div>
3892 ` : ''}
3893 ${analytics.brandRecommendations.skinphoria ? `
3894 <div style="background: white; padding: 20px; border-radius: 8px; border: 2px solid #ffc107;">
3895 <div style="font-size: 18px; font-weight: 600; margin-bottom: 15px; color: #333;">
3896 ${analytics.brandRecommendations.skinphoria.brand}
3897 </div>
3898 <div style="margin-bottom: 15px;">
3899 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Текущие товары в выдаче: ${analytics.brandRecommendations.skinphoria.currentProducts.length}</div>
3900 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Средняя позиция: ${analytics.brandRecommendations.skinphoria.avgPosition}</div>
3901 <div style="font-size: 13px; color: #666;">Средняя цена: ${analytics.brandRecommendations.skinphoria.avgPrice.toLocaleString('ru-RU')} ₽</div>
3902 </div>
3903 <details style="margin-top: 10px;" open>
3904 <summary style="cursor: pointer; font-size: 13px; color: #005bff; font-weight: 600;">Показать товары</summary>
3905 <table class="ozon-parser-analytics-table" style="margin-top: 10px;">
3906 <thead>
3907 <tr>
3908 <th>SKU</th>
3909 <th>Название</th>
3910 <th>Позиция</th>
3911 <th>Текущая цена</th>
3912 <th>⚡ Захват рынка</th>
3913 <th>✅ Оптимальная</th>
3914 <th>🔥 Агрессивная</th>
3915 </tr>
3916 </thead>
3917 <tbody>
3918 ${analytics.brandRecommendations.skinphoria.currentProducts.map(p => {
3919 const rec = p.recommendations;
3920 if (!rec) return `
3921 <tr>
3922 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
3923 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
3924 <td>${p.position}</td>
3925 <td>${p.price.toLocaleString('ru-RU')} ₽</td>
3926 <td>—</td>
3927 <td>—</td>
3928 <td>—</td>
3929 </tr>
3930 `;
3931 return `
3932 <tr>
3933 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
3934 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
3935 <td>${p.position}</td>
3936 <td>
3937 <div style="font-weight: 600;">${p.price.toLocaleString('ru-RU')} ₽</div>
3938 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.currentBasePrice.toLocaleString('ru-RU')} ₽</div>
3939 <div style="font-size: 11px; color: #666; margin-top: 4px;">Выручка: ${p.revenue.toLocaleString('ru-RU')} ₽</div>
3940 <div style="font-size: 11px; color: #666;">Заказы: ${p.orders.toLocaleString('ru-RU')}</div>
3941 ${rec.skuDiscount ? `
3942 <div style="font-size: 11px; color: #ff6b00; font-weight: 600; margin-top: 4px;">
3943 Скидка Ozon: ${rec.skuDiscount}%
3944 </div>
3945 ` : ''}
3946 ${rec.currentProfit !== null ? `
3947 <div style="font-size: 11px; color: #005bff; font-weight: 600; margin-top: 4px;">
3948 Прибыль: ${rec.currentProfit.toFixed(0)} ₽
3949 </div>
3950 ` : ''}
3951 </td>
3952 <td style="background: #e9ecef;">
3953 <div style="font-weight: 600; color: #6c757d;">${rec.marketCapture.toLocaleString('ru-RU')} ₽</div>
3954 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.marketCaptureBasePrice.toLocaleString('ru-RU')} ₽</div>
3955 <div style="font-size: 11px; color: ${rec.priceChange.marketCapture < 0 ? '#dc3545' : '#28a745'};">
3956 ${rec.priceChange.marketCapture > 0 ? '+' : ''}${rec.priceChange.marketCapture}%
3957 </div>
3958 ${rec.forecast && rec.forecast.marketCapture ? `
3959 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
3960 Выручка: ${rec.forecast.marketCapture.revenueChange > 0 ? '+' : ''}${rec.forecast.marketCapture.revenueChange}%
3961 </div>
3962 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
3963 Заказы: ${rec.forecast.marketCapture.ordersChange > 0 ? '+' : ''}${rec.forecast.marketCapture.ordersChange}%
3964 </div>
3965 ${rec.forecast.marketCapture.profit !== null ? `
3966 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
3967 Прибыль: ${rec.forecast.marketCapture.profitChange > 0 ? '+' : ''}${rec.forecast.marketCapture.profitChange}%
3968 </div>
3969 <div style="font-size: 10px; color: #666;">
3970 ${rec.forecast.marketCapture.profit.toFixed(0)} ₽
3971 </div>
3972 ` : ''}
3973 ` : ''}
3974 </td>
3975 <td style="background: #d4edda;">
3976 <div style="font-weight: 600; color: #28a745;">${rec.aggressive.toLocaleString('ru-RU')} ₽</div>
3977 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.aggressiveBasePrice.toLocaleString('ru-RU')} ₽</div>
3978 <div style="font-size: 11px; color: ${rec.priceChange.aggressive < 0 ? '#dc3545' : '#28a745'};">
3979 ${rec.priceChange.aggressive > 0 ? '+' : ''}${rec.priceChange.aggressive}%
3980 </div>
3981 ${rec.forecast && rec.forecast.aggressive ? `
3982 <div style="font-size: 11px; color: ${rec.forecast.aggressive.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
3983 Выручка: ${rec.forecast.aggressive.revenueChange > 0 ? '+' : ''}${rec.forecast.aggressive.revenueChange}%
3984 </div>
3985 <div style="font-size: 11px; color: ${rec.forecast.aggressive.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
3986 Заказы: ${rec.forecast.aggressive.ordersChange > 0 ? '+' : ''}${rec.forecast.aggressive.ordersChange}%
3987 </div>
3988 ${rec.forecast.aggressive.profit !== null ? `
3989 <div style="font-size: 11px; color: ${rec.forecast.aggressive.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
3990 Прибыль: ${rec.forecast.aggressive.profitChange > 0 ? '+' : ''}${rec.forecast.aggressive.profitChange}%
3991 </div>
3992 <div style="font-size: 10px; color: #666;">
3993 ${rec.forecast.aggressive.profit.toFixed(0)} ₽
3994 </div>
3995 ` : ''}
3996 ` : ''}
3997 </td>
3998 <td style="background: #fff3cd;">
3999 <div style="font-weight: 600; color: #dc3545;">${rec.optimal.toLocaleString('ru-RU')} ₽</div>
4000 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.optimalBasePrice.toLocaleString('ru-RU')} ₽</div>
4001 <div style="font-size: 11px; color: ${rec.priceChange.optimal < 0 ? '#dc3545' : '#28a745'};">
4002 ${rec.priceChange.optimal > 0 ? '+' : ''}${rec.priceChange.optimal}%
4003 </div>
4004 ${rec.forecast && rec.forecast.optimal ? `
4005 <div style="font-size: 11px; color: ${rec.forecast.optimal.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
4006 Выручка: ${rec.forecast.optimal.revenueChange > 0 ? '+' : ''}${rec.forecast.optimal.revenueChange}%
4007 </div>
4008 <div style="font-size: 11px; color: ${rec.forecast.optimal.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
4009 Заказы: ${rec.forecast.optimal.ordersChange > 0 ? '+' : ''}${rec.forecast.optimal.ordersChange}%
4010 </div>
4011 ${rec.forecast.optimal.profit !== null ? `
4012 <div style="font-size: 11px; color: ${rec.forecast.optimal.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
4013 Прибыль: ${rec.forecast.optimal.profitChange > 0 ? '+' : ''}${rec.forecast.optimal.profitChange}%
4014 </div>
4015 <div style="font-size: 10px; color: #666;">
4016 ${rec.forecast.optimal.profit.toFixed(0)} ₽
4017 </div>
4018 ` : ''}
4019 ` : ''}
4020 </td>
4021 </tr>
4022 `;
4023 }).join('')}
4024 </tbody>
4025 </table>
4026 </details>
4027 </div>
4028 ` : ''}
4029 </div>
4030 ` : ''}
4031
4032 <div class="ozon-parser-analytics-section">
4033 <div class="ozon-parser-analytics-section-title">Общая статистика</div>
4034 <div class="ozon-parser-analytics-grid">
4035 <div class="ozon-parser-analytics-card">
4036 <div class="ozon-parser-analytics-card-title">Общая выручка</div>
4037 <div class="ozon-parser-analytics-card-value">${analytics.totalRevenue.toLocaleString('ru-RU')} ₽</div>
4038 </div>
4039 <div class="ozon-parser-analytics-card">
4040 <div class="ozon-parser-analytics-card-title">Всего заказов</div>
4041 <div class="ozon-parser-analytics-card-value">${analytics.totalOrders.toLocaleString('ru-RU')}</div>
4042 </div>
4043 <div class="ozon-parser-analytics-card">
4044 <div class="ozon-parser-analytics-card-title">Средний чек</div>
4045 <div class="ozon-parser-analytics-card-value">${analytics.avgPrice.toLocaleString('ru-RU')} ₽</div>
4046 </div>
4047 </div>
4048 </div>
4049
4050 <div class="ozon-parser-analytics-section">
4051 <div class="ozon-parser-analytics-section-title">Топ-5 товаров по выручке</div>
4052 <table class="ozon-parser-analytics-table">
4053 <thead>
4054 <tr>
4055 <th>Товар</th>
4056 <th>Бренд</th>
4057 <th>Цена</th>
4058 <th>Выручка</th>
4059 <th>Доля</th>
4060 </tr>
4061 </thead>
4062 <tbody>
4063 `;
4064
4065 analytics.topProducts.forEach(product => {
4066 tableHTML += `
4067 <tr>
4068 <td style="max-width: 300px; white-space: normal;">${product.name || '—'}</td>
4069 <td>${product.brand}</td>
4070 <td>${product.price.toLocaleString('ru-RU')} ₽</td>
4071 <td>${product.revenue.toLocaleString('ru-RU')} ₽</td>
4072 <td>${product.revenueShare}%</td>
4073 </tr>
4074 `;
4075 });
4076
4077 tableHTML += `
4078 </tbody>
4079 </table>
4080 </div>
4081
4082 <div class="ozon-parser-analytics-section">
4083 <div class="ozon-parser-analytics-section-title">Ценовые сегменты</div>
4084 <table class="ozon-parser-analytics-table">
4085 <thead>
4086 <tr>
4087 <th>Сегмент</th>
4088 <th>Диапазон цен</th>
4089 <th>Товаров</th>
4090 <th>Средняя цена</th>
4091 <th>Выручка</th>
4092 <th>Доля выручки</th>
4093 </tr>
4094 </thead>
4095 <tbody>
4096 `;
4097
4098 analytics.priceSegments.forEach(segment => {
4099 tableHTML += `
4100 <tr>
4101 <td>${segment.name}</td>
4102 <td>${segment.priceRange}</td>
4103 <td>${segment.count}</td>
4104 <td>${segment.avgPrice.toLocaleString('ru-RU')} ₽</td>
4105 <td>${segment.revenue.toLocaleString('ru-RU')} ₽</td>
4106 <td>
4107 <div style="display: flex; align-items: center; gap: 10px;">
4108 <div class="ozon-parser-analytics-bar" style="width: ${segment.revenueShare}%; min-width: 20px;"></div>
4109 <span>${segment.revenueShare}%</span>
4110 </div>
4111 </td>
4112 </tr>
4113 `;
4114 });
4115
4116 tableHTML += `
4117 </tbody>
4118 </table>
4119 </div>
4120 </div>
4121 `;
4122
4123 container.innerHTML = tableHTML;
4124 console.log('Ozon Product Parser: Results displayed successfully');
4125 } catch (error) {
4126 console.error('Ozon Product Parser: Error in displayResults:', error);
4127 container.innerHTML = '<p>Ошибка при отображении результатов: ' + error.message + '</p>';
4128 }
4129 }
4130
4131 // Инициализация
4132 function init() {
4133 console.log('Ozon Product Parser: Initializing...');
4134
4135 if (document.readyState === 'loading') {
4136 document.addEventListener('DOMContentLoaded', () => {
4137 addStyles();
4138 createUI();
4139 continueParsingIfActive();
4140 continueDiscountCalculationIfActive();
4141 });
4142 } else {
4143 addStyles();
4144 createUI();
4145 continueParsingIfActive();
4146 continueDiscountCalculationIfActive();
4147 }
4148 }
4149
4150 init();
4151})();